diff --git a/jdk_17_gradle/cs/rest/komga/.editorconfig b/jdk_17_gradle/cs/rest/komga/.editorconfig new file mode 100644 index 000000000..f6bcbd02e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/.editorconfig @@ -0,0 +1,36 @@ +root=true + +[*] +charset=utf-8 +end_of_line=lf +indent_size=4 +indent_style=space +insert_final_newline=true +max_line_length=120 +tab_width=4 +trim_trailing_whitespace=true + +[{*.ats,*.ts}] +indent_size=2 +tab_width=2 + +[{*.cjs,*.js}] +indent_size=2 +tab_width=2 + +[{*.kts,*.kt}] +indent_size=2 +tab_width=2 +max_line_length=off +ij_kotlin_allow_trailing_comma=true +ij_kotlin_allow_trailing_comma_on_call_site=true +ij_kotlin_line_break_after_multiline_when_entry=false +ktlint_standard_blank-line-between-when-conditions=disabled +ktlint_standard_when-entry-bracing=disabled +ktlint_standard_multiline-if-else=disabled + +[{*.yml,*.yaml}] +indent_size=2 + +[{.eslintrc,bowerrc,.prettierrc,.releaserc,jest.config,.stylelintrc,.babelrc,*.jsb3,*.jsb2,*.json}] +indent_size=2 diff --git a/jdk_17_gradle/cs/rest/komga/.gitattributes b/jdk_17_gradle/cs/rest/komga/.gitattributes new file mode 100644 index 000000000..176a458f9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/jdk_17_gradle/cs/rest/komga/.gitignore b/jdk_17_gradle/cs/rest/komga/.gitignore new file mode 100644 index 000000000..7e048bb66 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/.gitignore @@ -0,0 +1,59 @@ +### Gradle +.gradle +!gradle/wrapper/gradle-wrapper.jar + +### Kotlin +.kotlin + +### NodeJS +node_modules + +### Logs +*.log +*.log.*.gz + +### Temporary files +*.tmp +*.bak + +### SQLite databases +*.sqlite +*.sqlite-journal + +### STS +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans + +### IntelliJ IDEA +.idea/* +!/.idea/codeStyles/ +!/.idea/runConfigurations/ +*.iws +*.iml +*.ipr + +### IntelliJ Fleet +.fleet/ + +### NetBeans +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +### Komga +/komga/src/main/resources/public/ +/config-dir/ +application-oauth2.yml +/benchmark +/release_notes + +### Conveyor +output/ +secret/ diff --git a/jdk_17_gradle/cs/rest/komga/.svu.yml b/jdk_17_gradle/cs/rest/komga/.svu.yml new file mode 100644 index 000000000..785d247bd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/.svu.yml @@ -0,0 +1,3 @@ +tag: + prefix: '' + pattern: '[0-9]*' \ No newline at end of file diff --git a/jdk_17_gradle/cs/rest/komga/AGENTS.md b/jdk_17_gradle/cs/rest/komga/AGENTS.md new file mode 100644 index 000000000..b3cb19469 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/AGENTS.md @@ -0,0 +1,7 @@ +# Project Guidelines + +## Project Structure +- `komga`: Backend +- `komga-tray`: Desktop application wrapper +- `komga-webui`: Legacy frontend +- `next-ui`: Frontend diff --git a/jdk_17_gradle/cs/rest/komga/CONTRIBUTING.md b/jdk_17_gradle/cs/rest/komga/CONTRIBUTING.md new file mode 100644 index 000000000..4cd8ddaf2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/CONTRIBUTING.md @@ -0,0 +1,6 @@ +1. **Before reporting a new issue, take a look at the [FAQ](https://komga.org/docs/faq/), the [changelog](https://github.com/gotson/komga/blob/master/CHANGELOG.md) and the already opened [issues](https://github.com/gotson/komga/issues).** +1. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/678794935368941569?label=Discord)](https://discord.gg/TdRpkDu) +1. **DO NOT** reply on existing issues to say _"+1"_ or _"I am interested in this"_. +1. **DO** show your enthusiasm for an existing issue by adding a :+1: reaction on the first message in the discussion. +1. **DO NOT** ask for any ETA or roadmap for a feature. This is an open-source projects and contributors work on it on their free time. They don't work for you. +1. Use the issue templates provided and fill all the details. diff --git a/jdk_17_gradle/cs/rest/komga/DEVELOPING.md b/jdk_17_gradle/cs/rest/komga/DEVELOPING.md new file mode 100644 index 000000000..577c510c1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/DEVELOPING.md @@ -0,0 +1,18 @@ +# Development guidelines + +Thanks a lot for contributing to Komga! + +## Commit messages + +Komga's commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) standard. This enables automatic versioning, releases, and release notes generation. + +## Project organization + +Komga is composed of 4 projects: +- `komga`: a Spring Boot backend server that hosts the APIs, but also serves the static assets of the frontend. +- `komga-webui`: the legacy VueJS frontend, built at compile time and served by the backend at runtime. +- `next-ui`: the new VueJS frontend, built at compile time and served by the backend at runtime. +- `komga-tray`: a thin desktop wrapper that displays a tray-icon + +Check the `README.md` files in each project for more details. + diff --git a/jdk_17_gradle/cs/rest/komga/DOCKERHUB.md b/jdk_17_gradle/cs/rest/komga/DOCKERHUB.md new file mode 100644 index 000000000..e6edaf4d9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/DOCKERHUB.md @@ -0,0 +1,16 @@ +[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/komga?label=OpenCollective%20Sponsors&color=success)](https://opencollective.com/komga) ![GitHub Sponsors](https://img.shields.io/github/sponsors/gotson?label=Github%20Sponsors&color=success) +[![Discord](https://img.shields.io/discord/678794935368941569?label=Discord&color=blue)](https://discord.gg/TdRpkDu) + +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gotson/komga/ci.yml?branch=master)](https://github.com/gotson/komga/actions?query=workflow%3ACI+branch%3Amaster) +[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/gotson/komga?color=blue&label=download&sort=semver)](https://github.com/gotson/komga/releases) +[![Docker Pulls](https://img.shields.io/docker/pulls/gotson/komga)](https://hub.docker.com/r/gotson/komga) + +[![Translation status](https://hosted.weblate.org/widgets/komga/-/webui/svg-badge.svg)](https://hosted.weblate.org/engage/komga/) + +# ![app icon](https://github.com/gotson/komga/raw/master/.github/readme-images/app-icon.png) Komga + +[Komga](https://github.com/gotson/komga) is a media server for your comics, mangas, BDs, magazines and eBooks. + +## Usage + +Please refer to the [official documentation](https://komga.org/docs/installation/docker). diff --git a/jdk_17_gradle/cs/rest/komga/ERRORCODES.md b/jdk_17_gradle/cs/rest/komga/ERRORCODES.md new file mode 100644 index 000000000..2e7bdefee --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/ERRORCODES.md @@ -0,0 +1,45 @@ + +# Error codes + +| Code | Description | +|--------------|---------------------------------------------------------| +| ERR_1000 | File could not be accessed during analysis | +| ERR_1001 | Media type is not supported during analysis | +| ERR_1002 | Encrypted RAR archives are not supported | +| ERR_1003 | Solid RAR archives are not supported | +| ERR_1004 | Multi-Volume RAR archives are not supported | +| ERR_1005 | Unknown error while analyzing book | +| ERR_1006 | Book does not contain any page | +| ERR_1007 | Some entries could not be analyzed | +| ERR_1008 | Unknown error while getting book's entries | +| ~~ERR_1009~~ | ~~A read list with that name already exists~~ | +| ~~ERR_1010~~ | ~~No books were matched within the read list request~~ | +| ~~ERR_1011~~ | ~~No unique match for series~~ | +| ~~ERR_1012~~ | ~~No match for series~~ | +| ~~ERR_1013~~ | ~~No unique match for book number within series~~ | +| ~~ERR_1014~~ | ~~No match for book number within series~~ | +| ERR_1015 | Error while deserializing ComicRack ReadingList | +| ERR_1016 | Directory not accessible or not a directory | +| ERR_1017 | Cannot scan folder that is part of an existing library | +| ERR_1018 | File not found | +| ERR_1019 | Cannot import file that is part of an existing library | +| ERR_1020 | Book to upgrade does not belong to provided series | +| ERR_1021 | Destination file already exists | +| ERR_1022 | Newly imported book could not be scanned | +| ERR_1023 | Book already present in ReadingList | +| ERR_1024 | OAuth2 login error: no email attribute | +| ERR_1025 | OAuth2 login error: no local user exist with that email | +| ERR_1026 | OpenIDConnect login error: email not verified | +| ERR_1027 | OpenIDConnect login error: no email_verified attribute | +| ERR_1028 | OpenIDConnect login error: no email attribute | +| ERR_1029 | ComicRack CBL does not contain any Book element | +| ERR_1030 | ComicRack CBL has no Name element | +| ERR_1031 | ComicRack CBL Book is missing series or number | +| ERR_1032 | EPUB file has wrong media type | +| ERR_1033 | Some entries are missing | +| ERR_1034 | An API key with that comment already exists | +| ERR_1035 | Error while getting EPUB TOC | +| ERR_1036 | Error while getting EPUB Landmarks | +| ERR_1037 | Error while getting EPUB page list | +| ERR_1038 | Error while getting EPUB divina pages | +| ERR_1039 | Error while getting EPUB positions | diff --git a/jdk_17_gradle/cs/rest/komga/LICENSE b/jdk_17_gradle/cs/rest/komga/LICENSE new file mode 100644 index 000000000..266fd0c09 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Gauthier Roebroeck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jdk_17_gradle/cs/rest/komga/PRIVACY.md b/jdk_17_gradle/cs/rest/komga/PRIVACY.md new file mode 100644 index 000000000..71da49843 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/PRIVACY.md @@ -0,0 +1,17 @@ +# Privacy Policy + +Komga is a self-hosted application. It does not transmit any data outside the machine hosting it. + +## Data collected + +Data collected is stored in the local SQLite database required for the application to function, and never leaves your +machine. + +### Information provided by the user(s) + +- **Email address**: required to create an account + +### Information collected automatically + +- **IP address and User-Agent of authentication connections**: stored in the local database during authentication + attempts. That information is automatically deleted after one month. diff --git a/jdk_17_gradle/cs/rest/komga/README.md b/jdk_17_gradle/cs/rest/komga/README.md new file mode 100644 index 000000000..d60db0d61 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/README.md @@ -0,0 +1,62 @@ +[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/komga?label=OpenCollective%20Sponsors&color=success)](https://opencollective.com/komga) [![GitHub Sponsors](https://img.shields.io/github/sponsors/gotson?label=Github%20Sponsors&color=success)](https://github.com/sponsors/gotson) +[![Discord](https://img.shields.io/discord/678794935368941569?label=Discord&color=blue)](https://discord.gg/TdRpkDu) + +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gotson/komga/tests.yml?branch=master)](https://github.com/gotson/komga/actions?query=workflow%3ATests+branch%3Amaster) +[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/gotson/komga?color=blue&label=download&sort=semver)](https://github.com/gotson/komga/releases) [![GitHub all releases](https://img.shields.io/github/downloads/gotson/komga/total?color=blue&label=github%20downloads)](https://github.com/gotson/komga/releases) +[![Docker Pulls](https://img.shields.io/docker/pulls/gotson/komga)](https://hub.docker.com/r/gotson/komga) + +[![Translation status](https://hosted.weblate.org/widgets/komga/-/webui/svg-badge.svg)](https://hosted.weblate.org/engage/komga/) + +# ![app icon](./.github/readme-images/app-icon.png) Komga + +Komga is a media server for your comics, mangas, BDs, magazines and eBooks. + +#### Chat on [Discord](https://discord.gg/TdRpkDu) + +## Features + +- Browse libraries, series and books via a responsive web UI that works on desktop, tablets and phones +- Organize your library with collections and read lists +- Edit metadata for your series and books +- Import embedded metadata automatically +- Webreader with multiple reading modes +- Manage multiple users, with per-library access control, age restrictions, and labels restrictions +- Offers a REST API, many community tools and scripts can interact with Komga +- OPDS v1 and v2 support +- Kobo Sync with your Kobo eReader +- KOReader Sync +- Download book files, whole series, or read lists +- Duplicate files detection +- Duplicate pages detection and removal +- Import books from outside your libraries directly into your series folder +- Import ComicRack `cbl` read lists + +## Installation + +Refer to the [website](https://komga.org/docs/category/installation) for instructions. + +## Documentation + +Head over to our [website](https://komga.org) for more information. + +## Develop in Komga + +Check the [development guidelines](./DEVELOPING.md). + +## Translation + +[![Translation status](https://hosted.weblate.org/widgets/komga/-/webui/horizontal-auto.svg)](https://hosted.weblate.org/engage/komga/) + +## Powered by + +[![Jetbrains_logo](./.github/readme-images/jetbrains.svg)](https://www.jetbrains.com/?from=Komga) + +Thanks to [JetBrains](https://www.jetbrains.com/?from=Komga) for providing the development environment that helps us develop Komga. + +[![Chromatic logo](https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png)](https://www.chromatic.com) + +Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. + +## Credits + +The Komga icon is based on an icon made by [Freepik](https://www.freepik.com/home) from www.flaticon.com diff --git a/jdk_17_gradle/cs/rest/komga/build.gradle.kts b/jdk_17_gradle/cs/rest/komga/build.gradle.kts new file mode 100644 index 000000000..7ca78685c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/build.gradle.kts @@ -0,0 +1,191 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +import org.jreleaser.model.Active +import org.jreleaser.model.Distribution.DistributionType.SINGLE_JAR +import org.jreleaser.model.api.common.Apply +import kotlin.io.path.Path +import kotlin.io.path.exists + +plugins { + run { + val kotlinVersion = "2.4.10" + kotlin("jvm") version kotlinVersion + kotlin("plugin.spring") version kotlinVersion + kotlin("kapt") version kotlinVersion + } + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" + id("org.jreleaser") version "1.25.0" +} + +fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val unstableKeyword = listOf("ALPHA", "RC").any { version.uppercase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return unstableKeyword || !isStable +} + +group = "org.gotson" + +tasks.named("dependencyUpdates") { + // disallow release candidates as upgradable versions from stable versions + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } + gradleReleaseChannel = "current" + checkConstraints = true +} + +allprojects { + repositories { + mavenCentral() + } + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + configure { + version = "1.8.0" + filter { + exclude("**/generated-src/**") + exclude("**/generated/**") + } + } +} + +tasks.wrapper { + gradleVersion = "9.6.1" + distributionType = Wrapper.DistributionType.ALL +} + +jreleaser { + project { + description = "Media server for comics/mangas/BDs with API and OPDS support" + copyright = "Gauthier Roebroeck" + authors.add("Gauthier Roebroeck") + license = "MIT" + links { + homepage = "https://komga.org" + } + } + + release { + github { + discussionCategoryName = "Announcements" + skipTag = true + tagName = "{{projectVersion}}" + + changelog { + formatted = Active.ALWAYS + preset = "conventional-commits" + skipMergeCommits = true + links = true + content = (if (Path("./release_notes/release_notes.md").exists()) "{{#f_file_read}}{{basedir}}/release_notes/release_notes.md{{/f_file_read}}" else "") + + """ + ## Changelog + + {{changelogChanges}} + {{changelogContributors}} + """.trimIndent() + format = "- {{#commitIsConventional}}{{#conventionalCommitIsBreakingChange}}🚨 {{/conventionalCommitIsBreakingChange}}{{#conventionalCommitScope}}**{{conventionalCommitScope}}**: {{/conventionalCommitScope}}{{conventionalCommitDescription}}{{#conventionalCommitBreakingChangeContent}}: *{{conventionalCommitBreakingChangeContent}}*{{/conventionalCommitBreakingChangeContent}} ({{commitShortHash}}){{/commitIsConventional}}{{^commitIsConventional}}{{commitTitle}} ({{commitShortHash}}){{/commitIsConventional}}{{#commitHasIssues}}, closes{{#commitIssues}} {{issue}}{{/commitIssues}}{{/commitHasIssues}}" + hide { + uncategorized = true + contributors = listOf("Weblate", "GitHub", "semantic-release-bot", "[bot]", "github-actions") + } + excludeLabels.add("chore") + category { + title = "🏎 Perf" + key = "perf" + labels.add("perf") + order = 25 + } + category { + title = "🌐 Translation" + key = "i18n" + labels.add("i18n") + order = 70 + } + category { + title = "⚙️ Dependencies" + key = "dependencies" + labels.add("dependencies") + order = 80 + } + labeler { + label = "perf" + title = "regex:^(?:perf(?:\\(.*\\))?!?):\\s.*" + order = 120 + } + labeler { + label = "i18n" + title = "regex:^(?:i18n(?:\\(.*\\))?!?):\\s.*" + order = 130 + } + labeler { + label = "dependencies" + title = "regex:^(?:deps(?:\\(.*\\))?!?):\\s.*" + order = 140 + } + extraProperties.put("categorizeScopes", true) + append { + enabled = true + title = "# [{{projectVersion}}]({{repoUrl}}/compare/{{previousTagName}}...{{tagName}}) ({{#f_now}}YYYY-MM-dd{{/f_now}})" + target = rootDir.resolve("CHANGELOG.md") + content = + """ + {{changelogTitle}} + {{changelogChanges}} + """.trimIndent() + } + } + + issues { + enabled = true + comment = "🎉 This issue has been resolved in `{{tagName}}` ([Release Notes]({{releaseNotesUrl}}))" + applyMilestone = Apply.ALWAYS + label { + name = "released" + description = "Issue has been released" + color = "#ededed" + } + } + } + } + + distributions { + create("komga") { + active = Active.RELEASE + distributionType = SINGLE_JAR + artifact { + path = rootDir.resolve("komga/build/libs/komga-{{projectVersion}}.jar") + } + } + } + + packagers { + docker { + active = Active.RELEASE + continueOnError = false + templateDirectory = rootDir.resolve("komga/docker") + repository.active = Active.NEVER + buildArgs = listOf("--cache-from", "gotson/komga:latest") + imageNames = + listOf( + "komga:latest", + "komga:{{projectVersion}}", + "komga:{{projectVersionMajor}}.x", + ) + registries { + create("docker.io") { externalLogin = true } + create("ghcr.io") { externalLogin = true } + } + buildx { + enabled = true + createBuilder = false + platforms = + listOf( + "linux/amd64", + "linux/arm/v7", + "linux/arm64/v8", + ) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/conventionalcommit.json b/jdk_17_gradle/cs/rest/komga/conventionalcommit.json new file mode 100644 index 000000000..982aa0cc2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/conventionalcommit.json @@ -0,0 +1,69 @@ +{ + "types": { + "refactor": { + "description": "Changes which neither fix a bug nor add a feature" + }, + "fix": { + "description": "Changes which patch a bug" + }, + "feat": { + "description": "Changes which introduce a new feature" + }, + "build": { + "description": "Changes which affect the build system or external dependencies.
Example scopes: gulp, broccoli, npm" + }, + "chore": { + "description": "Changes which aren't user-facing" + }, + "style": { + "description": "Changes which don't affect code logic, such as white-spaces, formatting, missing semi-colons" + }, + "test": { + "description": "Changes which add missing tests or correct existing tests" + }, + "docs": { + "description": "Changes which affect documentation" + }, + "perf": { + "description": "Changes which improve performance" + }, + "i18n": { + "description": "Changes which affect translation" + }, + "deps": { + "description": "Changes which affect dependencies" + }, + "ci": { + "description": "Changes which affect CI configuration files and scripts.
Example scopes: travis, circle, browser-stack, sauce-labs" + }, + "revert": { + "description": "Changes which revert a previous commit" + } + }, + "footerTypes": [ + { + "name": "BREAKING-CHANGE", + "description": "The commit introduces breaking API changes" + }, + { + "name": "Closes", + "description": "The commit closes issues or pull requests" + }, + { + "name": "Implements", + "description": "The commit implements features" + }, + { + "name": "Author", + "description": "The commit's author" + }, + { + "name": "Co-authored-by", + "description": "The commit is co-authored by another person.
For multiple people use one line each" + }, + { + "name": "Refs", + "description": "The commit references other commits by their hash ID.
For multiple hash IDs use a comma as separator" + } + ] +} diff --git a/jdk_17_gradle/cs/rest/komga/gradle.properties b/jdk_17_gradle/cs/rest/komga/gradle.properties new file mode 100644 index 000000000..1c7b5d251 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/gradle.properties @@ -0,0 +1,2 @@ +version=1.26.3 +org.gradle.jvmargs=-Xmx2G diff --git a/jdk_17_gradle/cs/rest/komga/komga/README.md b/jdk_17_gradle/cs/rest/komga/komga/README.md new file mode 100644 index 000000000..15592257c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/README.md @@ -0,0 +1,45 @@ +## Backend development + +### Requirements + +- Java JDK version 21+ + +### Spring profiles + +Komga uses Spring Profiles extensively: +- `dev`: add more logging, disable periodic scanning, in-memory database, and enable CORS from `localhost:8081` (the frontend dev server) +- `localdb`: a dev profile that stores the database in `./localdb`. +- `noclaim`: will create initial users at startup if none exist and output users and passwords in the standard output + - if `dev` is active, will create `admin@example.org` with password `admin`, and `user@example.org` with password `user` + - if `dev` is not active, will create `admin@example.org` with a random password that will be shown in the logs + +### Gradle tasks + +The backend project uses `gradle` to run all the necessary tasks. If your IDE does not have `gradle` integration, you can run the tasks from the root directory using `./gradlew `. + +Here is a list of useful tasks: +- `bootRun`: run the application locally, useful for testing your changes. +- `test`: run automated tests. Always run this before committing. +- `jooq-codegen-primary`: generates the jOOQ DSL. + +`bootRun` needs to be run with a profile or list of profiles, usually: +- `dev,noclaim`: when testing with a blank database +- `dev,localdb,noclaim`: when testing with an existing database + +There are few ways you can run the task with a profile: +- `./gradlew bootRun --args='--spring.profiles.active=dev'` +- On Linux: `SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun` +- On Windows: +``` +SET SPRING_PROFILES_ACTIVE=dev +./gradlew bootRun +``` +- If you use IntelliJ, some Run Configurations are saved in the repository and available from the Gradle panel + +## Docker + +To build the Docker image, you need to: + +- have the webui built and copied to `/resources/public` +- prepare the docker image via JReleaser. To do so, run `./gradlew jreleaserPackage`. +- the `Dockerfile` will be available in `komga/build/jreleaser/package/docker/` diff --git a/jdk_17_gradle/cs/rest/komga/komga/build.gradle.kts b/jdk_17_gradle/cs/rest/komga/komga/build.gradle.kts new file mode 100644 index 000000000..5a0fae29c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/build.gradle.kts @@ -0,0 +1,413 @@ +import nu.studer.gradle.jooq.JooqGenerate +import org.flywaydb.gradle.task.FlywayMigrateTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.util.prefixIfNot +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("kapt") + id("org.springframework.boot") version libs.versions.springboot.get() + alias(libs.plugins.gradleGitProperties) + id("nu.studer.jooq") version "10.2.1" + id("org.flywaydb.flyway") version "13.1.0" + id("com.github.johnrengelman.processes") version "0.5.0" + id("org.springdoc.openapi-gradle-plugin") version "1.9.0" + id("com.google.devtools.ksp") version "2.3.1" + jacoco +} + +val benchmarkSourceSet = + sourceSets.create("benchmark") { + java { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().runtimeClasspath + } + } + +sourceSets { + // add a flyway sourceSet + val flyway = + create("flyway") { + compileClasspath += sourceSets.main.get().compileClasspath + runtimeClasspath += sourceSets.main.get().runtimeClasspath + } + // main sourceSet depends on the output of flyway sourceSet, and generated jooq classes + main { + java { + output.dir(flyway.output) + srcDir("build/generated-src/jooq/tasks") + } + } +} + +val benchmarkImplementation = + configurations.getByName("benchmarkImplementation") { + extendsFrom(configurations.testImplementation.get()) + } +val kaptBenchmark = + configurations.getByName("kaptBenchmark") { + extendsFrom(configurations.kaptTest.get()) + } + +dependencies { + implementation(kotlin("stdlib")) + implementation(kotlin("reflect")) + + api(platform(SpringBootPlugin.BOM_COORDINATES)) + + api("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-jooq") + implementation("org.springframework.session:spring-session-core") + implementation("com.github.gotson:spring-session-caffeine:2.1.0") + implementation("org.springframework.data:spring-data-commons") + + kapt("org.springframework.boot:spring-boot-configuration-processor:${libs.versions.springboot.get()}") + + implementation("org.flywaydb:flyway-core") + + api("io.github.oshai:kotlin-logging-jvm:8.0.4") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + + implementation("commons-io:commons-io:2.22.0") + implementation("org.apache.commons:commons-lang3:3.20.0") + implementation("commons-validator:commons-validator:1.11.0") + + implementation("org.apache.lucene:lucene-core:${libs.versions.lucene.get()}") + implementation("org.apache.lucene:lucene-analysis-common:${libs.versions.lucene.get()}") + implementation("org.apache.lucene:lucene-queryparser:${libs.versions.lucene.get()}") + implementation("org.apache.lucene:lucene-backward-codecs:${libs.versions.lucene.get()}") + + implementation("com.ibm.icu:icu4j:78.3") + + implementation("com.appmattus.crypto:cryptohash:1.0.2") + + implementation("org.apache.tika:tika-core:3.3.2") + implementation("org.apache.commons:commons-compress:1.28.0") + implementation("com.github.junrar:junrar:8.1.0") + implementation("org.apache.pdfbox:pdfbox:3.0.8") + implementation("net.grey-panther:natural-comparator:1.1") + implementation("org.jsoup:jsoup:1.23.1") + + implementation("net.coobird:thumbnailator:0.4.21") + runtimeOnly("com.twelvemonkeys.imageio:imageio-jpeg:${libs.versions.twelvemonkeys.get()}") + runtimeOnly("com.twelvemonkeys.imageio:imageio-tiff:${libs.versions.twelvemonkeys.get()}") + runtimeOnly("com.twelvemonkeys.imageio:imageio-webp:${libs.versions.twelvemonkeys.get()}") + runtimeOnly("com.github.gotson.nightmonkeys:imageio-jxl:${libs.versions.nightmonkeys.get()}") + runtimeOnly("com.github.gotson.nightmonkeys:imageio-heif:${libs.versions.nightmonkeys.get()}") + runtimeOnly("com.github.gotson.nightmonkeys:imageio-webp:${libs.versions.nightmonkeys.get()}") + // support for jpeg2000 + runtimeOnly("com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0") + runtimeOnly("org.apache.pdfbox:jbig2-imageio:3.0.5") + + // barcode scanning + implementation("com.google.zxing:core:3.5.4") + + implementation("com.jakewharton.byteunits:byteunits:0.9.1") + + implementation("com.github.f4b6a3:tsid-creator:5.2.6") + + implementation("com.github.ben-manes.caffeine:caffeine") + + implementation("org.xerial:sqlite-jdbc:${libs.versions.sqliteJdbc.get()}") + jooqGenerator("org.xerial:sqlite-jdbc:${libs.versions.sqliteJdbc.get()}") + + if (version.toString().endsWith(".0.0")) { + ksp("com.github.gotson.bestbefore:bestbefore-processor-kotlin:0.2.0") + } + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(module = "mockito-core") + } + testImplementation("org.springframework.security:spring-security-test") + testImplementation("com.ninja-squad:springmockk:4.0.2") // v5 needs Spring Framework v7 + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("com.google.jimfs:jimfs:1.3.1") + + testImplementation("com.tngtech.archunit:archunit-junit5:1.5.0") + + benchmarkImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + benchmarkImplementation("org.openjdk.jmh:jmh-core:1.37") + kaptBenchmark("org.openjdk.jmh:jmh-generator-annprocess:1.37") + kaptBenchmark("org.springframework.boot:spring-boot-configuration-processor:${libs.versions.springboot.get()}") + + developmentOnly("org.springframework.boot:spring-boot-devtools:${libs.versions.springboot.get()}") +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs = + listOf( + "-Xjsr305=strict", + "-Xemit-jvm-type-annotations", + "-opt-in=kotlin.time.ExperimentalTime", + ) + } +} + +val webui = "$rootDir/komga-webui" +val nextui = "$rootDir/next-ui" +tasks { + withType { + sourceCompatibility = "17" + targetCompatibility = "17" + } + + withType { + useJUnitPlatform() + systemProperty("spring.profiles.active", "test") + maxHeapSize = "1G" + } + + withType { + manifest { + attributes("Enable-Native-Access" to "ALL-UNNAMED") + } + } + + getByName("jar") { + enabled = true + } + + register("webuiCopyDist") { + description = "Copies the WebUI build into resources/public" + group = "web" + from("$webui/dist/") + into("$projectDir/src/main/resources/public/") + } + + register("webuiCopyIndex") { + description = "Copies the WebUI index.html into resources/public and injects Thymeleaf tags" + group = "web" + dependsOn("webuiCopyDist") + from("$webui/dist/index.html") + into("$projectDir/src/main/resources/public/") + filter { line -> + line.replace("((?:src|content|href)=\")([\\w]*/.*?)(\")".toRegex()) { + it.groups[0]?.value + " th:" + it.groups[1]?.value + "@{" + it.groups[2]?.value?.prefixIfNot("/") + "}" + it.groups[3]?.value + } + } + } + + register("nextuiCopyDist") { + description = "Copies the nextUI build into resources/public" + group = "web" + from("$nextui/dist/") + into("$projectDir/src/main/resources/public/") + excludes.add("index.html") // will be copied by 'nextuiCopyIndex' + mustRunAfter(getByName("webuiCopyDist")) + } + + // modifies index.html to inject ThymeLeaf th: tags + register("nextuiCopyIndex") { + description = "Copies the nextUI index.html into resources/public/index-next.html and injects Thymeleaf tags" + group = "web" + dependsOn("nextuiCopyDist") + from("$nextui/dist/index.html") + into("$projectDir/src/main/resources/public/") + filter { line -> + line.replace("((?:src|content|href)=\")([\\w]*/.*?)(\")".toRegex()) { + it.groups[0]?.value + " th:" + it.groups[1]?.value + "@{" + it.groups[2]?.value?.prefixIfNot("/") + "}" + it.groups[3]?.value + } + } + rename("index.html", "index-next.html") + } + + withType { + filesMatching("application*.yml") { + expand( + mapOf( + "version" to project.version.toString(), + "rootDir" to project.rootDir.absolutePath, + ), + ) + } + mustRunAfter(getByName("webuiCopyIndex"), getByName("nextuiCopyIndex")) + } + + register("benchmark") { + group = "benchmark" + inputs.files(benchmarkSourceSet.output) + testClassesDirs = benchmarkSourceSet.output.classesDirs + classpath = benchmarkSourceSet.runtimeClasspath + } +} + +springBoot { + buildInfo { + // prevent task bootBuildInfo to rerun every time + excludes = setOf("time") + properties { + // but rerun if the gradle.properties file changed + inputs.file("$rootDir/gradle.properties") + } + } +} + +val sqliteUrls = + mapOf( + "main" to "jdbc:sqlite:${project.layout.buildDirectory.get()}/generated/flyway/main/database.sqlite", + "tasks" to "jdbc:sqlite:${project.layout.buildDirectory.get()}/generated/flyway/tasks/tasks.sqlite", + ) +val sqliteMigrationDirs = + mapOf( + "main" to + listOf( + "$projectDir/src/flyway/resources/db/migration/sqlite", + "$projectDir/src/flyway/kotlin/db/migration/sqlite", + ), + "tasks" to + listOf( + "$projectDir/src/flyway/resources/tasks/migration/sqlite", +// "$projectDir/src/flyway/kotlin/tasks/migration/sqlite", + ), + ) + +tasks.register("flywayMigrateMain", FlywayMigrateTask::class) { + val id = "main" + url = sqliteUrls[id] + locations = arrayOf("classpath:db/migration/sqlite") + placeholders = + mapOf( + "library-file-hashing" to "true", + "library-scan-startup" to "false", + "delete-empty-collections" to "true", + "delete-empty-read-lists" to "true", + ) + // in order to include the Java migrations, flywayClasses must be run before flywayMigrate + dependsOn("flywayClasses") + sqliteMigrationDirs[id]?.forEach { inputs.dir(it) } + outputs.dir("${project.layout.buildDirectory.get()}/generated/flyway/$id") + doFirst { + delete(outputs.files) + mkdir("${project.layout.buildDirectory.get()}/generated/flyway/$id") + } + mixed = true +} + +tasks.register("flywayMigrateTasks", FlywayMigrateTask::class) { + val id = "tasks" + url = sqliteUrls[id] + locations = arrayOf("classpath:tasks/migration/sqlite") + // in order to include the Java migrations, flywayClasses must be run before flywayMigrate + dependsOn("flywayClasses") + sqliteMigrationDirs[id]?.forEach { inputs.dir(it) } + outputs.dir("${project.layout.buildDirectory.get()}/generated/flyway/$id") + doFirst { + delete(outputs.files) + mkdir("${project.layout.buildDirectory.get()}/generated/flyway/$id") + } + mixed = true +} + +buildscript { + configurations["classpath"].resolutionStrategy.eachDependency { + if (requested.group.startsWith("org.jooq") && requested.name.startsWith("jooq")) { + useVersion(libs.versions.jooq.get()) + } + } +} + +jooq { + version = libs.versions.jooq.get() + configurations { + create("main") { + jooqConfiguration.apply { + logging = org.jooq.meta.jaxb.Logging.WARN + jdbc.apply { + driver = "org.sqlite.JDBC" + url = sqliteUrls["main"] + } + generator.apply { + database.apply { + name = "org.jooq.meta.sqlite.SQLiteDatabase" + } + target.apply { + packageName = "org.gotson.komga.jooq.main" + } + } + } + } + create("tasks") { + jooqConfiguration.apply { + logging = org.jooq.meta.jaxb.Logging.WARN + jdbc.apply { + driver = "org.sqlite.JDBC" + url = sqliteUrls["tasks"] + } + generator.apply { + database.apply { + name = "org.jooq.meta.sqlite.SQLiteDatabase" + } + target.apply { + packageName = "org.gotson.komga.jooq.tasks" + } + } + } + } + } +} +tasks.named("generateJooq") { + sqliteMigrationDirs["main"]?.forEach { inputs.dir(it) } + allInputsDeclared = true + dependsOn("flywayMigrateMain") +} +tasks.named("generateTasksJooq") { + sqliteMigrationDirs["tasks"]?.forEach { inputs.dir(it) } + allInputsDeclared = true + dependsOn("flywayMigrateTasks") +} + +tasks.whenTaskAdded { + if (name == "kaptGenerateStubsKotlin") { + dependsOn("generateTasksJooq") + } +} + +tasks.runKtlintFormatOverMainSourceSet { + dependsOn("generateTasksJooq") +} +tasks.runKtlintCheckOverMainSourceSet { + dependsOn("generateTasksJooq") +} +tasks.compileKotlin { + dependsOn("generateTasksJooq") +} + +openApi { + outputDir = file("$projectDir/docs") + customBootRun { + args.add("--spring.profiles.active=claim,generate-openapi") + args.add("--server.port=8080") + } + waitTimeInSeconds.set(60) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) +} + +configure { + filter { + exclude("**/db/migration/**") + } +} + +project.afterEvaluate { + tasks.named("forkedSpringBootRun") { + mustRunAfter(tasks.bootJar) + mustRunAfter(tasks.jar) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/docker/Dockerfile.tpl b/jdk_17_gradle/cs/rest/komga/komga/docker/Dockerfile.tpl new file mode 100644 index 000000000..4820d4747 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/docker/Dockerfile.tpl @@ -0,0 +1,54 @@ +FROM eclipse-temurin:17-jre AS builder +ARG JAR={{distributionArtifactFile}} +WORKDIR /builder +COPY assembly/${JAR} application.jar +RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted + +# amd64 builder +FROM ubuntu:26.04 AS build-amd64 +ENV JAVA_HOME=/opt/java/openjdk +COPY --from=eclipse-temurin:23-jre $JAVA_HOME $JAVA_HOME +ENV PATH="${JAVA_HOME}/bin:${PATH}" +RUN apt -y update && \ + apt -y install ca-certificates locales libjxl-dev libheif-dev libwebp-dev wget curl && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen en_US.UTF-8 && \ + wget "https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit" -O /usr/bin/kepubify && \ + chmod +x /usr/bin/kepubify && \ + apt -y autoremove && rm -rf /var/lib/apt/lists/* +ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/x86_64-linux-gnu" + +# arm64 builder +FROM ubuntu:26.04 AS build-arm64 +ENV JAVA_HOME=/opt/java/openjdk +COPY --from=eclipse-temurin:23-jre $JAVA_HOME $JAVA_HOME +ENV PATH="${JAVA_HOME}/bin:${PATH}" +RUN apt -y update && \ + apt -y install ca-certificates locales libjxl-dev libheif-dev libwebp-dev wget curl && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen en_US.UTF-8 && \ + wget "https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-arm64" -O /usr/bin/kepubify && \ + chmod +x /usr/bin/kepubify && \ + apt -y autoremove && rm -rf /var/lib/apt/lists/* +ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/aarch64-linux-gnu" + +# arm builder: uses temurin-17, as arm32 support was dropped in JDK 21 +FROM eclipse-temurin:17-jre AS build-arm +RUN apt -y update && \ + apt -y install wget curl && \ + wget "https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-arm" -O /usr/bin/kepubify && \ + chmod +x /usr/bin/kepubify && \ + apt -y autoremove && rm -rf /var/lib/apt/lists/* + +FROM build-${TARGETARCH} AS runner +VOLUME /config +WORKDIR /app +COPY --from=builder /builder/extracted/dependencies/ ./ +COPY --from=builder /builder/extracted/spring-boot-loader/ ./ +COPY --from=builder /builder/extracted/snapshot-dependencies/ ./ +COPY --from=builder /builder/extracted/application/ ./ +ENV KOMGA_CONFIGDIR="/config" +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' +ENTRYPOINT ["java", "-Dspring.profiles.include=docker", "--enable-native-access=ALL-UNNAMED", "-jar", "application.jar", "--spring.config.additional-location=file:/config/"] +EXPOSE 25600 +LABEL org.opencontainers.image.source="https://github.com/gotson/komga" diff --git a/jdk_17_gradle/cs/rest/komga/komga/docs/openapi.json b/jdk_17_gradle/cs/rest/komga/komga/docs/openapi.json new file mode 100644 index 000000000..7f65088dc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/docs/openapi.json @@ -0,0 +1,14694 @@ +{ + "openapi": "3.1.0", + "info": { + "description": "Komga REST API.\n\n## Reference\n\nCheck the API reference:\n- on the [Komga website](https://komga.org/docs/openapi/komga-api)\n- on any running Komga instance at `/swagger-ui.html`\n- on [GitHub](https://raw.githubusercontent.com/gotson/komga/refs/heads/master/komga/docs/openapi.json)\n\n## Authentication\n\nMost endpoints require authentication. Authentication is done using either:\n- Basic Authentication\n- Passing an API Key in the `X-API-Key` header\n\n## Sessions\n\nUpon successful authentication, a session is created, and can be reused.\n\n- By default, a `KOMGA-SESSION` cookie is set via `Set-Cookie` response header. This works well for browsers and clients that can handle cookies.\n- If you specify a header `X-Auth-Token` during authentication, the session ID will be returned via this same header. You can then pass that header again for subsequent requests to reuse the session.\n\nIf you need to set the session cookie later on, you can call `/api/v1/login/set-cookie` with `X-Auth-Token`. The response will contain the `Set-Cookie` header.\n\n## Remember Me\n\nDuring authentication, if a request parameter `remember-me` is passed and set to `true`, the server will also return a `komga-remember-me` cookie. This cookie will be used to login automatically even if the session has expired.\n\n## Logout\n\nYou can explicitly logout an existing session by calling `/api/logout`. This would return a `204`.\n\n## Deprecation\n\nAPI endpoints marked as deprecated will be removed in the next major version.", + "license": { + "name": "MIT", + "url": "https://github.com/gotson/komga/blob/master/LICENSE" + }, + "title": "Komga API", + "version": "1.26.3" + }, + "externalDocs": { + "description": "Komga documentation", + "url": "https://komga.org" + }, + "servers": [ + { + "url": "https://demo.komga.org", + "description": "Demo server" + }, + { + "url": "http://localhost:{port}", + "description": "Local development server", + "variables": { + "port": { + "enum": [ + "8080", + "25600" + ], + "default": "25600" + } + } + } + ], + "security": [ + { + "basicAuth": [] + }, + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Deprecated" + }, + { + "description": "Manage libraries.", + "name": "Libraries" + }, + { + "description": "Manage series.", + "name": "Series" + }, + { + "description": "Manage posters for series.", + "name": "Series Poster" + }, + { + "description": "Manage books.", + "name": "Books" + }, + { + "description": "Manage posters for books.", + "name": "Book Poster" + }, + { + "name": "Book Pages" + }, + { + "name": "WebPub Manifest" + }, + { + "name": "Import" + }, + { + "description": "Provide font files and CSS for the Epub Reader.", + "name": "Fonts" + }, + { + "description": "Manage duplicate pages. Duplicate pages are identified by a page hash.", + "name": "Duplicate Pages" + }, + { + "description": "Manage collections.", + "name": "Collections" + }, + { + "description": "Manage posters for collections.", + "name": "Collection Poster" + }, + { + "name": "Collection Series" + }, + { + "description": "Manage readlists.", + "name": "Readlists" + }, + { + "description": "Manage posters for readlists.", + "name": "Readlist Poster" + }, + { + "name": "Readlist Books" + }, + { + "description": "Retrieve referential metadata from all items in the Komga server.", + "name": "Referential metadata" + }, + { + "description": "Manage current user.", + "name": "Current user" + }, + { + "description": "Manage users.", + "name": "Users" + }, + { + "description": "Manage API Keys", + "name": "API Keys" + }, + { + "name": "User session" + }, + { + "description": "List registered OAuth2 providers", + "name": "OAuth2" + }, + { + "description": "Sync points are automatically created during a Kobo sync.", + "name": "Sync points" + }, + { + "description": "Claim a freshly installed Komga server.", + "name": "Claim" + }, + { + "description": "Manage server tasks", + "name": "Tasks" + }, + { + "description": "Server events history", + "name": "History" + }, + { + "description": "List files from the host server\u0027s file system", + "name": "File system" + }, + { + "description": "Store and retrieve server settings", + "name": "Server settings" + }, + { + "description": "Retrieve releases information", + "name": "Releases" + }, + { + "description": "Retrieve announcements from the Komga website", + "name": "Announcements" + }, + { + "description": "Manage server", + "name": "Management" + }, + { + "name": "Mihon" + }, + { + "name": "ComicRack" + }, + { + "description": "Store and retrieve global and per-user settings. Those settings are not used by Komga itself, but can be stored for convenience by client applications.", + "name": "Client settings" + } + ], + "paths": { + "/actuator/info": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getActuatorInfo", + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "Example": { + "value": "{\n \"git\": {\n \"branch\": \"master\",\n \"commit\": {\n \"id\": \"9be980d\",\n \"time\": \"2025-03-12T03:40:38Z\"\n }\n },\n \"build\": {\n \"artifact\": \"komga\",\n \"name\": \"komga\",\n \"version\": \"1.21.2\",\n \"group\": \"komga\"\n },\n \"java\": {\n \"version\": \"23.0.2\",\n \"vendor\": {\n \"name\": \"Eclipse Adoptium\",\n \"version\": \"Temurin-23.0.2+7\"\n },\n \"runtime\": {\n \"name\": \"OpenJDK Runtime Environment\",\n \"version\": \"23.0.2+7\"\n },\n \"jvm\": {\n \"name\": \"OpenJDK 64-Bit Server VM\",\n \"vendor\": \"Eclipse Adoptium\",\n \"version\": \"23.0.2+7\"\n }\n },\n \"os\": {\n \"name\": \"Linux\",\n \"version\": \"6.8.0-57-generic\",\n \"arch\": \"amd64\"\n }\n}" + } + }, + "schema": { + "additionalProperties": true + } + } + }, + "description": "OK" + } + }, + "summary": "Get server information", + "tags": [ + "Management" + ] + }, + "summary": "Get general information about the server" + }, + "/api/logout": { + "get": { + "description": "Invalidates the current session and clean up any remember-me authentication.", + "operationId": "postLogout", + "responses": { + "204": { + "description": "No Content" + } + }, + "summary": "Logout", + "tags": [ + "User session" + ] + }, + "post": { + "description": "Invalidates the current session and clean up any remember-me authentication.", + "operationId": "postLogout_1", + "responses": { + "204": { + "description": "No Content" + } + }, + "summary": "Logout", + "tags": [ + "User session" + ] + }, + "summary": "Logout current session" + }, + "/api/v1/age-ratings": { + "get": { + "deprecated": true, + "description": "Use GET /v2/age-ratings instead. Deprecated since 1.26.0", + "operationId": "getAgeRatings_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List age ratings", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/announcements": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getAnnouncements", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonFeedDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve announcements", + "tags": [ + "Announcements" + ] + }, + "put": { + "description": "Required role: **ADMIN**", + "operationId": "markAnnouncementsRead", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark announcements as read", + "tags": [ + "Announcements" + ] + } + }, + "/api/v1/authors": { + "get": { + "deprecated": true, + "description": "Use GET /api/v2/authors instead. Deprecated since 1.20.0.", + "operationId": "getAuthorsDeprecated", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/authors/names": { + "get": { + "deprecated": true, + "description": "Use GET /v2/authors/names instead. Deprecated since 1.26.0", + "operationId": "getAuthorsNames_1", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors\u0027 names", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/authors/roles": { + "get": { + "deprecated": true, + "description": "Use GET /v2/authors/roles instead. Deprecated since 1.26.0", + "operationId": "getAuthorsRoles_1", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors\u0027 roles", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/books": { + "get": { + "deprecated": true, + "description": "Use POST /api/v1/books/list instead. Deprecated since 1.19.0.", + "operationId": "getAllBooksDeprecated", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "media_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNKNOWN", + "ERROR", + "READY", + "UNSUPPORTED", + "OUTDATED" + ] + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "released_after", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List books", + "tags": [ + "Books", + "Deprecated" + ] + } + }, + "/api/v1/books/duplicates": { + "get": { + "description": "Return books that have the same file hash.\n\nRequired role: **ADMIN**", + "operationId": "getBooksDuplicates", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List duplicate books", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/import": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "importBooks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookImportBatchDto" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Import books", + "tags": [ + "Import" + ] + } + }, + "/api/v1/books/latest": { + "get": { + "description": "Return newly added or updated books.", + "operationId": "getBooksLatest", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List latest books", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/list": { + "post": { + "operationId": "getBooks", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookSearch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List books", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/metadata": { + "patch": { + "description": "Set a field to null to unset the metadata. You can omit fields you don\u0027t want to update.\n\nRequired role: **ADMIN**", + "operationId": "updateBookMetadataByBatch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/BookMetadataUpdateDto" + }, + "description": "A map of book IDs which values are the metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don\u0027t want to update." + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update book metadata in bulk", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/ondeck": { + "get": { + "description": "Return first unread book of series with at least one book read and no books in progress.", + "operationId": "getBooksOnDeck", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List books on deck", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/thumbnails": { + "put": { + "description": "Required role: **ADMIN**", + "operationId": "booksRegenerateThumbnails", + "parameters": [ + { + "in": "query", + "name": "for_bigger_result_only", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Regenerate books posters", + "tags": [ + "Book Poster" + ] + } + }, + "/api/v1/books/{bookId}": { + "get": { + "operationId": "getBookById", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + }, + "summary": "Get book details", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/analyze": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "bookAnalyze", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Analyze book", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/file": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteBookFile", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete book file", + "tags": [ + "Books" + ] + }, + "get": { + "description": "Download the book file.\n\nRequired role: **FILE_DOWNLOAD**", + "operationId": "downloadBookFile", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Download book file", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/file/*": { + "get": { + "description": "Download the book file.\n\nRequired role: **FILE_DOWNLOAD**", + "operationId": "downloadBookFile_1", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Download book file", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/manifest": { + "get": { + "operationId": "getBookWebPubManifest", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/divina+json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + }, + "application/webpub+json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get book\u0027s WebPub manifest", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/manifest/divina": { + "get": { + "operationId": "getBookWebPubManifestDivina", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/divina+json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get book\u0027s WebPub manifest (DiViNa)", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/manifest/epub": { + "get": { + "operationId": "getBookWebPubManifestEpub", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + }, + "application/webpub+json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get book\u0027s WebPub manifest (Epub)", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/manifest/pdf": { + "get": { + "operationId": "getBookWebPubManifestPdf", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + }, + "application/webpub+json": { + "schema": { + "$ref": "#/components/schemas/WPPublicationDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get book\u0027s WebPub manifest (PDF)", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/metadata": { + "patch": { + "description": "Set a field to null to unset the metadata. You can omit fields you don\u0027t want to update.\n\nRequired role: **ADMIN**", + "operationId": "updateBookMetadata", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookMetadataUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update book metadata", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/metadata/refresh": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "bookRefreshMetadata", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Refresh book metadata", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/next": { + "get": { + "operationId": "getBookSiblingNext", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get next book in series", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/pages": { + "get": { + "operationId": "getBookPages", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List book pages", + "tags": [ + "Book Pages" + ] + } + }, + "/api/v1/books/{bookId}/pages/{pageNumber}": { + "get": { + "description": "Required role: **PAGE_STREAMING**", + "operationId": "getBookPageByNumber", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "pageNumber", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Convert the image to the provided format.", + "in": "query", + "name": "convert", + "required": false, + "schema": { + "type": "string", + "enum": [ + "jpeg", + "png" + ] + } + }, + { + "description": "If set to true, pages will start at index 0. If set to false, pages will start at index 1.", + "in": "query", + "name": "zero_based", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "description": "Some very limited server driven content negotiation is handled. If a book is a PDF book, and the Accept header contains \u0027application/pdf\u0027 as a more specific type than other \u0027image/\u0027 types, a raw PDF page will be returned.", + "in": "header", + "name": "Accept", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaType" + } + } + }, + { + "in": "query", + "name": "contentNegotiation", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get book page image", + "tags": [ + "Book Pages" + ] + } + }, + "/api/v1/books/{bookId}/pages/{pageNumber}/raw": { + "get": { + "description": "Returns the book page in raw format, without content negotiation.\n\nRequired role: **PAGE_STREAMING**", + "operationId": "getBookPageRawByNumber", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "pageNumber", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + }, + "application/json": { + "schema": { + "type": "string", + "format": "byte" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get raw book page", + "tags": [ + "Book Pages" + ] + } + }, + "/api/v1/books/{bookId}/pages/{pageNumber}/thumbnail": { + "get": { + "description": "The image is resized to 300px on the largest dimension.", + "operationId": "getBookPageThumbnailByNumber", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "pageNumber", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get book page thumbnail", + "tags": [ + "Book Pages" + ] + } + }, + "/api/v1/books/{bookId}/positions": { + "get": { + "description": "The Positions API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", + "operationId": "getBookPositions", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/R2Positions" + } + }, + "application/vnd.readium.position-list+json": { + "schema": { + "$ref": "#/components/schemas/R2Positions" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List book\u0027s positions", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/previous": { + "get": { + "operationId": "getBookSiblingPrevious", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get previous book in series", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/progression": { + "get": { + "description": "The Progression API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", + "operationId": "getBookProgression", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/R2Progression" + } + }, + "application/vnd.readium.progression+json": { + "schema": { + "$ref": "#/components/schemas/R2Progression" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get book progression", + "tags": [ + "WebPub Manifest" + ] + }, + "put": { + "description": "The Progression API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", + "operationId": "updateBookProgression", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/R2Progression" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark book progression", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/read-progress": { + "delete": { + "description": "Mark book as unread", + "operationId": "deleteBookReadProgress", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark book as unread", + "tags": [ + "Books" + ] + }, + "patch": { + "description": "Mark book as read and/or change page progress.", + "operationId": "markBookReadProgress", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadProgressUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark book\u0027s read progress", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/readlists": { + "get": { + "operationId": "getReadListsByBookId", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List book\u0027s readlists", + "tags": [ + "Books" + ] + } + }, + "/api/v1/books/{bookId}/resource/{resource}": { + "get": { + "description": "Return a resource from within an Epub book.", + "operationId": "getBookEpubResource", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "resource", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + }, + "application/json": { + "schema": { + "type": "string", + "format": "byte" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Get Epub resource", + "tags": [ + "WebPub Manifest" + ] + } + }, + "/api/v1/books/{bookId}/thumbnail": { + "get": { + "operationId": "getBookThumbnail", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get book\u0027s poster image", + "tags": [ + "Book Poster" + ] + } + }, + "/api/v1/books/{bookId}/thumbnails": { + "get": { + "operationId": "getBookThumbnails", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThumbnailBookDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List book posters", + "tags": [ + "Book Poster" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addUserUploadedBookThumbnail", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "selected", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Add book poster", + "tags": [ + "Book Poster" + ] + } + }, + "/api/v1/books/{bookId}/thumbnails/{thumbnailId}": { + "delete": { + "description": "Only uploaded posters can be deleted.\n\nRequired role: **ADMIN**", + "operationId": "deleteUserUploadedBookThumbnail", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete book poster", + "tags": [ + "Book Poster" + ] + }, + "get": { + "operationId": "getBookThumbnailById", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get book poster image", + "tags": [ + "Book Poster" + ] + } + }, + "/api/v1/books/{bookId}/thumbnails/{thumbnailId}/selected": { + "put": { + "description": "Required role: **ADMIN**", + "operationId": "markBookThumbnailSelected", + "parameters": [ + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark book poster as selected", + "tags": [ + "Book Poster" + ] + } + }, + "/api/v1/claim": { + "get": { + "description": "Check whether this server has already been claimed.", + "operationId": "getClaimStatus", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimStatus" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Retrieve claim status", + "tags": [ + "Claim" + ] + }, + "post": { + "description": "Creates an admin user with the provided credentials.", + "operationId": "claimServer", + "parameters": [ + { + "in": "header", + "name": "X-Komga-Email", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Komga-Password", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Claim server", + "tags": [ + "Claim" + ] + } + }, + "/api/v1/client-settings/global": { + "delete": { + "description": "Setting key should be a valid lowercase namespace string like \u0027application.domain.key\u0027\n\nRequired role: **ADMIN**", + "operationId": "deleteGlobalSettings", + "requestBody": { + "content": { + "application/json": { + "example": [ + "application.key1", + "application.key2" + ], + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete global settings", + "tags": [ + "Client settings" + ] + }, + "patch": { + "description": "Setting key should be a valid lowercase namespace string like \u0027application.domain.key\u0027\n\nRequired role: **ADMIN**", + "operationId": "saveGlobalSetting", + "requestBody": { + "content": { + "application/json": { + "example": { + "application.key1": { + "value": "a string value", + "allowUnauthorized": true + }, + "application.key2": { + "value": "{\"json\":\"object\"}", + "allowUnauthorized": false + } + }, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ClientSettingGlobalUpdateDto" + } + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Save global settings", + "tags": [ + "Client settings" + ] + } + }, + "/api/v1/client-settings/global/list": { + "get": { + "description": "For unauthenticated users, only settings with \u0027allowUnauthorized\u003dtrue\u0027 will be returned.", + "operationId": "getGlobalSettings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ClientSettingDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Retrieve global client settings", + "tags": [ + "Client settings" + ] + } + }, + "/api/v1/client-settings/user": { + "delete": { + "description": "Setting key should be a valid lowercase namespace string like \u0027application.domain.key\u0027", + "operationId": "deleteUserSettings", + "requestBody": { + "content": { + "application/json": { + "example": [ + "application.key1", + "application.key2" + ], + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete user settings", + "tags": [ + "Client settings" + ] + }, + "patch": { + "description": "Setting key should be a valid lowercase namespace string like \u0027application.domain.key\u0027", + "operationId": "saveUserSetting", + "requestBody": { + "content": { + "application/json": { + "example": { + "application.key1": { + "value": "a string value" + }, + "application.key2": { + "value": "{\"json\":\"object\"}" + } + }, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ClientSettingUserUpdateDto" + } + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Save user settings", + "tags": [ + "Client settings" + ] + } + }, + "/api/v1/client-settings/user/list": { + "get": { + "operationId": "getUserSettings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ClientSettingDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve user client settings", + "tags": [ + "Client settings" + ] + } + }, + "/api/v1/collections": { + "get": { + "operationId": "getCollections", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageCollectionDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List collections", + "tags": [ + "Collections" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "createCollection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionCreationDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Create collection", + "tags": [ + "Collections" + ] + } + }, + "/api/v1/collections/{id}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteCollectionById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete collection", + "tags": [ + "Collections" + ] + }, + "get": { + "operationId": "getCollectionById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get collection details", + "tags": [ + "Collections" + ] + }, + "patch": { + "description": "Required role: **ADMIN**", + "operationId": "updateCollectionById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update collection", + "tags": [ + "Collections" + ] + } + }, + "/api/v1/collections/{id}/series": { + "get": { + "operationId": "getSeriesByCollectionId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ENDED", + "ONGOING", + "ABANDONED", + "HIATUS" + ] + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "publisher", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "language", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "genre", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "age_rating", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "release_year", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "complete", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Author criteria in the format: name,role. Multiple author criteria are supported.", + "in": "query", + "name": "author", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List collection\u0027s series", + "tags": [ + "Collection Series" + ] + } + }, + "/api/v1/collections/{id}/thumbnail": { + "get": { + "operationId": "getCollectionThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get collection\u0027s poster image", + "tags": [ + "Collection Poster" + ] + } + }, + "/api/v1/collections/{id}/thumbnails": { + "get": { + "operationId": "getCollectionThumbnails", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThumbnailSeriesCollectionDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List collection\u0027s posters", + "tags": [ + "Collection Poster" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addUserUploadedCollectionThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "selected", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailSeriesCollectionDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Add collection poster", + "tags": [ + "Collection Poster" + ] + } + }, + "/api/v1/collections/{id}/thumbnails/{thumbnailId}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteUserUploadedCollectionThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete collection poster", + "tags": [ + "Collection Poster" + ] + }, + "get": { + "operationId": "getCollectionThumbnailById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get collection poster image", + "tags": [ + "Collection Poster" + ] + } + }, + "/api/v1/collections/{id}/thumbnails/{thumbnailId}/selected": { + "put": { + "description": "Required role: **ADMIN**", + "operationId": "markCollectionThumbnailSelected", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark collection poster as selected", + "tags": [ + "Collection Poster" + ] + } + }, + "/api/v1/filesystem": { + "post": { + "description": "List folders and files from the host server\u0027s file system. If no request body is passed then the root directories are returned.\n\nRequired role: **ADMIN**", + "operationId": "getDirectoryListing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryRequestDto" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryListingDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Directory listing", + "tags": [ + "File system" + ] + } + }, + "/api/v1/fonts/families": { + "get": { + "description": "List all available font families.", + "operationId": "getFonts", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List font families", + "tags": [ + "Fonts" + ] + } + }, + "/api/v1/fonts/resource/{fontFamily}/css": { + "get": { + "description": "Download a CSS file with the @font-face block for the font family. This is used by the Epub Reader to change fonts.", + "operationId": "getFontFamilyAsCss", + "parameters": [ + { + "in": "path", + "name": "fontFamily", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "text/css": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Download CSS file", + "tags": [ + "Fonts" + ] + } + }, + "/api/v1/fonts/resource/{fontFamily}/{fontFile}": { + "get": { + "operationId": "getFontFile", + "parameters": [ + { + "in": "path", + "name": "fontFamily", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "fontFile", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "Download font file", + "tags": [ + "Fonts" + ] + } + }, + "/api/v1/genres": { + "get": { + "deprecated": true, + "description": "Use GET /v2/genres instead. Deprecated since 1.26.0", + "operationId": "getGenres_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List genres", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/history": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getHistoricalEvents", + "parameters": [ + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageHistoricalEventDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List historical events", + "tags": [ + "History" + ] + } + }, + "/api/v1/languages": { + "get": { + "deprecated": true, + "description": "Use GET /v2/languages instead. Deprecated since 1.26.0", + "operationId": "getLanguages_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List languages", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/libraries": { + "get": { + "description": "The libraries are filtered based on the current user\u0027s permissions", + "operationId": "getLibraries", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List all libraries", + "tags": [ + "Libraries" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addLibrary", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryCreationDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Create a library", + "tags": [ + "Libraries" + ] + } + }, + "/api/v1/libraries/{libraryId}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteLibraryById", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete a library", + "tags": [ + "Libraries" + ] + }, + "get": { + "operationId": "getLibraryById", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get details for a single library", + "tags": [ + "Libraries" + ] + }, + "patch": { + "description": "You can omit fields you don\u0027t want to update\n\nRequired role: **ADMIN**", + "operationId": "updateLibraryById", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update a library", + "tags": [ + "Libraries" + ] + }, + "put": { + "deprecated": true, + "description": "Use PATCH /api/v1/libraries/{libraryId} instead. Deprecated since 1.3.0.\n\nRequired role: **ADMIN**", + "operationId": "updateLibraryByIdDeprecated", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update a library", + "tags": [ + "Deprecated", + "Libraries" + ] + } + }, + "/api/v1/libraries/{libraryId}/analyze": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "libraryAnalyze", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Analyze a library", + "tags": [ + "Libraries" + ] + } + }, + "/api/v1/libraries/{libraryId}/empty-trash": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "libraryEmptyTrash", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Empty trash for a library", + "tags": [ + "Libraries" + ] + } + }, + "/api/v1/libraries/{libraryId}/metadata/refresh": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "libraryRefreshMetadata", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Refresh metadata for a library", + "tags": [ + "Libraries" + ] + } + }, + "/api/v1/libraries/{libraryId}/scan": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "libraryScan", + "parameters": [ + { + "in": "path", + "name": "libraryId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "deep", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Scan a library", + "tags": [ + "Libraries" + ] + } + }, + "/api/v1/login/set-cookie": { + "get": { + "description": "Forcefully return Set-Cookie header, even if the session is contained in the X-Auth-Token header.", + "operationId": "convertHeaderSessionToCookie", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Set cookie", + "tags": [ + "User session" + ] + } + }, + "/api/v1/oauth2/providers": { + "get": { + "operationId": "getOAuth2Providers", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuth2ClientDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [], + "summary": "List registered OAuth2 providers", + "tags": [ + "OAuth2" + ] + } + }, + "/api/v1/page-hashes": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getKnownPageHashes", + "parameters": [ + { + "in": "query", + "name": "action", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "DELETE_AUTO", + "DELETE_MANUAL", + "IGNORE" + ] + } + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagePageHashKnownDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List known duplicates", + "tags": [ + "Duplicate Pages" + ] + }, + "put": { + "description": "Required role: **ADMIN**", + "operationId": "createOrUpdateKnownPageHash", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageHashCreationDto" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark duplicate page as known", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/unknown": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getUnknownPageHashes", + "parameters": [ + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagePageHashUnknownDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List unknown duplicates", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/unknown/{pageHash}/thumbnail": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getUnknownPageHashThumbnail", + "parameters": [ + { + "in": "path", + "name": "pageHash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resize", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get unknown duplicate image thumbnail", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/{pageHash}": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getPageHashMatches", + "parameters": [ + { + "in": "path", + "name": "pageHash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagePageHashMatchDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List duplicate matches", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/{pageHash}/delete-all": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "deleteDuplicatePagesByPageHash", + "parameters": [ + { + "in": "path", + "name": "pageHash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete all duplicate pages by hash", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/{pageHash}/delete-match": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "deleteSingleMatchByPageHash", + "parameters": [ + { + "in": "path", + "name": "pageHash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageHashMatchDto" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete specific duplicate page", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/page-hashes/{pageHash}/thumbnail": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getKnownPageHashThumbnail", + "parameters": [ + { + "in": "path", + "name": "pageHash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get known duplicate image thumbnail", + "tags": [ + "Duplicate Pages" + ] + } + }, + "/api/v1/publishers": { + "get": { + "deprecated": true, + "description": "Use GET /v2/publishers instead. Deprecated since 1.26.0", + "operationId": "getPublishers_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List publishers", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/readlists": { + "get": { + "operationId": "getReadLists", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageReadListDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List readlists", + "tags": [ + "Readlists" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "createReadList", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListCreationDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Create readlist", + "tags": [ + "Readlists" + ] + } + }, + "/api/v1/readlists/match/comicrack": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "matchComicRackList", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListRequestMatchDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Match ComicRack list", + "tags": [ + "ComicRack" + ] + } + }, + "/api/v1/readlists/{id}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteReadListById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete readlist", + "tags": [ + "Readlists" + ] + }, + "get": { + "operationId": "getReadListById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get readlist details", + "tags": [ + "Readlists" + ] + }, + "patch": { + "description": "Required role: **ADMIN**", + "operationId": "updateReadListById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update readlist", + "tags": [ + "Readlists" + ] + } + }, + "/api/v1/readlists/{id}/books": { + "get": { + "operationId": "getBooksByReadListId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "media_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNKNOWN", + "ERROR", + "READY", + "UNSUPPORTED", + "OUTDATED" + ] + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Author criteria in the format: name,role. Multiple author criteria are supported.", + "in": "query", + "name": "author", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List readlist\u0027s books", + "tags": [ + "Readlist Books" + ] + } + }, + "/api/v1/readlists/{id}/books/{bookId}/next": { + "get": { + "operationId": "getBookSiblingNextInReadList", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get next book in readlist", + "tags": [ + "Readlist Books" + ] + } + }, + "/api/v1/readlists/{id}/books/{bookId}/previous": { + "get": { + "operationId": "getBookSiblingPreviousInReadList", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "bookId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get previous book in readlist", + "tags": [ + "Readlist Books" + ] + } + }, + "/api/v1/readlists/{id}/file": { + "get": { + "description": "Download the whole readlist as a ZIP file.\n\nRequired role: **FILE_DOWNLOAD**", + "operationId": "downloadReadListAsZip", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Download readlist", + "tags": [ + "Readlists" + ] + } + }, + "/api/v1/readlists/{id}/read-progress/tachiyomi": { + "get": { + "description": "Mihon specific, due to how read progress is handled in Mihon.", + "operationId": "getMihonReadProgressByReadListId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TachiyomiReadProgressDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get readlist read progress (Mihon)", + "tags": [ + "Mihon" + ] + }, + "put": { + "description": "Mihon specific, due to how read progress is handled in Mihon.", + "operationId": "updateMihonReadProgressByReadListId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TachiyomiReadProgressUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update readlist read progress (Mihon)", + "tags": [ + "Mihon" + ] + } + }, + "/api/v1/readlists/{id}/thumbnail": { + "get": { + "operationId": "getReadListThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get readlist\u0027s poster image", + "tags": [ + "Readlist Poster" + ] + } + }, + "/api/v1/readlists/{id}/thumbnails": { + "get": { + "operationId": "getReadListThumbnails", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThumbnailReadListDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List readlist\u0027s posters", + "tags": [ + "Readlist Poster" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addUserUploadedReadListThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "selected", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailReadListDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Add readlist poster", + "tags": [ + "Readlist Poster" + ] + } + }, + "/api/v1/readlists/{id}/thumbnails/{thumbnailId}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteUserUploadedReadListThumbnail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete readlist poster", + "tags": [ + "Readlist Poster" + ] + }, + "get": { + "operationId": "getReadListThumbnailById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get readlist poster image", + "tags": [ + "Readlist Poster" + ] + } + }, + "/api/v1/readlists/{id}/thumbnails/{thumbnailId}/selected": { + "put": { + "description": "Required role: **ADMIN**", + "operationId": "markReadListThumbnailSelected", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark readlist poster as selected", + "tags": [ + "Readlist Poster" + ] + } + }, + "/api/v1/releases": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getReleases", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List releases", + "tags": [ + "Releases" + ] + } + }, + "/api/v1/series": { + "get": { + "deprecated": true, + "description": "Use POST /api/v1/series/list instead. Deprecated since 1.19.0.", + "operationId": "getSeriesDeprecated", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ENDED", + "ONGOING", + "ABANDONED", + "HIATUS" + ] + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "publisher", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "language", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "genre", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "age_rating", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "release_year", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "sharing_label", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "complete", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "oneshot", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.", + "in": "query", + "name": "search_regex", + "schema": { + "type": "string" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Author criteria in the format: name,role. Multiple author criteria are supported.", + "in": "query", + "name": "author", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series", + "tags": [ + "Series", + "Deprecated" + ] + } + }, + "/api/v1/series/alphabetical-groups": { + "get": { + "deprecated": true, + "description": "Use POST /api/v1/series/list/alphabetical-groups instead. Deprecated since 1.19.0.", + "operationId": "getSeriesAlphabeticalGroupsDeprecated", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ENDED", + "ONGOING", + "ABANDONED", + "HIATUS" + ] + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "publisher", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "language", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "genre", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "age_rating", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "release_year", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "sharing_label", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "complete", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "oneshot", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.", + "in": "query", + "name": "search_regex", + "schema": { + "type": "string" + } + }, + { + "description": "Author criteria in the format: name,role. Multiple author criteria are supported.", + "in": "query", + "name": "author", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupCountDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series groups", + "tags": [ + "Series", + "Deprecated" + ] + } + }, + "/api/v1/series/latest": { + "get": { + "description": "Return recently added or updated series.", + "operationId": "getSeriesLatest", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "oneshot", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List latest series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/list": { + "post": { + "operationId": "getSeries", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesSearch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/list/alphabetical-groups": { + "post": { + "description": "List series grouped by the first character of their sort title.", + "operationId": "getSeriesAlphabeticalGroups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesSearch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupCountDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series groups", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/new": { + "get": { + "description": "Return newly added series.", + "operationId": "getSeriesNew", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "oneshot", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List new series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/release-dates": { + "get": { + "deprecated": true, + "description": "Use GET /v2/series/release-years instead. Deprecated since 1.26.0", + "operationId": "getSeriesReleaseDates", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series release dates", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/series/updated": { + "get": { + "description": "Return recently updated series, but not newly added ones.", + "operationId": "getSeriesUpdated", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "oneshot", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List updated series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}": { + "get": { + "operationId": "getSeriesById", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + }, + "summary": "Get series details", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/analyze": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "seriesAnalyze", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Analyze series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/books": { + "get": { + "deprecated": true, + "description": "Use POST /api/v1/books/list instead. Deprecated since 1.19.0.", + "operationId": "getBooksBySeriesId", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "media_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNKNOWN", + "ERROR", + "READY", + "UNSUPPORTED", + "OUTDATED" + ] + } + } + }, + { + "in": "query", + "name": "read_status", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "UNREAD", + "READ", + "IN_PROGRESS" + ] + } + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "deleted", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + }, + { + "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Author criteria in the format: name,role. Multiple author criteria are supported.", + "in": "query", + "name": "author", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series\u0027 books", + "tags": [ + "Series", + "Deprecated" + ] + } + }, + "/api/v1/series/{seriesId}/collections": { + "get": { + "operationId": "getCollectionsBySeriesId", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series\u0027 collections", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/file": { + "delete": { + "description": "Delete all of the series\u0027 books files on disk.\n\nRequired role: **ADMIN**", + "operationId": "deleteSeriesFile", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete series files", + "tags": [ + "Series" + ] + }, + "get": { + "description": "Download the whole series as a ZIP file.\n\nRequired role: **FILE_DOWNLOAD**", + "operationId": "downloadSeriesAsZip", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingResponseBody" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Download series", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/metadata": { + "patch": { + "description": "Required role: **ADMIN**", + "operationId": "updateSeriesMetadata", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesMetadataUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update series metadata", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/metadata/refresh": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "seriesRefreshMetadata", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Refresh series metadata", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/read-progress": { + "delete": { + "description": "Mark all book for series as unread", + "operationId": "markSeriesAsUnread", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark series as unread", + "tags": [ + "Series" + ] + }, + "post": { + "description": "Mark all book for series as read", + "operationId": "markSeriesAsRead", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark series as read", + "tags": [ + "Series" + ] + } + }, + "/api/v1/series/{seriesId}/thumbnail": { + "get": { + "operationId": "getSeriesThumbnail", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get series\u0027 poster image", + "tags": [ + "Series Poster" + ] + } + }, + "/api/v1/series/{seriesId}/thumbnails": { + "get": { + "operationId": "getSeriesThumbnails", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThumbnailSeriesDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series posters", + "tags": [ + "Series Poster" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addUserUploadedSeriesThumbnail", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "selected", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailSeriesDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Add series poster", + "tags": [ + "Series Poster" + ] + } + }, + "/api/v1/series/{seriesId}/thumbnails/{thumbnailId}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteUserUploadedSeriesThumbnail", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete series poster", + "tags": [ + "Series Poster" + ] + }, + "get": { + "operationId": "getSeriesThumbnailById", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "summary": "Get series poster image", + "tags": [ + "Series Poster" + ] + } + }, + "/api/v1/series/{seriesId}/thumbnails/{thumbnailId}/selected": { + "put": { + "description": "Required role: **ADMIN**", + "operationId": "markSeriesThumbnailSelected", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "thumbnailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Mark series poster as selected", + "tags": [ + "Series Poster" + ] + } + }, + "/api/v1/settings": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getServerSettings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve server settings", + "tags": [ + "Server settings" + ] + }, + "patch": { + "description": "You can omit fields you don\u0027t want to update\n\nRequired role: **ADMIN**", + "operationId": "updateServerSettings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update server settings", + "tags": [ + "Server settings" + ] + } + }, + "/api/v1/sharing-labels": { + "get": { + "deprecated": true, + "description": "Use GET /v2/sharing-labels instead. Deprecated since 1.26.0", + "operationId": "getSharingLabels_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List sharing labels", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/syncpoints/me": { + "delete": { + "description": "If an API Key ID is passed, deletes only the sync points associated with that API Key. Deleting sync points will allow a Kobo to sync from scratch upon the next sync.", + "operationId": "deleteSyncPointsForCurrentUser", + "parameters": [ + { + "in": "query", + "name": "key_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete all sync points", + "tags": [ + "Sync points" + ] + } + }, + "/api/v1/tags": { + "get": { + "deprecated": true, + "description": "Use GET /v2/tags instead. Deprecated since 1.26.0", + "operationId": "getTags_1", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List tags", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/tags/book": { + "get": { + "deprecated": true, + "description": "Use GET /v2/tags instead. Deprecated since 1.26.0", + "operationId": "getBookTags", + "parameters": [ + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "readlist_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List book tags", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/tags/series": { + "get": { + "deprecated": true, + "description": "Use GET /v2/tags instead. Deprecated since 1.26.0", + "operationId": "getSeriesTags", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series tags", + "tags": [ + "Deprecated", + "Referential metadata" + ] + } + }, + "/api/v1/tasks": { + "delete": { + "description": "Cancel all tasks queued\n\nRequired role: **ADMIN**", + "operationId": "emptyTaskQueue", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Clear task queue", + "tags": [ + "Tasks" + ] + } + }, + "/api/v1/transient-books": { + "post": { + "description": "Scan provided folder for transient books.\n\nRequired role: **ADMIN**", + "operationId": "scanTransientBooks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransientBookDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Scan folder for transient books", + "tags": [ + "Import" + ] + } + }, + "/api/v1/transient-books/{id}/analyze": { + "post": { + "description": "Required role: **ADMIN**", + "operationId": "analyzeTransientBook", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransientBookDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Analyze transient book", + "tags": [ + "Import" + ] + } + }, + "/api/v1/transient-books/{id}/pages/{pageNumber}": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getPageByTransientBookId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "pageNumber", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + }, + "application/json": { + "schema": { + "type": "string", + "format": "byte" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get transient book page", + "tags": [ + "Import" + ] + } + }, + "/api/v2/age-ratings": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getAgeRatings", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageInteger" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List age ratings", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/authors": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getAuthors", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "role", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "readlist_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageAuthorDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/authors/names": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getAuthorsNames", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "role", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "readlist_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors names", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/authors/roles": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getAuthorsRoles", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "readlist_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List authors roles", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/genres": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getGenres", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List genres", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/languages": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getLanguages", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List languages", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/publishers": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getPublishers", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List publishers", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/series/release-years": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getSeriesReleaseYears", + "parameters": [ + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List series release years", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/series/{seriesId}/read-progress/tachiyomi": { + "get": { + "description": "Mihon specific, due to how read progress is handled in Mihon.", + "operationId": "getMihonReadProgressBySeriesId", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TachiyomiReadProgressV2Dto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Get series read progress (Mihon)", + "tags": [ + "Mihon" + ] + }, + "put": { + "description": "Mihon specific, due to how read progress is handled in Mihon.", + "operationId": "updateMihonReadProgressBySeriesId", + "parameters": [ + { + "in": "path", + "name": "seriesId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TachiyomiReadProgressUpdateV2Dto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update series read progress (Mihon)", + "tags": [ + "Mihon" + ] + } + }, + "/api/v2/sharing-labels": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getSharingLabels", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List sharing labels", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/tags": { + "get": { + "description": "Can be filtered by various criteria", + "operationId": "getTags", + "parameters": [ + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "library_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "collection_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "series_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "readlist_id", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + { + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string", + "enum": [ + "SERIES", + "BOOK", + "BOTH" + ] + } + }, + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List tags", + "tags": [ + "Referential metadata" + ] + } + }, + "/api/v2/users": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getUsers", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "List users", + "tags": [ + "Users" + ] + }, + "post": { + "description": "Required role: **ADMIN**", + "operationId": "addUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreationDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Create user", + "tags": [ + "Users" + ] + } + }, + "/api/v2/users/authentication-activity": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getAuthenticationActivity", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageAuthenticationActivityDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve authentication activity", + "tags": [ + "Users" + ] + } + }, + "/api/v2/users/me": { + "get": { + "operationId": "getCurrentUser", + "parameters": [ + { + "in": "query", + "name": "remember-me", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve current user", + "tags": [ + "Current user" + ] + } + }, + "/api/v2/users/me/api-keys": { + "get": { + "operationId": "getApiKeysForCurrentUser", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKeyDto" + } + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve API keys", + "tags": [ + "API Keys" + ] + }, + "post": { + "operationId": "createApiKeyForCurrentUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Create API key", + "tags": [ + "API Keys" + ] + } + }, + "/api/v2/users/me/api-keys/{keyId}": { + "delete": { + "operationId": "deleteApiKeyByKeyId", + "parameters": [ + { + "in": "path", + "name": "keyId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete API key", + "tags": [ + "API Keys" + ] + } + }, + "/api/v2/users/me/authentication-activity": { + "get": { + "operationId": "getAuthenticationActivityForCurrentUser", + "parameters": [ + { + "in": "query", + "name": "unpaged", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Zero-based page index (0..N)", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "description": "The size of the page to be returned", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageAuthenticationActivityDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve authentication activity for the current user", + "tags": [ + "Current user" + ] + } + }, + "/api/v2/users/me/password": { + "patch": { + "operationId": "updatePasswordForCurrentUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update current user\u0027s password", + "tags": [ + "Current user" + ] + } + }, + "/api/v2/users/{id}": { + "delete": { + "description": "Required role: **ADMIN**", + "operationId": "deleteUserById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Delete user", + "tags": [ + "Users" + ] + }, + "patch": { + "description": "Required role: **ADMIN**", + "operationId": "updateUserById", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update user", + "tags": [ + "Users" + ] + } + }, + "/api/v2/users/{id}/authentication-activity/latest": { + "get": { + "description": "Required role: **ADMIN**", + "operationId": "getLatestAuthenticationActivityByUserId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "apikey_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticationActivityDto" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Retrieve latest authentication activity for a user", + "tags": [ + "Users" + ] + } + }, + "/api/v2/users/{id}/password": { + "patch": { + "description": "Required role: **ADMIN**", + "operationId": "updatePasswordByUserId", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Update user\u0027s password", + "tags": [ + "Users" + ] + } + } + }, + "components": { + "schemas": { + "AgeRestrictionDto": { + "type": "object", + "properties": { + "age": { + "type": "integer", + "format": "int32" + }, + "restriction": { + "type": "string", + "enum": [ + "ALLOW_ONLY", + "EXCLUDE" + ] + } + }, + "required": [ + "age", + "restriction" + ] + }, + "AgeRestrictionUpdateDto": { + "type": "object", + "properties": { + "age": { + "type": "integer", + "format": "int32" + }, + "restriction": { + "type": "string", + "enum": [ + "ALLOW_ONLY", + "EXCLUDE", + "NONE" + ] + } + }, + "required": [ + "age", + "restriction" + ] + }, + "AlternateTitleDto": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "label", + "title" + ] + }, + "AlternateTitleUpdateDto": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "label", + "title" + ] + }, + "ApiKeyDto": { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "createdDate": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "lastModifiedDate": { + "type": "string", + "format": "date-time" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "comment", + "createdDate", + "id", + "key", + "lastModifiedDate", + "userId" + ] + }, + "ApiKeyRequestDto": { + "type": "object", + "properties": { + "comment": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "comment" + ] + }, + "AuthenticationActivityDto": { + "type": "object", + "properties": { + "apiKeyComment": { + "type": "string" + }, + "apiKeyId": { + "type": "string" + }, + "dateTime": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "error": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "source": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "userAgent": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "dateTime", + "success" + ] + }, + "AuthorDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "required": [ + "name", + "role" + ] + }, + "AuthorUpdateDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "name", + "role" + ] + }, + "BookDto": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "deleted": { + "type": "boolean" + }, + "fileHash": { + "type": "string" + }, + "fileLastModified": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "libraryId": { + "type": "string" + }, + "media": { + "$ref": "#/components/schemas/MediaDto" + }, + "metadata": { + "$ref": "#/components/schemas/BookMetadataDto" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "oneshot": { + "type": "boolean" + }, + "readProgress": { + "$ref": "#/components/schemas/ReadProgressDto" + }, + "seriesId": { + "type": "string" + }, + "seriesTitle": { + "type": "string" + }, + "size": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + } + }, + "required": [ + "created", + "deleted", + "fileHash", + "fileLastModified", + "id", + "lastModified", + "libraryId", + "media", + "metadata", + "name", + "number", + "oneshot", + "seriesId", + "seriesTitle", + "size", + "sizeBytes", + "url" + ] + }, + "BookImportBatchDto": { + "type": "object", + "properties": { + "books": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookImportDto" + } + }, + "copyMode": { + "type": "string", + "enum": [ + "MOVE", + "COPY", + "HARDLINK" + ] + } + }, + "required": [ + "books", + "copyMode" + ] + }, + "BookImportDto": { + "type": "object", + "properties": { + "destinationName": { + "type": "string" + }, + "seriesId": { + "type": "string" + }, + "sourceFile": { + "type": "string" + }, + "upgradeBookId": { + "type": "string" + } + }, + "required": [ + "seriesId", + "sourceFile" + ] + }, + "BookMetadataAggregationDto": { + "type": "object", + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorDto" + } + }, + "created": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "releaseDate": { + "type": "string", + "format": "date" + }, + "summary": { + "type": "string" + }, + "summaryNumber": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "authors", + "created", + "lastModified", + "summary", + "summaryNumber", + "tags" + ] + }, + "BookMetadataDto": { + "type": "object", + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorDto" + } + }, + "authorsLock": { + "type": "boolean" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "isbn": { + "type": "string" + }, + "isbnLock": { + "type": "boolean" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebLinkDto" + } + }, + "linksLock": { + "type": "boolean" + }, + "number": { + "type": "string" + }, + "numberLock": { + "type": "boolean" + }, + "numberSort": { + "type": "number", + "format": "float" + }, + "numberSortLock": { + "type": "boolean" + }, + "releaseDate": { + "type": "string", + "format": "date" + }, + "releaseDateLock": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "summaryLock": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "tagsLock": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "titleLock": { + "type": "boolean" + } + }, + "required": [ + "authors", + "authorsLock", + "created", + "isbn", + "isbnLock", + "lastModified", + "links", + "linksLock", + "number", + "numberLock", + "numberSort", + "numberSortLock", + "releaseDateLock", + "summary", + "summaryLock", + "tags", + "tagsLock", + "title", + "titleLock" + ] + }, + "BookMetadataUpdateDto": { + "type": "object", + "description": "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don\u0027t want to update.", + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorUpdateDto" + } + }, + "authorsLock": { + "type": "boolean" + }, + "isbn": { + "type": "string" + }, + "isbnLock": { + "type": "boolean" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebLinkUpdateDto" + } + }, + "linksLock": { + "type": "boolean" + }, + "number": { + "type": "string" + }, + "numberLock": { + "type": "boolean" + }, + "numberSort": { + "type": "number", + "format": "float" + }, + "numberSortLock": { + "type": "boolean" + }, + "releaseDate": { + "type": "string", + "format": "date" + }, + "releaseDateLock": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "summaryLock": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "tagsLock": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "titleLock": { + "type": "boolean" + } + } + }, + "BookSearch": { + "type": "object", + "properties": { + "condition": { + "$ref": "#/components/schemas/SearchConditionBook" + }, + "fullTextSearch": { + "type": "string" + } + } + }, + "ClaimStatus": { + "type": "object", + "properties": { + "isClaimed": { + "type": "boolean" + } + }, + "required": [ + "isClaimed" + ] + }, + "ClientSettingDto": { + "type": "object", + "properties": { + "allowUnauthorized": { + "type": "boolean" + }, + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "ClientSettingGlobalUpdateDto": { + "type": "object", + "properties": { + "allowUnauthorized": { + "type": "boolean" + }, + "value": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "allowUnauthorized", + "value" + ] + }, + "ClientSettingUserUpdateDto": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "value" + ] + }, + "CollectionCreationDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "ordered": { + "type": "boolean" + }, + "seriesIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "name", + "ordered", + "seriesIds" + ] + }, + "CollectionDto": { + "type": "object", + "properties": { + "createdDate": { + "type": "string", + "format": "date-time" + }, + "filtered": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "lastModifiedDate": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "ordered": { + "type": "boolean" + }, + "seriesIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "createdDate", + "filtered", + "id", + "lastModifiedDate", + "name", + "ordered", + "seriesIds" + ] + }, + "CollectionUpdateDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "ordered": { + "type": "boolean" + }, + "seriesIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DirectoryListingDto": { + "type": "object", + "properties": { + "directories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathDto" + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathDto" + } + }, + "parent": { + "type": "string" + } + }, + "required": [ + "directories", + "files" + ] + }, + "DirectoryRequestDto": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "showFiles": { + "type": "boolean" + } + }, + "required": [ + "path", + "showFiles" + ] + }, + "GroupCountDto": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "format": "int32" + }, + "group": { + "type": "string" + } + }, + "required": [ + "count", + "group" + ] + }, + "HistoricalEventDto": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "seriesId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "properties", + "timestamp", + "type" + ] + }, + "ItemAuthorDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Author\u0027s name", + "example": "gotson" + }, + "url": { + "type": "string", + "description": "URL of a site owned by the author", + "example": "https://github.com/gotson" + } + } + }, + "ItemDto": { + "type": "object", + "properties": { + "_komga": { + "$ref": "#/components/schemas/KomgaExtensionDto", + "description": "Additional fields for the item" + }, + "author": { + "$ref": "#/components/schemas/ItemAuthorDto", + "description": "Author of the item" + }, + "content_html": { + "type": "string", + "description": "HTML of the item", + "example": "\u003cp\u003eA longer text…\u003c/p\u003e" + }, + "date_modified": { + "type": "string", + "format": "date-time", + "description": "Modification date in RFC 3339 format", + "example": "2023-12-15T00:00:00Z" + }, + "id": { + "type": "string", + "description": "Unique for that item for that feed over time", + "example": "https://komga.org/blog/ebook-drop2" + }, + "summary": { + "type": "string", + "description": "A plain text sentence or two describing the item", + "example": "Version 1.9.0 contains the second feature drop for Ebooks support." + }, + "tags": { + "type": "array", + "description": "Tags describing the item", + "examples": [ + "upgrade", + "komga" + ], + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "title": { + "type": "string", + "description": "Plain text title", + "example": "eBook drop 2" + }, + "url": { + "type": "string", + "description": "URL of the resource described by the item", + "example": "https://komga.org/blog/ebook-drop2" + } + }, + "required": [ + "id", + "tags" + ] + }, + "JsonFeedDto": { + "type": "object", + "example": { + "version": "https://jsonfeed.org/version/1", + "title": "Announcements", + "home_page_url": "https://komga.org/blog", + "description": "Latest Komga announcements", + "items": [ + { + "id": "https://komga.org/blog/ebook-drop2", + "url": "https://komga.org/blog/ebook-drop2", + "title": "eBook drop 2", + "summary": "Version 1.9.0 contains the second feature drop for Ebooks support.", + "content_html": "\u003cp\u003eA longer text…\u003c/p\u003e", + "date_modified": "2023-12-15T00:00:00Z", + "author": { + "name": "gotson", + "url": "https://github.com/gotson" + }, + "tags": [ + "upgrade", + "komga" + ], + "_komga": { + "read": false + } + }, + { + "id": "https://komga.org/blog/ebook-support", + "url": "https://komga.org/blog/ebook-support", + "title": "eBook support", + "summary": "Version 1.8.0 is bringing a long awaited feature: proper eBook support!", + "content_html": "\u003cp\u003eA longer text…\u003c/p\u003e", + "date_modified": "2023-11-29T00:00:00Z", + "author": { + "name": "gotson", + "url": "https://github.com/gotson" + }, + "tags": [ + "upgrade", + "komga" + ], + "_komga": { + "read": true + } + } + ] + }, + "properties": { + "description": { + "type": "string", + "description": "Provides more detail on what the feed is about", + "example": "Latest Komga announcements" + }, + "home_page_url": { + "type": "string", + "description": "URL of the resource that the feed describes", + "example": "https://komga.org/blog" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemDto" + } + }, + "title": { + "type": "string", + "description": "Name of the feed", + "example": "Announcements" + }, + "version": { + "type": "string", + "description": "URL of the version of the format the feed uses", + "example": "https://jsonfeed.org/version/1" + } + }, + "required": [ + "items", + "title", + "version" + ] + }, + "KomgaExtensionDto": { + "type": "object", + "properties": { + "read": { + "type": "boolean", + "description": "Whether the current item has been marked read by the current user", + "example": false + } + }, + "required": [ + "read" + ] + }, + "LibraryCreationDto": { + "type": "object", + "properties": { + "analyzeDimensions": { + "type": "boolean" + }, + "convertToCbz": { + "type": "boolean" + }, + "emptyTrashAfterScan": { + "type": "boolean" + }, + "hashFiles": { + "type": "boolean" + }, + "hashKoreader": { + "type": "boolean" + }, + "hashPages": { + "type": "boolean" + }, + "importBarcodeIsbn": { + "type": "boolean" + }, + "importComicInfoBook": { + "type": "boolean" + }, + "importComicInfoCollection": { + "type": "boolean" + }, + "importComicInfoReadList": { + "type": "boolean" + }, + "importComicInfoSeries": { + "type": "boolean" + }, + "importComicInfoSeriesAppendVolume": { + "type": "boolean" + }, + "importEpubBook": { + "type": "boolean" + }, + "importEpubSeries": { + "type": "boolean" + }, + "importLocalArtwork": { + "type": "boolean" + }, + "importMylarSeries": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "oneshotsDirectory": { + "type": "string" + }, + "repairExtensions": { + "type": "boolean" + }, + "root": { + "type": "string", + "minLength": 1 + }, + "scanCbx": { + "type": "boolean" + }, + "scanDirectoryExclusions": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "scanEpub": { + "type": "boolean" + }, + "scanForceModifiedTime": { + "type": "boolean" + }, + "scanInterval": { + "type": "string", + "enum": [ + "DISABLED", + "HOURLY", + "EVERY_6H", + "EVERY_12H", + "DAILY", + "WEEKLY" + ] + }, + "scanOnStartup": { + "type": "boolean" + }, + "scanPdf": { + "type": "boolean" + }, + "seriesCover": { + "type": "string", + "enum": [ + "FIRST", + "FIRST_UNREAD_OR_FIRST", + "FIRST_UNREAD_OR_LAST", + "LAST" + ] + } + }, + "required": [ + "analyzeDimensions", + "convertToCbz", + "emptyTrashAfterScan", + "hashFiles", + "hashKoreader", + "hashPages", + "importBarcodeIsbn", + "importComicInfoBook", + "importComicInfoCollection", + "importComicInfoReadList", + "importComicInfoSeries", + "importComicInfoSeriesAppendVolume", + "importEpubBook", + "importEpubSeries", + "importLocalArtwork", + "importMylarSeries", + "name", + "repairExtensions", + "root", + "scanCbx", + "scanDirectoryExclusions", + "scanEpub", + "scanForceModifiedTime", + "scanInterval", + "scanOnStartup", + "scanPdf", + "seriesCover" + ] + }, + "LibraryDto": { + "type": "object", + "properties": { + "analyzeDimensions": { + "type": "boolean" + }, + "convertToCbz": { + "type": "boolean" + }, + "emptyTrashAfterScan": { + "type": "boolean" + }, + "hashFiles": { + "type": "boolean" + }, + "hashKoreader": { + "type": "boolean" + }, + "hashPages": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "importBarcodeIsbn": { + "type": "boolean" + }, + "importComicInfoBook": { + "type": "boolean" + }, + "importComicInfoCollection": { + "type": "boolean" + }, + "importComicInfoReadList": { + "type": "boolean" + }, + "importComicInfoSeries": { + "type": "boolean" + }, + "importComicInfoSeriesAppendVolume": { + "type": "boolean" + }, + "importEpubBook": { + "type": "boolean" + }, + "importEpubSeries": { + "type": "boolean" + }, + "importLocalArtwork": { + "type": "boolean" + }, + "importMylarSeries": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "oneshotsDirectory": { + "type": "string" + }, + "repairExtensions": { + "type": "boolean" + }, + "root": { + "type": "string" + }, + "scanCbx": { + "type": "boolean" + }, + "scanDirectoryExclusions": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "scanEpub": { + "type": "boolean" + }, + "scanForceModifiedTime": { + "type": "boolean" + }, + "scanInterval": { + "type": "string", + "enum": [ + "DISABLED", + "HOURLY", + "EVERY_6H", + "EVERY_12H", + "DAILY", + "WEEKLY" + ] + }, + "scanOnStartup": { + "type": "boolean" + }, + "scanPdf": { + "type": "boolean" + }, + "seriesCover": { + "type": "string", + "enum": [ + "FIRST", + "FIRST_UNREAD_OR_FIRST", + "FIRST_UNREAD_OR_LAST", + "LAST" + ] + }, + "unavailable": { + "type": "boolean" + } + }, + "required": [ + "analyzeDimensions", + "convertToCbz", + "emptyTrashAfterScan", + "hashFiles", + "hashKoreader", + "hashPages", + "id", + "importBarcodeIsbn", + "importComicInfoBook", + "importComicInfoCollection", + "importComicInfoReadList", + "importComicInfoSeries", + "importComicInfoSeriesAppendVolume", + "importEpubBook", + "importEpubSeries", + "importLocalArtwork", + "importMylarSeries", + "name", + "repairExtensions", + "root", + "scanCbx", + "scanDirectoryExclusions", + "scanEpub", + "scanForceModifiedTime", + "scanInterval", + "scanOnStartup", + "scanPdf", + "seriesCover", + "unavailable" + ] + }, + "LibraryUpdateDto": { + "type": "object", + "description": "Fields to update. You can omit fields you don\u0027t want to update.", + "properties": { + "analyzeDimensions": { + "type": "boolean" + }, + "convertToCbz": { + "type": "boolean" + }, + "emptyTrashAfterScan": { + "type": "boolean" + }, + "hashFiles": { + "type": "boolean" + }, + "hashKoreader": { + "type": "boolean" + }, + "hashPages": { + "type": "boolean" + }, + "importBarcodeIsbn": { + "type": "boolean" + }, + "importComicInfoBook": { + "type": "boolean" + }, + "importComicInfoCollection": { + "type": "boolean" + }, + "importComicInfoReadList": { + "type": "boolean" + }, + "importComicInfoSeries": { + "type": "boolean" + }, + "importComicInfoSeriesAppendVolume": { + "type": "boolean" + }, + "importEpubBook": { + "type": "boolean" + }, + "importEpubSeries": { + "type": "boolean" + }, + "importLocalArtwork": { + "type": "boolean" + }, + "importMylarSeries": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "oneshotsDirectory": { + "type": "string" + }, + "repairExtensions": { + "type": "boolean" + }, + "root": { + "type": "string" + }, + "scanCbx": { + "type": "boolean" + }, + "scanDirectoryExclusions": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "scanEpub": { + "type": "boolean" + }, + "scanForceModifiedTime": { + "type": "boolean" + }, + "scanInterval": { + "type": "string", + "enum": [ + "DISABLED", + "HOURLY", + "EVERY_6H", + "EVERY_12H", + "DAILY", + "WEEKLY" + ] + }, + "scanOnStartup": { + "type": "boolean" + }, + "scanPdf": { + "type": "boolean" + }, + "seriesCover": { + "type": "string", + "enum": [ + "FIRST", + "FIRST_UNREAD_OR_FIRST", + "FIRST_UNREAD_OR_LAST", + "LAST" + ] + } + } + }, + "Location": { + "type": "object", + "properties": { + "fragments": { + "type": "array", + "items": { + "type": "string" + } + }, + "position": { + "type": "integer", + "format": "int32" + }, + "progression": { + "type": "number", + "format": "float" + }, + "totalProgression": { + "type": "number", + "format": "float" + } + }, + "required": [ + "fragments" + ] + }, + "MediaDto": { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "epubDivinaCompatible": { + "type": "boolean" + }, + "epubIsKepub": { + "type": "boolean" + }, + "mediaProfile": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "pagesCount": { + "type": "integer", + "format": "int32" + }, + "status": { + "type": "string" + } + }, + "required": [ + "comment", + "epubDivinaCompatible", + "epubIsKepub", + "mediaProfile", + "mediaType", + "pagesCount", + "status" + ] + }, + "MediaType": { + "type": "object", + "properties": { + "charset": { + "type": "string" + }, + "concrete": { + "type": "boolean" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "qualityValue": { + "type": "number", + "format": "double" + }, + "subtype": { + "type": "string" + }, + "subtypeSuffix": { + "type": "string" + }, + "type": { + "type": "string" + }, + "wildcardSubtype": { + "type": "boolean" + }, + "wildcardType": { + "type": "boolean" + } + } + }, + "OAuth2ClientDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "registrationId": { + "type": "string" + } + }, + "required": [ + "name", + "registrationId" + ] + }, + "PageAuthenticationActivityDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthenticationActivityDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageAuthorDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageBookDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageCollectionDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageDto": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "mediaType": { + "type": "string" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "format": "int64" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "fileName", + "mediaType", + "number", + "size" + ] + }, + "PageHashCreationDto": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "DELETE_AUTO", + "DELETE_MANUAL", + "IGNORE" + ] + }, + "hash": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "action", + "hash" + ] + }, + "PageHashKnownDto": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "DELETE_AUTO", + "DELETE_MANUAL", + "IGNORE" + ] + }, + "created": { + "type": "string", + "format": "date-time" + }, + "deleteCount": { + "type": "integer", + "format": "int32" + }, + "hash": { + "type": "string" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "matchCount": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "action", + "created", + "deleteCount", + "hash", + "lastModified", + "matchCount" + ] + }, + "PageHashMatchDto": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "fileSize": { + "type": "integer", + "format": "int64" + }, + "mediaType": { + "type": "string" + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "url": { + "type": "string" + } + }, + "required": [ + "bookId", + "fileName", + "fileSize", + "mediaType", + "pageNumber", + "url" + ] + }, + "PageHashUnknownDto": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "matchCount": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "hash", + "matchCount" + ] + }, + "PageHistoricalEventDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HistoricalEventDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageInteger": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PagePageHashKnownDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageHashKnownDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PagePageHashMatchDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageHashMatchDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PagePageHashUnknownDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageHashUnknownDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageReadListDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageSeriesDto": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageString": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "string" + } + }, + "empty": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "number": { + "type": "integer", + "format": "int32" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageableObject": { + "type": "object", + "properties": { + "offset": { + "type": "integer", + "format": "int64" + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "paged": { + "type": "boolean" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "unpaged": { + "type": "boolean" + } + } + }, + "PasswordUpdateDto": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "password" + ] + }, + "PathDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ] + }, + "R2Device": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "R2Locator": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "koboSpan": { + "type": "string" + }, + "locations": { + "$ref": "#/components/schemas/Location" + }, + "text": { + "$ref": "#/components/schemas/Text" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "href", + "type" + ] + }, + "R2Positions": { + "type": "object", + "properties": { + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/R2Locator" + } + }, + "total": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "positions", + "total" + ] + }, + "R2Progression": { + "type": "object", + "properties": { + "device": { + "$ref": "#/components/schemas/R2Device" + }, + "locator": { + "$ref": "#/components/schemas/R2Locator" + }, + "modified": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "device", + "locator", + "modified" + ] + }, + "ReadListCreationDto": { + "type": "object", + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "ordered": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "bookIds", + "name", + "ordered", + "summary" + ] + }, + "ReadListDto": { + "type": "object", + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdDate": { + "type": "string", + "format": "date-time" + }, + "filtered": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "lastModifiedDate": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "ordered": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "bookIds", + "createdDate", + "filtered", + "id", + "lastModifiedDate", + "name", + "ordered", + "summary" + ] + }, + "ReadListMatchDto": { + "type": "object", + "properties": { + "errorCode": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "errorCode", + "name" + ] + }, + "ReadListRequestBookDto": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "series": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "number", + "series" + ] + }, + "ReadListRequestBookMatchBookDto": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "number": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "bookId", + "number", + "title" + ] + }, + "ReadListRequestBookMatchDto": { + "type": "object", + "properties": { + "books": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListRequestBookMatchBookDto" + } + }, + "series": { + "$ref": "#/components/schemas/ReadListRequestBookMatchSeriesDto" + } + }, + "required": [ + "books", + "series" + ] + }, + "ReadListRequestBookMatchSeriesDto": { + "type": "object", + "properties": { + "releaseDate": { + "type": "string", + "format": "date" + }, + "seriesId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "seriesId", + "title" + ] + }, + "ReadListRequestBookMatchesDto": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListRequestBookMatchDto" + } + }, + "request": { + "$ref": "#/components/schemas/ReadListRequestBookDto" + } + }, + "required": [ + "matches", + "request" + ] + }, + "ReadListRequestMatchDto": { + "type": "object", + "properties": { + "errorCode": { + "type": "string" + }, + "readListMatch": { + "$ref": "#/components/schemas/ReadListMatchDto" + }, + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListRequestBookMatchesDto" + } + } + }, + "required": [ + "errorCode", + "readListMatch", + "requests" + ] + }, + "ReadListUpdateDto": { + "type": "object", + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "ordered": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + } + }, + "ReadProgressDto": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "deviceName": { + "type": "string" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "readDate": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "completed", + "created", + "deviceId", + "deviceName", + "lastModified", + "page", + "readDate" + ] + }, + "ReadProgressUpdateDto": { + "type": "object", + "description": "page can be omitted if completed is set to true. completed can be omitted, and will be set accordingly depending on the page passed and the total number of pages in the book.", + "properties": { + "completed": { + "type": "boolean" + }, + "page": { + "type": "integer", + "format": "int32" + } + } + }, + "ReleaseDto": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "latest": { + "type": "boolean" + }, + "preRelease": { + "type": "boolean" + }, + "releaseDate": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "description", + "latest", + "preRelease", + "releaseDate", + "url", + "version" + ] + }, + "ScanRequestDto": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "SearchConditionAgeRating": { + "properties": { + "ageRating": { + "$ref": "#/components/schemas/SearchOperatorNumericNullableInteger" + } + }, + "required": [ + "ageRating" + ] + }, + "SearchConditionAllOfBook": { + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchConditionBook" + } + } + }, + "required": [ + "allOf" + ] + }, + "SearchConditionAllOfSeries": { + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchConditionSeries" + } + } + }, + "required": [ + "allOf" + ] + }, + "SearchConditionAnyOfBook": { + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchConditionBook" + } + } + }, + "required": [ + "anyOf" + ] + }, + "SearchConditionAnyOfSeries": { + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchConditionSeries" + } + } + }, + "required": [ + "anyOf" + ] + }, + "SearchConditionAuthor": { + "properties": { + "author": { + "$ref": "#/components/schemas/SearchOperatorEqualityAuthorMatch" + } + }, + "required": [ + "author" + ] + }, + "SearchConditionBook": { + "oneOf": [ + { + "$ref": "#/components/schemas/SearchConditionAnyOfBook" + }, + { + "$ref": "#/components/schemas/SearchConditionAllOfBook" + }, + { + "$ref": "#/components/schemas/SearchConditionLibraryId" + }, + { + "$ref": "#/components/schemas/SearchConditionReadListId" + }, + { + "$ref": "#/components/schemas/SearchConditionSeriesId" + }, + { + "$ref": "#/components/schemas/SearchConditionDeleted" + }, + { + "$ref": "#/components/schemas/SearchConditionOneShot" + }, + { + "$ref": "#/components/schemas/SearchConditionTitle" + }, + { + "$ref": "#/components/schemas/SearchConditionReleaseDate" + }, + { + "$ref": "#/components/schemas/SearchConditionTag" + }, + { + "$ref": "#/components/schemas/SearchConditionNumberSort" + }, + { + "$ref": "#/components/schemas/SearchConditionReadStatus" + }, + { + "$ref": "#/components/schemas/SearchConditionMediaStatus" + }, + { + "$ref": "#/components/schemas/SearchConditionMediaProfile" + }, + { + "$ref": "#/components/schemas/SearchConditionAuthor" + }, + { + "$ref": "#/components/schemas/SearchConditionPoster" + } + ] + }, + "SearchConditionCollectionId": { + "properties": { + "collectionId": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "collectionId" + ] + }, + "SearchConditionComplete": { + "properties": { + "complete": { + "$ref": "#/components/schemas/SearchOperatorBoolean" + } + }, + "required": [ + "complete" + ] + }, + "SearchConditionDeleted": { + "properties": { + "deleted": { + "$ref": "#/components/schemas/SearchOperatorBoolean" + } + }, + "required": [ + "deleted" + ] + }, + "SearchConditionGenre": { + "properties": { + "genre": { + "$ref": "#/components/schemas/SearchOperatorEqualityNullableString" + } + }, + "required": [ + "genre" + ] + }, + "SearchConditionLanguage": { + "properties": { + "language": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "language" + ] + }, + "SearchConditionLibraryId": { + "properties": { + "libraryId": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "libraryId" + ] + }, + "SearchConditionMediaProfile": { + "properties": { + "mediaProfile": { + "$ref": "#/components/schemas/SearchOperatorEqualityMediaProfile" + } + }, + "required": [ + "mediaProfile" + ] + }, + "SearchConditionMediaStatus": { + "properties": { + "mediaStatus": { + "$ref": "#/components/schemas/SearchOperatorEqualityStatus" + } + }, + "required": [ + "mediaStatus" + ] + }, + "SearchConditionNumberSort": { + "properties": { + "numberSort": { + "$ref": "#/components/schemas/SearchOperatorNumericTFloat" + } + }, + "required": [ + "numberSort" + ] + }, + "SearchConditionOneShot": { + "properties": { + "oneShot": { + "$ref": "#/components/schemas/SearchOperatorBoolean" + } + }, + "required": [ + "oneShot" + ] + }, + "SearchConditionPoster": { + "properties": { + "poster": { + "$ref": "#/components/schemas/SearchOperatorEqualityPosterMatch" + } + }, + "required": [ + "poster" + ] + }, + "SearchConditionPublisher": { + "properties": { + "publisher": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "publisher" + ] + }, + "SearchConditionReadListId": { + "properties": { + "readListId": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "readListId" + ] + }, + "SearchConditionReadStatus": { + "properties": { + "readStatus": { + "$ref": "#/components/schemas/SearchOperatorEqualityReadStatus" + } + }, + "required": [ + "readStatus" + ] + }, + "SearchConditionReleaseDate": { + "properties": { + "releaseDate": { + "$ref": "#/components/schemas/SearchOperatorDate" + } + }, + "required": [ + "releaseDate" + ] + }, + "SearchConditionSeries": { + "oneOf": [ + { + "$ref": "#/components/schemas/SearchConditionAnyOfSeries" + }, + { + "$ref": "#/components/schemas/SearchConditionAllOfSeries" + }, + { + "$ref": "#/components/schemas/SearchConditionLibraryId" + }, + { + "$ref": "#/components/schemas/SearchConditionCollectionId" + }, + { + "$ref": "#/components/schemas/SearchConditionDeleted" + }, + { + "$ref": "#/components/schemas/SearchConditionComplete" + }, + { + "$ref": "#/components/schemas/SearchConditionOneShot" + }, + { + "$ref": "#/components/schemas/SearchConditionTitle" + }, + { + "$ref": "#/components/schemas/SearchConditionTitleSort" + }, + { + "$ref": "#/components/schemas/SearchConditionReleaseDate" + }, + { + "$ref": "#/components/schemas/SearchConditionTag" + }, + { + "$ref": "#/components/schemas/SearchConditionSharingLabel" + }, + { + "$ref": "#/components/schemas/SearchConditionPublisher" + }, + { + "$ref": "#/components/schemas/SearchConditionLanguage" + }, + { + "$ref": "#/components/schemas/SearchConditionGenre" + }, + { + "$ref": "#/components/schemas/SearchConditionAgeRating" + }, + { + "$ref": "#/components/schemas/SearchConditionReadStatus" + }, + { + "$ref": "#/components/schemas/SearchConditionSeriesStatus" + }, + { + "$ref": "#/components/schemas/SearchConditionAuthor" + } + ] + }, + "SearchConditionSeriesId": { + "properties": { + "seriesId": { + "$ref": "#/components/schemas/SearchOperatorEqualityString" + } + }, + "required": [ + "seriesId" + ] + }, + "SearchConditionSeriesStatus": { + "properties": { + "seriesStatus": { + "$ref": "#/components/schemas/SearchOperatorEqualityStatus" + } + }, + "required": [ + "seriesStatus" + ] + }, + "SearchConditionSharingLabel": { + "properties": { + "sharingLabel": { + "$ref": "#/components/schemas/SearchOperatorEqualityNullableString" + } + }, + "required": [ + "sharingLabel" + ] + }, + "SearchConditionTag": { + "properties": { + "tag": { + "$ref": "#/components/schemas/SearchOperatorEqualityNullableString" + } + }, + "required": [ + "tag" + ] + }, + "SearchConditionTitle": { + "properties": { + "title": { + "$ref": "#/components/schemas/SearchOperatorString" + } + }, + "required": [ + "title" + ] + }, + "SearchConditionTitleSort": { + "properties": { + "titleSort": { + "$ref": "#/components/schemas/SearchOperatorString" + } + }, + "required": [ + "titleSort" + ] + }, + "SearchOperatorAfter": { + "properties": { + "dateTime": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "dateTime" + ] + }, + "SearchOperatorBefore": { + "properties": { + "dateTime": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "dateTime" + ] + }, + "SearchOperatorBeginsWith": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorBoolean": { + "discriminator": { + "mapping": { + "isFalse": "#/components/schemas/SearchOperatorIsFalse", + "isTrue": "#/components/schemas/SearchOperatorIsTrue" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIsTrue" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsFalse" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorContains": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorDate": { + "discriminator": { + "mapping": { + "after": "#/components/schemas/SearchOperatorAfter", + "before": "#/components/schemas/SearchOperatorBefore", + "isInTheLast": "#/components/schemas/SearchOperatorIsInTheLast", + "isNotInTheLast": "#/components/schemas/SearchOperatorIsNotInTheLast", + "isNotNull": "#/components/schemas/SearchOperatorIsNotNull", + "isNull": "#/components/schemas/SearchOperatorIsNull" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorBefore" + }, + { + "$ref": "#/components/schemas/SearchOperatorAfter" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsInTheLast" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNotInTheLast" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNull" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNotNull" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorDoesNotBeginWith": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorDoesNotContain": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorDoesNotEndWith": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorEndsWith": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "SearchOperatorEqualityAuthorMatch": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityMediaProfile": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityNullableString": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot", + "isNotNull": "#/components/schemas/SearchOperatorIsNotNullT", + "isNull": "#/components/schemas/SearchOperatorIsNullT" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNullT" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNotNullT" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityPosterMatch": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityReadStatus": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityStatus": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorEqualityString": { + "discriminator": { + "mapping": { + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorGreaterThan": { + "properties": { + "value": {} + }, + "required": [ + "value" + ] + }, + "SearchOperatorIs": { + "properties": { + "value": {} + }, + "required": [ + "value" + ] + }, + "SearchOperatorIsFalse": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorBoolean" + } + ] + }, + "SearchOperatorIsInTheLast": { + "properties": { + "duration": { + "type": "string" + } + }, + "required": [ + "duration" + ] + }, + "SearchOperatorIsNot": { + "properties": { + "value": {} + }, + "required": [ + "value" + ] + }, + "SearchOperatorIsNotInTheLast": { + "properties": { + "duration": { + "type": "string" + } + }, + "required": [ + "duration" + ] + }, + "SearchOperatorIsNotNull": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorDate" + } + ] + }, + "SearchOperatorIsNotNullT": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorNumericNullableInteger" + }, + { + "$ref": "#/components/schemas/SearchOperatorEqualityNullableString" + } + ] + }, + "SearchOperatorIsNull": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorDate" + } + ] + }, + "SearchOperatorIsNullT": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorNumericNullableInteger" + }, + { + "$ref": "#/components/schemas/SearchOperatorEqualityNullableString" + } + ] + }, + "SearchOperatorIsTrue": { + "allOf": [ + { + "$ref": "#/components/schemas/SearchOperatorBoolean" + } + ] + }, + "SearchOperatorLessThan": { + "properties": { + "value": {} + }, + "required": [ + "value" + ] + }, + "SearchOperatorNumericNullableInteger": { + "discriminator": { + "mapping": { + "greaterThan": "#/components/schemas/SearchOperatorGreaterThan", + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot", + "isNotNull": "#/components/schemas/SearchOperatorIsNotNullT", + "isNull": "#/components/schemas/SearchOperatorIsNullT", + "lessThan": "#/components/schemas/SearchOperatorLessThan" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorGreaterThan" + }, + { + "$ref": "#/components/schemas/SearchOperatorLessThan" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNullT" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNotNullT" + }, + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorNumericTFloat": { + "discriminator": { + "mapping": { + "greaterThan": "#/components/schemas/SearchOperatorGreaterThan", + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot", + "lessThan": "#/components/schemas/SearchOperatorLessThan" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorGreaterThan" + }, + { + "$ref": "#/components/schemas/SearchOperatorLessThan" + }, + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SearchOperatorString": { + "discriminator": { + "mapping": { + "beginsWith": "#/components/schemas/SearchOperatorBeginsWith", + "contains": "#/components/schemas/SearchOperatorContains", + "doesNotBeginWith": "#/components/schemas/SearchOperatorDoesNotBeginWith", + "doesNotContain": "#/components/schemas/SearchOperatorDoesNotContain", + "doesNotEndWith": "#/components/schemas/SearchOperatorDoesNotEndWith", + "endsWith": "#/components/schemas/SearchOperatorEndsWith", + "is": "#/components/schemas/SearchOperatorIs", + "isNot": "#/components/schemas/SearchOperatorIsNot" + }, + "propertyName": "operator" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchOperatorBeginsWith" + }, + { + "$ref": "#/components/schemas/SearchOperatorDoesNotBeginWith" + }, + { + "$ref": "#/components/schemas/SearchOperatorContains" + }, + { + "$ref": "#/components/schemas/SearchOperatorDoesNotContain" + }, + { + "$ref": "#/components/schemas/SearchOperatorEndsWith" + }, + { + "$ref": "#/components/schemas/SearchOperatorDoesNotEndWith" + }, + { + "$ref": "#/components/schemas/SearchOperatorIs" + }, + { + "$ref": "#/components/schemas/SearchOperatorIsNot" + } + ], + "properties": { + "operator": { + "type": "string" + } + }, + "required": [ + "operator" + ] + }, + "SeriesDto": { + "type": "object", + "properties": { + "booksCount": { + "type": "integer", + "format": "int32" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32" + }, + "booksMetadata": { + "$ref": "#/components/schemas/BookMetadataAggregationDto" + }, + "booksReadCount": { + "type": "integer", + "format": "int32" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "deleted": { + "type": "boolean" + }, + "fileLastModified": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "libraryId": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/SeriesMetadataDto" + }, + "name": { + "type": "string" + }, + "oneshot": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "booksCount", + "booksInProgressCount", + "booksMetadata", + "booksReadCount", + "booksUnreadCount", + "created", + "deleted", + "fileLastModified", + "id", + "lastModified", + "libraryId", + "metadata", + "name", + "oneshot", + "url" + ] + }, + "SeriesMetadataDto": { + "type": "object", + "properties": { + "ageRating": { + "type": "integer", + "format": "int32" + }, + "ageRatingLock": { + "type": "boolean" + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleDto" + } + }, + "alternateTitlesLock": { + "type": "boolean" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "genresLock": { + "type": "boolean" + }, + "language": { + "type": "string" + }, + "languageLock": { + "type": "boolean" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebLinkDto" + } + }, + "linksLock": { + "type": "boolean" + }, + "publisher": { + "type": "string" + }, + "publisherLock": { + "type": "boolean" + }, + "readingDirection": { + "type": "string" + }, + "readingDirectionLock": { + "type": "boolean" + }, + "sharingLabels": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "sharingLabelsLock": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "statusLock": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "summaryLock": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "tagsLock": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "titleLock": { + "type": "boolean" + }, + "titleSort": { + "type": "string" + }, + "titleSortLock": { + "type": "boolean" + }, + "totalBookCount": { + "type": "integer", + "format": "int32" + }, + "totalBookCountLock": { + "type": "boolean" + } + }, + "required": [ + "ageRatingLock", + "alternateTitles", + "alternateTitlesLock", + "created", + "genres", + "genresLock", + "language", + "languageLock", + "lastModified", + "links", + "linksLock", + "publisher", + "publisherLock", + "readingDirection", + "readingDirectionLock", + "sharingLabels", + "sharingLabelsLock", + "status", + "statusLock", + "summary", + "summaryLock", + "tags", + "tagsLock", + "title", + "titleLock", + "titleSort", + "titleSortLock", + "totalBookCountLock" + ] + }, + "SeriesMetadataUpdateDto": { + "type": "object", + "description": "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don\u0027t want to update.", + "properties": { + "ageRating": { + "type": "integer", + "format": "int32" + }, + "ageRatingLock": { + "type": "boolean" + }, + "alternateTitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlternateTitleUpdateDto" + } + }, + "alternateTitlesLock": { + "type": "boolean" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "genresLock": { + "type": "boolean" + }, + "language": { + "type": "string" + }, + "languageLock": { + "type": "boolean" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebLinkUpdateDto" + } + }, + "linksLock": { + "type": "boolean" + }, + "publisher": { + "type": "string" + }, + "publisherLock": { + "type": "boolean" + }, + "readingDirection": { + "type": "string", + "enum": [ + "LEFT_TO_RIGHT", + "RIGHT_TO_LEFT", + "VERTICAL", + "WEBTOON" + ] + }, + "readingDirectionLock": { + "type": "boolean" + }, + "sharingLabels": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "sharingLabelsLock": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "ENDED", + "ONGOING", + "ABANDONED", + "HIATUS" + ] + }, + "statusLock": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "summaryLock": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "tagsLock": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "titleLock": { + "type": "boolean" + }, + "titleSort": { + "type": "string" + }, + "titleSortLock": { + "type": "boolean" + }, + "totalBookCount": { + "type": "integer", + "format": "int32" + }, + "totalBookCountLock": { + "type": "boolean" + } + } + }, + "SeriesSearch": { + "type": "object", + "properties": { + "condition": { + "$ref": "#/components/schemas/SearchConditionSeries" + }, + "fullTextSearch": { + "type": "string" + } + } + }, + "SettingMultiSourceInteger": { + "type": "object", + "properties": { + "configurationSource": { + "type": "integer", + "format": "int32" + }, + "databaseSource": { + "type": "integer", + "format": "int32" + }, + "effectiveValue": { + "type": "integer", + "format": "int32" + } + } + }, + "SettingMultiSourceString": { + "type": "object", + "properties": { + "configurationSource": { + "type": "string" + }, + "databaseSource": { + "type": "string" + }, + "effectiveValue": { + "type": "string" + } + } + }, + "SettingsDto": { + "type": "object", + "properties": { + "deleteEmptyCollections": { + "type": "boolean" + }, + "deleteEmptyReadLists": { + "type": "boolean" + }, + "kepubifyPath": { + "$ref": "#/components/schemas/SettingMultiSourceString" + }, + "koboPort": { + "type": "integer", + "format": "int32" + }, + "koboProxy": { + "type": "boolean" + }, + "rememberMeDurationDays": { + "type": "integer", + "format": "int64" + }, + "serverContextPath": { + "$ref": "#/components/schemas/SettingMultiSourceString" + }, + "serverPort": { + "$ref": "#/components/schemas/SettingMultiSourceInteger" + }, + "taskPoolSize": { + "type": "integer", + "format": "int32" + }, + "thumbnailSize": { + "type": "string", + "enum": [ + "DEFAULT", + "MEDIUM", + "LARGE", + "XLARGE" + ] + } + }, + "required": [ + "deleteEmptyCollections", + "deleteEmptyReadLists", + "kepubifyPath", + "koboProxy", + "rememberMeDurationDays", + "serverContextPath", + "serverPort", + "taskPoolSize", + "thumbnailSize" + ] + }, + "SettingsUpdateDto": { + "type": "object", + "description": "Fields to update. You can omit fields you don\u0027t want to update.", + "properties": { + "deleteEmptyCollections": { + "type": "boolean" + }, + "deleteEmptyReadLists": { + "type": "boolean" + }, + "kepubifyPath": { + "type": "string", + "deprecated": true, + "description": "Will be removed in a future version" + }, + "koboPort": { + "type": "integer", + "format": "int32", + "maximum": 65535 + }, + "koboProxy": { + "type": "boolean" + }, + "rememberMeDurationDays": { + "type": "integer", + "format": "int64" + }, + "renewRememberMeKey": { + "type": "boolean" + }, + "serverContextPath": { + "type": "string", + "pattern": "^/[\\w-/]*[a-zA-Z0-9]$" + }, + "serverPort": { + "type": "integer", + "format": "int32", + "maximum": 65535 + }, + "taskPoolSize": { + "type": "integer", + "format": "int32" + }, + "thumbnailSize": { + "type": "string", + "enum": [ + "DEFAULT", + "MEDIUM", + "LARGE", + "XLARGE" + ] + } + } + }, + "SharedLibrariesUpdateDto": { + "type": "object", + "properties": { + "all": { + "type": "boolean" + }, + "libraryIds": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "all", + "libraryIds" + ] + }, + "SortObject": { + "type": "object", + "properties": { + "empty": { + "type": "boolean" + }, + "sorted": { + "type": "boolean" + }, + "unsorted": { + "type": "boolean" + } + } + }, + "StreamingResponseBody": {}, + "TachiyomiReadProgressDto": { + "type": "object", + "properties": { + "booksCount": { + "type": "integer", + "format": "int32" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32" + }, + "booksReadCount": { + "type": "integer", + "format": "int32" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32" + }, + "lastReadContinuousIndex": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "booksCount", + "booksInProgressCount", + "booksReadCount", + "booksUnreadCount", + "lastReadContinuousIndex" + ] + }, + "TachiyomiReadProgressUpdateDto": { + "type": "object", + "properties": { + "lastBookRead": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "lastBookRead" + ] + }, + "TachiyomiReadProgressUpdateV2Dto": { + "type": "object", + "properties": { + "lastBookNumberSortRead": { + "type": "number", + "format": "float" + } + }, + "required": [ + "lastBookNumberSortRead" + ] + }, + "TachiyomiReadProgressV2Dto": { + "type": "object", + "properties": { + "booksCount": { + "type": "integer", + "format": "int32" + }, + "booksInProgressCount": { + "type": "integer", + "format": "int32" + }, + "booksReadCount": { + "type": "integer", + "format": "int32" + }, + "booksUnreadCount": { + "type": "integer", + "format": "int32" + }, + "lastReadContinuousNumberSort": { + "type": "number", + "format": "float" + }, + "maxNumberSort": { + "type": "number", + "format": "float" + } + }, + "required": [ + "booksCount", + "booksInProgressCount", + "booksReadCount", + "booksUnreadCount", + "lastReadContinuousNumberSort", + "maxNumberSort" + ] + }, + "Text": { + "type": "object", + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + }, + "highlight": { + "type": "string" + } + } + }, + "ThumbnailBookDto": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "fileSize": { + "type": "integer", + "format": "int64" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "bookId", + "fileSize", + "height", + "id", + "mediaType", + "selected", + "type", + "width" + ] + }, + "ThumbnailReadListDto": { + "type": "object", + "properties": { + "fileSize": { + "type": "integer", + "format": "int64" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "readListId": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "fileSize", + "height", + "id", + "mediaType", + "readListId", + "selected", + "type", + "width" + ] + }, + "ThumbnailSeriesCollectionDto": { + "type": "object", + "properties": { + "collectionId": { + "type": "string" + }, + "fileSize": { + "type": "integer", + "format": "int64" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "collectionId", + "fileSize", + "height", + "id", + "mediaType", + "selected", + "type", + "width" + ] + }, + "ThumbnailSeriesDto": { + "type": "object", + "properties": { + "fileSize": { + "type": "integer", + "format": "int64" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "seriesId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "fileSize", + "height", + "id", + "mediaType", + "selected", + "seriesId", + "type", + "width" + ] + }, + "TransientBookDto": { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "fileLastModified": { + "type": "string", + "format": "date-time" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "number", + "format": "float" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageDto" + } + }, + "seriesId": { + "type": "string" + }, + "size": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "comment", + "fileLastModified", + "files", + "id", + "mediaType", + "name", + "pages", + "size", + "sizeBytes", + "status", + "url" + ] + }, + "UserCreationDto": { + "type": "object", + "properties": { + "ageRestriction": { + "$ref": "#/components/schemas/AgeRestrictionUpdateDto" + }, + "email": { + "type": "string" + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "password": { + "type": "string", + "minLength": 1 + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "sharedLibraries": { + "$ref": "#/components/schemas/SharedLibrariesUpdateDto" + } + }, + "required": [ + "email", + "password", + "roles" + ] + }, + "UserDto": { + "type": "object", + "properties": { + "ageRestriction": { + "$ref": "#/components/schemas/AgeRestrictionDto" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "sharedAllLibraries": { + "type": "boolean" + }, + "sharedLibrariesIds": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "email", + "id", + "labelsAllow", + "labelsExclude", + "roles", + "sharedAllLibraries", + "sharedLibrariesIds" + ] + }, + "UserUpdateDto": { + "type": "object", + "properties": { + "ageRestriction": { + "$ref": "#/components/schemas/AgeRestrictionUpdateDto" + }, + "labelsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "labelsExclude": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "sharedLibraries": { + "$ref": "#/components/schemas/SharedLibrariesUpdateDto" + } + } + }, + "ValidationErrorResponse": { + "type": "object", + "properties": { + "violations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Violation" + } + } + }, + "required": [ + "violations" + ] + }, + "Violation": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "WPBelongsToDto": { + "type": "object", + "properties": { + "collection": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPContributorDto" + } + }, + "series": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPContributorDto" + } + } + }, + "required": [ + "collection", + "series" + ] + }, + "WPContributorDto": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "name": { + "type": "string" + }, + "position": { + "type": "number", + "format": "float" + } + }, + "required": [ + "links", + "name" + ] + }, + "WPLinkDto": { + "type": "object", + "properties": { + "height": { + "type": "integer", + "format": "int32" + }, + "href": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "rel": { + "type": "string" + }, + "templated": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "properties" + ] + }, + "WPMetadataDto": { + "type": "object", + "properties": { + "artist": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "type": "array", + "items": { + "type": "string" + } + }, + "belongsTo": { + "$ref": "#/components/schemas/WPBelongsToDto" + }, + "colorist": { + "type": "array", + "items": { + "type": "string" + } + }, + "conformsTo": { + "type": "string" + }, + "contributor": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "editor": { + "type": "array", + "items": { + "type": "string" + } + }, + "identifier": { + "type": "string" + }, + "illustrator": { + "type": "array", + "items": { + "type": "string" + } + }, + "inker": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "string" + }, + "letterer": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "numberOfPages": { + "type": "integer", + "format": "int32" + }, + "penciler": { + "type": "array", + "items": { + "type": "string" + } + }, + "published": { + "type": "string", + "format": "date" + }, + "publisher": { + "type": "array", + "items": { + "type": "string" + } + }, + "readingProgression": { + "type": "string", + "enum": [ + "rtl", + "ltr", + "ttb", + "btt", + "auto" + ] + }, + "rendition": { + "type": "object", + "additionalProperties": {} + }, + "sortAs": { + "type": "string" + }, + "subject": { + "type": "array", + "items": { + "type": "string" + } + }, + "subtitle": { + "type": "string" + }, + "title": { + "type": "string" + }, + "translator": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string" + } + }, + "required": [ + "artist", + "author", + "colorist", + "contributor", + "editor", + "illustrator", + "inker", + "letterer", + "penciler", + "publisher", + "rendition", + "subject", + "title", + "translator" + ] + }, + "WPPublicationDto": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "images": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "landmarks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "metadata": { + "$ref": "#/components/schemas/WPMetadataDto" + }, + "pageList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "readingOrder": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + }, + "toc": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPLinkDto" + } + } + }, + "required": [ + "images", + "landmarks", + "links", + "metadata", + "pageList", + "readingOrder", + "resources", + "toc" + ] + }, + "WebLinkDto": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "label", + "url" + ] + }, + "WebLinkUpdateDto": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string" + } + }, + "required": [ + "label" + ] + } + }, + "securitySchemes": { + "apiKey": { + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "basicAuth": { + "scheme": "basic", + "type": "http" + } + } + }, + "x-tagGroups": [ + { + "name": "Deprecation", + "tags": [ + "Deprecated" + ] + }, + { + "name": "Libraries", + "tags": [ + "Libraries" + ] + }, + { + "name": "Series", + "tags": [ + "Series", + "Series Poster" + ] + }, + { + "name": "Books", + "tags": [ + "Books", + "Book Pages", + "Book Poster", + "Import", + "Duplicate Pages", + "WebPub Manifest", + "Fonts" + ] + }, + { + "name": "Collections", + "tags": [ + "Collections", + "Collection Series", + "Collection Poster" + ] + }, + { + "name": "Readlists", + "tags": [ + "Readlists", + "Readlist Books", + "Readlist Poster" + ] + }, + { + "name": "Referential", + "tags": [ + "Referential metadata" + ] + }, + { + "name": "Users", + "tags": [ + "Current user", + "Users", + "API Keys", + "User session", + "OAuth2", + "Sync points" + ] + }, + { + "name": "Server", + "tags": [ + "Claim", + "Server settings", + "Tasks", + "History", + "File system", + "Releases", + "Announcements", + "Management" + ] + }, + { + "name": "Integrations", + "tags": [ + "Client settings", + "Mihon", + "ComicRack" + ] + } + ] +} \ No newline at end of file diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/AbstractBenchmark.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/AbstractBenchmark.kt new file mode 100644 index 000000000..40e358fa7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/AbstractBenchmark.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.benchmark + +import org.junit.jupiter.api.Test +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.results.format.ResultFormatType +import org.openjdk.jmh.runner.Runner +import org.openjdk.jmh.runner.options.Options +import org.openjdk.jmh.runner.options.OptionsBuilder +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.absolutePathString + +@SpringBootTest +@ActiveProfiles("benchmark") +@State(Scope.Benchmark) +abstract class AbstractBenchmark { + @Autowired + private lateinit var benchmarkProperties: BenchmarkProperties + + @Test + fun executeJmhRunner() { + if (this.javaClass.simpleName.contains("jmhType")) return + Files.createDirectories(Paths.get(benchmarkProperties.resultFolder)) + val extension = + when (benchmarkProperties.resultFormat) { + ResultFormatType.TEXT -> "txt" + ResultFormatType.CSV -> "csv" + ResultFormatType.SCSV -> "scsv" + ResultFormatType.JSON -> "json" + ResultFormatType.LATEX -> "tex" + } + val resultFile = Paths.get(benchmarkProperties.resultFolder, "${this.javaClass.name}.$extension").absolutePathString() + val opt: Options = + OptionsBuilder() + .include("\\." + this.javaClass.simpleName + "\\.") // set the class name regex for benchmarks to search for to the current class + .apply { if (benchmarkProperties.warmupIterations > 0) warmupIterations(benchmarkProperties.warmupIterations) } + .measurementIterations(benchmarkProperties.measurementIterations) + .forks(0) // do not use forking or the benchmark methods will not see references stored within its class + .threads(1) // do not use multiple threads + .mode(benchmarkProperties.mode) + .shouldDoGC(true) + .shouldFailOnError(true) + .resultFormat(benchmarkProperties.resultFormat) + .result(resultFile) + .shouldFailOnError(true) + .jvmArgs("-server") + .build() + Runner(opt).run() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/BenchmarkProperties.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/BenchmarkProperties.kt new file mode 100644 index 000000000..d1d1b24d8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/BenchmarkProperties.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.benchmark + +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.PositiveOrZero +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.results.format.ResultFormatType +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated + +@Component +@ConfigurationProperties(prefix = "benchmark") +@Validated +class BenchmarkProperties { + @PositiveOrZero + var warmupIterations: Int = 1 + + @Positive + var measurementIterations: Int = 1 + + var resultFolder: String = "" + + var resultFormat: ResultFormatType = ResultFormatType.TEXT + + var mode: Mode = Mode.AverageTime +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/AbstractRestBenchmark.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/AbstractRestBenchmark.kt new file mode 100644 index 000000000..b6fa4de08 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/AbstractRestBenchmark.kt @@ -0,0 +1,46 @@ +package org.gotson.komga.benchmark.rest + +import org.gotson.komga.benchmark.AbstractBenchmark +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.BookController +import org.gotson.komga.interfaces.api.rest.SeriesController +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.Setup +import org.springframework.beans.factory.annotation.Autowired + +internal const val DEFAULT_PAGE_SIZE = 20 + +abstract class AbstractRestBenchmark : AbstractBenchmark() { + companion object { + lateinit var userRepository: KomgaUserRepository + lateinit var seriesController: SeriesController + lateinit var bookController: BookController + } + + @Autowired + fun setUserRepository(repository: KomgaUserRepository) { + userRepository = repository + } + + @Autowired + fun setSeriesController(controller: SeriesController) { + seriesController = controller + } + + @Autowired + fun setBookController(controller: BookController) { + bookController = controller + } + + protected lateinit var principal: KomgaPrincipal + + @Setup(Level.Trial) + fun prepareData() { + principal = + KomgaPrincipal( + userRepository.findByEmailIgnoreCaseOrNull("admin@example.org") + ?: throw IllegalStateException("no user found"), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/BrowseBenchmark.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/BrowseBenchmark.kt new file mode 100644 index 000000000..550710c56 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/BrowseBenchmark.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.benchmark.rest + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesSearch +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param +import org.openjdk.jmh.annotations.Setup +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import java.util.concurrent.TimeUnit + +@OutputTimeUnit(TimeUnit.MILLISECONDS) +class BrowseBenchmark : AbstractRestBenchmark() { + companion object { + private lateinit var biggestSeriesId: String + } + + @Param("20", "50", "100", "200", "500") + private var pageSize: Int = DEFAULT_PAGE_SIZE + + @Setup(Level.Trial) + override fun prepareData() { + super.prepareData() + + // find series with most books + biggestSeriesId = + seriesController + .getSeries(principal, page = PageRequest.of(0, 1, Sort.by(Sort.Order.desc("booksCount"))), search = SeriesSearch()) + .content + .first() + .id + } + + @Benchmark + fun browseSeries() { + seriesController.getSeries(principal, page = PageRequest.of(0, pageSize, Sort.by(Sort.Order.asc("metadata.titleSort"))), search = SeriesSearch()) + } + + @Benchmark + fun browseSeriesBooks() { + bookController.getBooks(principal, page = PageRequest.of(0, pageSize, Sort.by(Sort.Order.asc("metadata.numberSort"))), search = BookSearch(SearchCondition.SeriesId(SearchOperator.Is(biggestSeriesId)))) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/DashboardBenchmark.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/DashboardBenchmark.kt new file mode 100644 index 000000000..af70f8825 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/DashboardBenchmark.kt @@ -0,0 +1,148 @@ +package org.gotson.komga.benchmark.rest + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.interfaces.api.rest.dto.ReadProgressUpdateDto +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Setup +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit + +@OutputTimeUnit(TimeUnit.MILLISECONDS) +class DashboardBenchmark : AbstractRestBenchmark() { + companion object { + lateinit var bookLatestReleaseDate: ZonedDateTime + } + + @Setup(Level.Trial) + override fun prepareData() { + super.prepareData() + + // mark some books in progress + bookController.getBooks(principal, page = Pageable.ofSize(DEFAULT_PAGE_SIZE), search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)))).let { page -> + if (page.totalElements < DEFAULT_PAGE_SIZE) { + bookController.getBooks(principal, page = Pageable.ofSize(DEFAULT_PAGE_SIZE), search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)))).content.forEach { book -> + bookController.markBookReadProgress(book.id, ReadProgressUpdateDto(2, false), principal) + } + } + } + + // mark some books read for on deck + bookController.getBooksOnDeck(principal, page = Pageable.ofSize(DEFAULT_PAGE_SIZE)).let { page -> + if (page.totalElements < DEFAULT_PAGE_SIZE) { + seriesController + .getSeries( + principal, + page = Pageable.ofSize(DEFAULT_PAGE_SIZE), + search = + SeriesSearch( + SearchCondition.AllOfSeries( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + SearchCondition.OneShot(SearchOperator.IsFalse), + ), + ), + ).content + .forEach { series -> + val book = bookController.getBooks(principal, page = Pageable.ofSize(1), search = BookSearch(SearchCondition.SeriesId(SearchOperator.Is(series.id)))).content.first() + bookController.markBookReadProgress(book.id, ReadProgressUpdateDto(null, true), principal) + } + } + } + + // retrieve most recent book release date + bookLatestReleaseDate = bookController + .getBooks(principal, page = PageRequest.of(0, 1, Sort.by(Sort.Order.desc("metadata.releaseDate"))), search = BookSearch()) + .content + .firstOrNull() + ?.metadata + ?.releaseDate + ?.atStartOfDay(ZoneOffset.UTC) ?: ZonedDateTime.now() + } + + @Benchmark + fun loadDashboard() { + runBlocking { + withContext(Dispatchers.Default) { + launch { getBooksInProgress() } + launch { getBooksOnDeck() } + launch { getBooksLatest() } + launch { getBooksRecentlyReleased() } + launch { getSeriesNew() } + launch { getSeriesUpdated() } + } + } + } + + val pageableBooksInProgress = PageRequest.of(0, DEFAULT_PAGE_SIZE, Sort.by(Sort.Order.desc("readProgress.readDate"))) + + @Benchmark + fun getBooksInProgress() { + bookController.getBooks(principal, page = pageableBooksInProgress, search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)))) + } + + val pageableBooksOnDeck = Pageable.ofSize(DEFAULT_PAGE_SIZE) + + @Benchmark + fun getBooksOnDeck() { + bookController.getBooksOnDeck(principal, page = pageableBooksOnDeck) + } + + val pageableBooksLatest = PageRequest.of(0, DEFAULT_PAGE_SIZE, Sort.by(Sort.Order.desc("createdDate"))) + + @Benchmark + fun getBooksLatest() { + bookController.getBooks(principal, page = pageableBooksLatest, search = BookSearch()) + } + + val pageableBooksRecentlyReleased = PageRequest.of(0, DEFAULT_PAGE_SIZE, Sort.by(Sort.Order.desc("metadata.releaseDate"))) + + @Benchmark + fun getBooksRecentlyReleased() { // releasedAfter = bookLatestReleaseDate.minusMonths(1) + bookController.getBooks(principal, page = pageableBooksRecentlyReleased, search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.After(bookLatestReleaseDate.minusMonths(1))))) + } + + val pageableSeriesNew = Pageable.ofSize(DEFAULT_PAGE_SIZE) + + @Benchmark + fun getSeriesNew() { + seriesController.getSeriesNew(principal, page = pageableSeriesNew) + } + + val pageableSeriesUpdated = Pageable.ofSize(DEFAULT_PAGE_SIZE) + + @Benchmark + fun getSeriesUpdated() { + seriesController.getSeriesUpdated(principal, page = pageableSeriesUpdated) + } + + val pageableBooksToCheck = Pageable.ofSize(1) + + @Benchmark + fun getBooksToCheck() { + bookController.getBooks( + principal, + page = pageableBooksToCheck, + search = + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.ERROR)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.UNSUPPORTED)), + ), + ), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/UnsortedBenchmark.kt b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/UnsortedBenchmark.kt new file mode 100644 index 000000000..088a0ab26 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/kotlin/org/gotson/komga/benchmark/rest/UnsortedBenchmark.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.benchmark.rest + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.SeriesSearch +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Setup +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.util.concurrent.TimeUnit + +@OutputTimeUnit(TimeUnit.MILLISECONDS) +class UnsortedBenchmark : AbstractRestBenchmark() { + companion object { + private lateinit var biggestSeriesId: String + } + + private var pageSize: Int = 2000 + + @Setup(Level.Trial) + override fun prepareData() { + super.prepareData() + + // find series with most books + biggestSeriesId = + seriesController + .getSeries(principal, page = PageRequest.of(0, 1, Sort.by(Sort.Order.desc("booksCount"))), search = SeriesSearch()) + .content + .first() + .id + } + + @Benchmark + fun getAllSeries() { + seriesController.getSeries(principal, page = Pageable.ofSize(pageSize), search = SeriesSearch()) + } + + @Benchmark + fun getAllBooks() { + bookController.getBooks(principal, page = Pageable.ofSize(pageSize), search = BookSearch()) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/application-benchmark.yml b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/application-benchmark.yml new file mode 100644 index 000000000..4ae9ee875 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/application-benchmark.yml @@ -0,0 +1,27 @@ +application.version: BENCHMARK + +benchmark: + result-folder: ${project.reportsDir}/benchmark + measurement-iterations: 5 + warmup-iterations: 1 + mode: averagetime + # measurement-iterations: 1 + # warmup-iterations: 0 + # mode: singleshottime + result-format: text +komga: + config-dir: ${rootDir}/config-dir + workspace: diesel + database: + file: \${komga.config-dir}/\${komga.workspace}.sqlite + lucene: + data-directory: \${komga.config-dir}/lucene/\${komga.workspace} + tasks-db: + file: \${komga.config-dir}/\${komga.workspace}-tasks.sqlite +spring: + flyway: + enabled: true +#logging: +# level: +# root: ERROR +# org.jooq.tools.LoggerListener: DEBUG diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/junit-platform.properties b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/junit-platform.properties new file mode 100644 index 000000000..2af5bf864 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/benchmark/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default=per_class diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200810154730__thumbnails_part_2.kt b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200810154730__thumbnails_part_2.kt new file mode 100644 index 000000000..983a05970 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200810154730__thumbnails_part_2.kt @@ -0,0 +1,28 @@ +package db.migration.sqlite + +import com.github.f4b6a3.tsid.TsidCreator +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource + +// This migration will copy the existing thumbnails in MEDIA to the new table THUMBNAIL_BOOK, +// adding a generated TSID as the ID +class V20200810154730__thumbnails_part_2 : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val thumbnails = jdbcTemplate.queryForList("SELECT THUMBNAIL, BOOK_ID FROM MEDIA") + + if (thumbnails.isNotEmpty()) { + val parameters = thumbnails.map { + arrayOf(TsidCreator.getTsid256().toString(), it["THUMBNAIL"], it["BOOK_ID"]) + } + + jdbcTemplate.batchUpdate( + "INSERT INTO THUMBNAIL_BOOK(ID, THUMBNAIL, SELECTED, TYPE, BOOK_ID) values (?, ?, 1, 'GENERATED', ?)", + parameters, + ) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200820150923__metadata_fields_part_2.kt b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200820150923__metadata_fields_part_2.kt new file mode 100644 index 000000000..030d8a904 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20200820150923__metadata_fields_part_2.kt @@ -0,0 +1,48 @@ +package db.migration.sqlite + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource + +class V20200820150923__metadata_fields_part_2 : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val bookMetadata = jdbcTemplate.queryForList( + """select m.AGE_RATING, m.AGE_RATING_LOCK, m.PUBLISHER, m.PUBLISHER_LOCK, m.READING_DIRECTION, m.READING_DIRECTION_LOCK, b.SERIES_ID, m.NUMBER_SORT +from BOOK_METADATA m +left join BOOK B on B.ID = m.BOOK_ID""", + ) + + if (bookMetadata.isNotEmpty()) { + val parameters = bookMetadata + .groupBy { it["SERIES_ID"] } + .map { (seriesId, v) -> + val ageRating = v.mapNotNull { it["AGE_RATING"] as Int? }.maxOrNull() + val ageRatingLock = v.mapNotNull { it["AGE_RATING_LOCK"] as Int? }.maxOrNull() + + val publisher = + v.filter { (it["PUBLISHER"] as String).isNotEmpty() } + .sortedByDescending { it["NUMBER_SORT"] as Double? } + .map { it["PUBLISHER"] as String } + .firstOrNull() ?: "" + val publisherLock = v.mapNotNull { it["PUBLISHER_LOCK"] as Int? }.maxOrNull() + + val readingDir = + v.mapNotNull { it["READING_DIRECTION"] as String? } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value }?.key + val readingDirLock = v.mapNotNull { it["READING_DIRECTION_LOCK"] as Int? }.maxOrNull() + + arrayOf(ageRating, ageRatingLock, publisher, publisherLock, readingDir, readingDirLock, seriesId) + } + + jdbcTemplate.batchUpdate( + "UPDATE SERIES_METADATA SET AGE_RATING = ?, AGE_RATING_LOCK = ?, PUBLISHER = ?, PUBLISHER_LOCK = ?, READING_DIRECTION = ?, READING_DIRECTION_LOCK = ? WHERE SERIES_ID = ?", + parameters, + ) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20210624165023__missing_series_metadata.kt b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20210624165023__missing_series_metadata.kt new file mode 100644 index 000000000..492277839 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20210624165023__missing_series_metadata.kt @@ -0,0 +1,46 @@ +package db.migration.sqlite + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.lang3.StringUtils +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource + +private val logger = KotlinLogging.logger {} + +class V20210624165023__missing_series_metadata : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val seriesWithoutMetada = jdbcTemplate.queryForList( + """select s.ID, s.NAME from SERIES s where s.ID not in (select sm.SERIES_ID from SERIES_METADATA sm)""", + ) + + if (seriesWithoutMetada.isNotEmpty()) { + logger.info { "Found ${seriesWithoutMetada.size} series without metadata" } + + seriesWithoutMetada + .map { + // fields for SERIES_METADATA: SERIES_ID, STATUS=ONGOING, TITLE, TITLE_SORT, READING_DIRECTION=null, AGE_RATING=null + arrayOf(it["ID"], "ONGOING", it["NAME"], StringUtils.stripAccents(it["NAME"].toString()), null, null) + }.let { parameters -> + jdbcTemplate.batchUpdate( + "INSERT INTO SERIES_METADATA(SERIES_ID, STATUS, TITLE, TITLE_SORT, READING_DIRECTION, AGE_RATING) VALUES (?,?,?,?,?,?)", + parameters, + ) + } + + seriesWithoutMetada + .map { + // fields for BOOK_METADATA_AGGREGATION: SERIES_ID, RELEASE_DATE=null + arrayOf(it["ID"], null) + }.let { parameters -> + jdbcTemplate.batchUpdate( + "INSERT INTO BOOK_METADATA_AGGREGATION(SERIES_ID, RELEASE_DATE) VALUES (?,?)", + parameters, + ) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20230801104436__fix_incorrect_language_codes.kt b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20230801104436__fix_incorrect_language_codes.kt new file mode 100644 index 000000000..5cf593c86 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20230801104436__fix_incorrect_language_codes.kt @@ -0,0 +1,47 @@ +package db.migration.sqlite + +import com.ibm.icu.util.ULocale +import io.github.oshai.kotlinlogging.KotlinLogging +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource + +private val logger = KotlinLogging.logger {} + +class V20230801104436__fix_incorrect_language_codes : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val seriesLanguage = jdbcTemplate.queryForList( + """select m.SERIES_ID, m.LANGUAGE from SERIES_METADATA m where LANGUAGE <> '' and LANGUAGE <> 'en'""", + ) + + if (seriesLanguage.isNotEmpty()) { + seriesLanguage.mapNotNull { + val language = it["LANGUAGE"].toString() + if (language.isBlank()) null + else { + val languageNormalized = normalize(language) + if (language == languageNormalized) null + else arrayOf(languageNormalized, it["SERIES_ID"]) + } + }.let { params -> + logger.info { "Updating ${params.size} incorrect language codes for Series metadata" } + jdbcTemplate.batchUpdate( + """update SERIES_METADATA set LANGUAGE = ? where SERIES_ID = ?""", + params, + ) + } + } + } + + private fun normalize(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + ULocale.forLanguageTag(value).toLanguageTag() + } catch (e: Exception) { + "" + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20240422132621__fix_read_progress_locators.kt b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20240422132621__fix_read_progress_locators.kt new file mode 100644 index 000000000..0b0191653 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/kotlin/db/migration/sqlite/V20240422132621__fix_read_progress_locators.kt @@ -0,0 +1,56 @@ +package db.migration.sqlite + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode +import io.github.oshai.kotlinlogging.KotlinLogging +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +private val logger = KotlinLogging.logger {} + +class V20240422132621__fix_read_progress_locators : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val readProgressList = jdbcTemplate.queryForList( + """select r.BOOK_ID, r.USER_ID, r.locator from READ_PROGRESS r where locator is not null""", + ) + + if (readProgressList.isNotEmpty()) { + val mapper = ObjectMapper() + + readProgressList.mapNotNull { + try { + val locator = GZIPInputStream((it["LOCATOR"] as ByteArray).inputStream()).use { gz -> mapper.readTree(gz) } + val href = locator["href"]?.asText() + if (href == null) null + else { + val correctHref = href.replaceBefore("/resource/", "").removePrefix("/resource/") + (locator as ObjectNode).replace("href", TextNode(correctHref)) + val gzLocator = ByteArrayOutputStream().use { baos -> + GZIPOutputStream(baos).use { gz -> + mapper.writeValue(gz, locator) + baos.toByteArray() + } + } + arrayOf(gzLocator, it["BOOK_ID"], it["USER_ID"]) + } + } catch (e: Exception) { + null + } + }.let { params -> + logger.info { "Updating ${params.size} incorrect read progress locators" } + jdbcTemplate.batchUpdate( + """update READ_PROGRESS set locator = ? where BOOK_ID = ? and USER_ID = ?""", + params, + ) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200706141854__initial_migration.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200706141854__initial_migration.sql new file mode 100644 index 000000000..fc35ef97f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200706141854__initial_migration.sql @@ -0,0 +1,160 @@ +CREATE TABLE LIBRARY +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + NAME varchar NOT NULL, + ROOT varchar NOT NULL, + IMPORT_COMICINFO_BOOK boolean NOT NULL DEFAULT 1, + IMPORT_COMICINFO_SERIES boolean NOT NULL DEFAULT 1, + IMPORT_COMICINFO_COLLECTION boolean NOT NULL DEFAULT 1, + IMPORT_EPUB_BOOK boolean NOT NULL DEFAULT 1, + IMPORT_EPUB_SERIES boolean NOT NULL DEFAULT 1 +); +CREATE TABLE USER +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + EMAIL varchar NOT NULL UNIQUE, + PASSWORD varchar NOT NULL, + SHARED_ALL_LIBRARIES boolean NOT NULL DEFAULT 1, + ROLE_ADMIN boolean NOT NULL DEFAULT 0, + ROLE_FILE_DOWNLOAD boolean NOT NULL DEFAULT 1, + ROLE_PAGE_STREAMING boolean NOT NULL DEFAULT 1 +); +CREATE TABLE USER_LIBRARY_SHARING +( + USER_ID varchar NOT NULL, + LIBRARY_ID varchar NOT NULL, + PRIMARY KEY (USER_ID, LIBRARY_ID), + FOREIGN KEY (USER_ID) REFERENCES USER (ID), + FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY (ID) +); +CREATE TABLE SERIES +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FILE_LAST_MODIFIED datetime NOT NULL, + NAME varchar NOT NULL, + URL varchar NOT NULL, + LIBRARY_ID varchar NOT NULL, + FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY (ID) +); +CREATE TABLE SERIES_METADATA +( + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + STATUS varchar NOT NULL, + STATUS_LOCK boolean NOT NULL DEFAULT 0, + TITLE varchar NOT NULL, + TITLE_LOCK boolean NOT NULL DEFAULT 0, + TITLE_SORT varchar NOT NULL, + TITLE_SORT_LOCK boolean NOT NULL DEFAULT 0, + SERIES_ID varchar NOT NULL PRIMARY KEY, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); +CREATE TABLE BOOK +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FILE_LAST_MODIFIED datetime NOT NULL, + NAME varchar NOT NULL, + URL varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FILE_SIZE int8 NOT NULL DEFAULT 0, + NUMBER int NOT NULL DEFAULT 0, + LIBRARY_ID varchar NOT NULL, + FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY (ID), + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); +CREATE TABLE MEDIA +( + MEDIA_TYPE varchar NULL, + STATUS varchar NOT NULL, + THUMBNAIL blob NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + COMMENT varchar NULL, + BOOK_ID varchar NOT NULL PRIMARY KEY, + PAGE_COUNT int NOT NULL DEFAULT 0, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); +CREATE TABLE MEDIA_PAGE +( + FILE_NAME varchar NOT NULL, + MEDIA_TYPE varchar NOT NULL, + NUMBER int NOT NULL, + BOOK_ID varchar NOT NULL, + PRIMARY KEY (BOOK_ID, NUMBER), + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); +CREATE TABLE MEDIA_FILE +( + FILE_NAME varchar NOT NULL, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); +CREATE TABLE BOOK_METADATA +( + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + AGE_RATING int NULL, + AGE_RATING_LOCK boolean NOT NULL DEFAULT 0, + NUMBER varchar NOT NULL, + NUMBER_LOCK boolean NOT NULL DEFAULT 0, + NUMBER_SORT real NOT NULL, + NUMBER_SORT_LOCK boolean NOT NULL DEFAULT 0, + PUBLISHER varchar NOT NULL DEFAULT '', + PUBLISHER_LOCK boolean NOT NULL DEFAULT 0, + READING_DIRECTION varchar NULL, + READING_DIRECTION_LOCK boolean NOT NULL DEFAULT 0, + RELEASE_DATE date NULL, + RELEASE_DATE_LOCK boolean NOT NULL DEFAULT 0, + SUMMARY varchar NOT NULL DEFAULT '', + SUMMARY_LOCK boolean NOT NULL DEFAULT 0, + TITLE varchar NOT NULL, + TITLE_LOCK boolean NOT NULL DEFAULT 0, + AUTHORS_LOCK boolean NOT NULL DEFAULT 0, + BOOK_ID varchar NOT NULL PRIMARY KEY, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); +CREATE TABLE BOOK_METADATA_AUTHOR +( + NAME varchar NOT NULL, + ROLE varchar NOT NULL, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); +CREATE TABLE READ_PROGRESS +( + BOOK_ID varchar NOT NULL, + USER_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PAGE int NOT NULL, + COMPLETED boolean NOT NULL, + PRIMARY KEY (BOOK_ID, USER_ID), + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID), + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); +CREATE TABLE COLLECTION +( + ID varchar NOT NULL PRIMARY KEY, + NAME varchar NOT NULL, + ORDERED boolean NOT NULL DEFAULT 0, + SERIES_COUNT int NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE COLLECTION_SERIES +( + COLLECTION_ID varchar NOT NULL, + SERIES_ID varchar NOT NULL, + NUMBER int NOT NULL, + PRIMARY KEY (COLLECTION_ID, SERIES_ID), + FOREIGN KEY (COLLECTION_ID) REFERENCES COLLECTION (ID), + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200730135746__image_dimension.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200730135746__image_dimension.sql new file mode 100644 index 000000000..ac166e78a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200730135746__image_dimension.sql @@ -0,0 +1,4 @@ +alter table media_page + add column width int NULL; +alter table media_page + add column height int NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810154729__thumbnails_part_1.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810154729__thumbnails_part_1.sql new file mode 100644 index 000000000..048c4ed99 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810154729__thumbnails_part_1.sql @@ -0,0 +1,13 @@ +-- This is a multi-steps migration, mixing 2 SQL migrations and a Java migration in-between +CREATE TABLE THUMBNAIL_BOOK +( + ID varchar NOT NULL PRIMARY KEY, + THUMBNAIL blob NULL, + URL varchar NULL, + SELECTED boolean NOT NULL DEFAULT 0, + TYPE varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810165912__thumbnails_part_3.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810165912__thumbnails_part_3.sql new file mode 100644 index 000000000..497ad2b25 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200810165912__thumbnails_part_3.sql @@ -0,0 +1,28 @@ +-- This is a multi-steps migration, mixing 2 SQL migrations and a Java migration in-between +CREATE INDEX idx__thumbnail_book__book_id on THUMBNAIL_BOOK (BOOK_ID); + +-- Remove column THUMBNAIL from table MEDIA +PRAGMA foreign_keys= OFF; + +ALTER TABLE MEDIA + RENAME TO _MEDIA_OLD; + +CREATE TABLE MEDIA +( + MEDIA_TYPE varchar NULL, + STATUS varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + COMMENT varchar NULL, + BOOK_ID varchar NOT NULL PRIMARY KEY, + PAGE_COUNT int NOT NULL DEFAULT 0, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); + +INSERT INTO MEDIA (MEDIA_TYPE, STATUS, CREATED_DATE, LAST_MODIFIED_DATE, COMMENT, BOOK_ID, PAGE_COUNT) +SELECT MEDIA_TYPE, STATUS, CREATED_DATE, LAST_MODIFIED_DATE, COMMENT, BOOK_ID, PAGE_COUNT +FROM _MEDIA_OLD; + +DROP TABLE _MEDIA_OLD; + +PRAGMA foreign_keys= ON; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql new file mode 100644 index 000000000..da14b642f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql @@ -0,0 +1,10 @@ +CREATE TABLE THUMBNAIL_SERIES +( + ID varchar NOT NULL PRIMARY KEY, + URL varchar NOT NULL, + SELECTED boolean NOT NULL DEFAULT 0, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814142406__library_scan_options.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814142406__library_scan_options.sql new file mode 100644 index 000000000..1c527d350 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814142406__library_scan_options.sql @@ -0,0 +1,4 @@ +alter table library + add column SCAN_FORCE_MODIFIED_TIME boolean NOT NULL DEFAULT 0; +alter table library + add column SCAN_DEEP boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814145800__library_import_local_artwork.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814145800__library_import_local_artwork.sql new file mode 100644 index 000000000..143f0e71c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200814145800__library_import_local_artwork.sql @@ -0,0 +1,2 @@ +alter table library + add column IMPORT_LOCAL_ARTWORK boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql new file mode 100644 index 000000000..f49f5dc4c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200817115957__readlists.sql @@ -0,0 +1,20 @@ +CREATE TABLE READLIST +( + ID varchar NOT NULL PRIMARY KEY, + NAME varchar NOT NULL, + BOOK_COUNT int NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE READLIST_BOOK +( + READLIST_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + NUMBER int NOT NULL, + PRIMARY KEY (READLIST_ID, BOOK_ID), + FOREIGN KEY (READLIST_ID) REFERENCES READLIST (ID), + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); + +alter table library + add column IMPORT_COMICINFO_READLIST boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820141405__metadata_fields_part_1.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820141405__metadata_fields_part_1.sql new file mode 100644 index 000000000..dadeb6a1a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820141405__metadata_fields_part_1.sql @@ -0,0 +1,52 @@ +alter table series_metadata + add column PUBLISHER varchar NOT NULL DEFAULT ''; +alter table series_metadata + add column PUBLISHER_LOCK boolean NOT NULL DEFAULT 0; + +alter table series_metadata + add column READING_DIRECTION varchar NULL; +alter table series_metadata + add column READING_DIRECTION_LOCK boolean NOT NULL DEFAULT 0; + +alter table series_metadata + add column AGE_RATING int NULL; +alter table series_metadata + add column AGE_RATING_LOCK boolean NOT NULL DEFAULT 0; + +alter table SERIES_METADATA + add column SUMMARY varchar NOT NULL DEFAULT ''; +alter table SERIES_METADATA + add column SUMMARY_LOCK boolean NOT NULL DEFAULT 0; + +alter table SERIES_METADATA + add column LANGUAGE varchar NOT NULL DEFAULT ''; +alter table SERIES_METADATA + add column LANGUAGE_LOCK boolean NOT NULL DEFAULT 0; + +alter table SERIES_METADATA + add column GENRES_LOCK boolean NOT NULL DEFAULT 0; + +alter table SERIES_METADATA + add column TAGS_LOCK boolean NOT NULL DEFAULT 0; + + +CREATE TABLE SERIES_METADATA_GENRE +( + GENRE varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +CREATE TABLE SERIES_METADATA_TAG +( + TAG varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +CREATE TABLE BOOK_METADATA_TAG +( + TAG varchar NOT NULL, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820154318__metadata_fields_part_3.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820154318__metadata_fields_part_3.sql new file mode 100644 index 000000000..ba8facba1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200820154318__metadata_fields_part_3.sql @@ -0,0 +1,47 @@ +PRAGMA foreign_keys= OFF; + +ALTER TABLE BOOK_METADATA + RENAME TO _BOOK_METADATA_OLD; + +CREATE TABLE BOOK_METADATA +( + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + NUMBER varchar NOT NULL, + NUMBER_LOCK boolean NOT NULL DEFAULT 0, + NUMBER_SORT real NOT NULL, + NUMBER_SORT_LOCK boolean NOT NULL DEFAULT 0, + RELEASE_DATE date NULL, + RELEASE_DATE_LOCK boolean NOT NULL DEFAULT 0, + SUMMARY varchar NOT NULL DEFAULT '', + SUMMARY_LOCK boolean NOT NULL DEFAULT 0, + TITLE varchar NOT NULL, + TITLE_LOCK boolean NOT NULL DEFAULT 0, + AUTHORS_LOCK boolean NOT NULL DEFAULT 0, + TAGS_LOCK boolean NOT NULL DEFAULT 0, + BOOK_ID varchar NOT NULL PRIMARY KEY, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); + +INSERT INTO BOOK_METADATA (CREATED_DATE, LAST_MODIFIED_DATE, NUMBER, NUMBER_LOCK, NUMBER_SORT, NUMBER_SORT_LOCK, + RELEASE_DATE, RELEASE_DATE_LOCK, SUMMARY, SUMMARY_LOCK, TITLE, TITLE_LOCK, AUTHORS_LOCK, + BOOK_ID) +SELECT CREATED_DATE, + LAST_MODIFIED_DATE, + NUMBER, + NUMBER_LOCK, + NUMBER_SORT, + NUMBER_SORT_LOCK, + RELEASE_DATE, + RELEASE_DATE_LOCK, + SUMMARY, + SUMMARY_LOCK, + TITLE, + TITLE_LOCK, + AUTHORS_LOCK, + BOOK_ID +FROM _BOOK_METADATA_OLD; + +DROP TABLE _BOOK_METADATA_OLD; + +PRAGMA foreign_keys= ON; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200827103627__index.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200827103627__index.sql new file mode 100644 index 000000000..6b90d95fd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200827103627__index.sql @@ -0,0 +1,15 @@ +CREATE INDEX idx__book__series_id on BOOK (SERIES_ID); +CREATE INDEX idx__book__library_id on BOOK (LIBRARY_ID); + +CREATE INDEX idx__book_metadata_author__book_id on BOOK_METADATA_AUTHOR (BOOK_ID); +CREATE INDEX idx__book_metadata_tag__book_id on BOOK_METADATA_TAG (BOOK_ID); + +CREATE INDEX idx__media_file__book_id on MEDIA_FILE (BOOK_ID); + + +CREATE INDEX idx__series__library_id on SERIES (LIBRARY_ID); + +CREATE INDEX idx__series_metadata_genre__series_id on SERIES_METADATA_GENRE (SERIES_ID); +CREATE INDEX idx__series_metadata_tag__series_id on SERIES_METADATA_TAG (SERIES_ID); + +CREATE INDEX idx__thumbnail_series__series_id on THUMBNAIL_SERIES (SERIES_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200906115600__lower_trim_tags.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200906115600__lower_trim_tags.sql new file mode 100644 index 000000000..a49a7b0b9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20200906115600__lower_trim_tags.sql @@ -0,0 +1,37 @@ +UPDATE BOOK_METADATA_TAG +SET tag = lower(trim(tag)); + +delete +from BOOK_METADATA_TAG +where ROWID not in + ( + select min(rowid) + from BOOK_METADATA_TAG + group by tag, BOOK_ID + ); + + +UPDATE SERIES_METADATA_TAG +SET tag = lower(trim(tag)); + +delete +from SERIES_METADATA_TAG +where ROWID not in + ( + select min(rowid) + from SERIES_METADATA_TAG + group by tag, SERIES_ID + ); + + +UPDATE SERIES_METADATA_GENRE +SET GENRE = lower(trim(GENRE)); + +delete +from SERIES_METADATA_GENRE +where ROWID not in + ( + select min(rowid) + from SERIES_METADATA_GENRE + group by GENRE, SERIES_ID + ); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210111113543__book_metadata_aggregation.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210111113543__book_metadata_aggregation.sql new file mode 100644 index 000000000..b8f1d0262 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210111113543__book_metadata_aggregation.sql @@ -0,0 +1,20 @@ +CREATE TABLE BOOK_METADATA_AGGREGATION +( + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + RELEASE_DATE date NULL, + SUMMARY varchar NOT NULL DEFAULT '', + SUMMARY_NUMBER varchar NOT NULL DEFAULT '', + SERIES_ID varchar NOT NULL PRIMARY KEY, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); +CREATE TABLE BOOK_METADATA_AGGREGATION_AUTHOR +( + NAME varchar NOT NULL, + ROLE varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); +INSERT INTO BOOK_METADATA_AGGREGATION(SERIES_ID) +SELECT ID +from SERIES; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql new file mode 100644 index 000000000..55d1774ec --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql @@ -0,0 +1,7 @@ +alter table library + add column IMPORT_BARCODE_ISBN boolean NOT NULL DEFAULT 1; + +alter table book_metadata + add column ISBN varchar NOT NULL DEFAULT ''; +alter table book_metadata + add column ISBN_LOCK boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210504171645__library_convert_to_cbz.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210504171645__library_convert_to_cbz.sql new file mode 100644 index 000000000..1b6e3c477 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210504171645__library_convert_to_cbz.sql @@ -0,0 +1,2 @@ +alter table library + add column CONVERT_TO_CBZ boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210505150806__library_repair_extensions.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210505150806__library_repair_extensions.sql new file mode 100644 index 000000000..0e5663dcd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210505150806__library_repair_extensions.sql @@ -0,0 +1,2 @@ +alter table library + add column REPAIR_EXTENSIONS boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210517145427__fix_rar.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210517145427__fix_rar.sql new file mode 100644 index 000000000..96e034e8e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210517145427__fix_rar.sql @@ -0,0 +1,18 @@ +-- fix media type for files analyzed with tika 1.23 or before which didn't get the rar version +update MEDIA +set MEDIA_TYPE = 'application/x-rar-compressed; version=4' +where MEDIA_TYPE = 'application/x-rar-compressed' + and STATUS = 'READY'; + +-- rar files that could have had an incorrect analysis are marked at OUTDATED to be ra-analyzed +update MEDIA +set STATUS = 'OUTDATED' +where BOOK_ID in ( + select F.BOOK_ID + from MEDIA_FILE F + left join MEDIA M on F.BOOK_ID = M.BOOK_ID + where F.FILE_NAME like '%.%' + and M.MEDIA_TYPE like 'application/x-rar-compressed%' + and lower(replace(F.FILE_NAME, rtrim(F.FILE_NAME, replace(F.FILE_NAME, '.', '')), '')) + in ('jpg', 'jpeg', 'webp', 'tiff', 'tif', 'gif', 'png', 'bmp') + ); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210525172240__index.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210525172240__index.sql new file mode 100644 index 000000000..2ba70dfc4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210525172240__index.sql @@ -0,0 +1 @@ +CREATE INDEX idx__book_metadata_aggregation_author__series_id on BOOK_METADATA_AGGREGATION_AUTHOR (SERIES_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210526113555__series_book_count.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210526113555__series_book_count.sql new file mode 100644 index 000000000..52be268e1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210526113555__series_book_count.sql @@ -0,0 +1,29 @@ +alter table SERIES + add column BOOK_COUNT int NOT NULL DEFAULT 0; + +update SERIES +set BOOK_COUNT = ( + SELECT COUNT(b.ID) + FROM BOOK b + WHERE b.SERIES_ID = SERIES.ID + ); + +CREATE TABLE READ_PROGRESS_SERIES +( + SERIES_ID varchar NOT NULL, + USER_ID varchar NOT NULL, + READ_COUNT int NOT NULL, + IN_PROGRESS_COUNT int NOT NULL, + PRIMARY KEY (SERIES_ID, USER_ID), + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID), + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +insert into READ_PROGRESS_SERIES +select BOOK.SERIES_ID, + READ_PROGRESS.USER_ID, + sum(case when READ_PROGRESS.COMPLETED = 1 then 1 else 0 end) as READ_COUNT, + sum(case when READ_PROGRESS.COMPLETED = 0 then 1 else 0 end) as IN_PROGRESS_COUNT +from BOOK + inner join READ_PROGRESS on (BOOK.ID = READ_PROGRESS.BOOK_ID) +group by BOOK.SERIES_ID, READ_PROGRESS.USER_ID; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210527100741__index_book_created.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210527100741__index_book_created.sql new file mode 100644 index 000000000..b7c0682e2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210527100741__index_book_created.sql @@ -0,0 +1,2 @@ +create index idx__book__created_date on BOOK (CREATED_DATE); + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql new file mode 100644 index 000000000..dbbe7002e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql @@ -0,0 +1,7 @@ +CREATE TABLE SIDECAR +( + URL varchar NOT NULL PRIMARY KEY, + PARENT_URL varchar NOT NULL, + LAST_MODIFIED_TIME datetime NOT NULL, + LIBRARY_ID varchar NOT NULL +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210617114814__sidecar_cleanup.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210617114814__sidecar_cleanup.sql new file mode 100644 index 000000000..254e7418a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210617114814__sidecar_cleanup.sql @@ -0,0 +1,6 @@ +DELETE +FROM SIDECAR +WHERE LIBRARY_ID NOT IN ( + SELECT ID + FROM LIBRARY + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210625155626__authentication_activity.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210625155626__authentication_activity.sql new file mode 100644 index 000000000..3428555cf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210625155626__authentication_activity.sql @@ -0,0 +1,11 @@ +create table AUTHENTICATION_ACTIVITY +( + USER_ID varchar NULL DEFAULT NULL, + EMAIL varchar NULL DEFAULT NULL, + IP varchar NULL DEFAULT NULL, + USER_AGENT varchar NULL DEFAULT NULL, + SUCCESS boolean NOT NULL, + ERROR varchar NULL DEFAULT NULL, + DATE_TIME datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (USER_ID) references USER (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql new file mode 100644 index 000000000..fa4c31b96 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql @@ -0,0 +1,11 @@ +ALTER TABLE BOOK + ADD COLUMN FILE_HASH varchar NOT NULL DEFAULT ''; + +ALTER TABLE BOOK + ADD COLUMN DELETED_DATE datetime NULL DEFAULT NULL; + +ALTER TABLE SERIES + ADD COLUMN DELETED_DATE datetime NULL DEFAULT NULL; + +alter table library + add column EMPTY_TRASH_AFTER_SCAN boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210716153910__library_import_mylar_metadata.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210716153910__library_import_mylar_metadata.sql new file mode 100644 index 000000000..6213d3d4f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210716153910__library_import_mylar_metadata.sql @@ -0,0 +1,2 @@ +alter table library + add column IMPORT_MYLAR_SERIES boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210726140759__cover_preference.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210726140759__cover_preference.sql new file mode 100644 index 000000000..101e523c4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210726140759__cover_preference.sql @@ -0,0 +1,2 @@ +alter table library + add column SERIES_COVER varchar NOT NULL DEFAULT 'FIRST'; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210727102041__full_text_search.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210727102041__full_text_search.sql new file mode 100644 index 000000000..3bbf602e8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210727102041__full_text_search.sql @@ -0,0 +1,83 @@ +-- FTS for BOOK_METADATA +create virtual table fts_book_metadata using fts5(title, isbn, book_id UNINDEXED, content=book_metadata, tokenize = 'porter unicode61 remove_diacritics 2'); +INSERT INTO fts_book_metadata(fts_book_metadata) VALUES('rebuild'); + +-- Triggers to keep the FTS index up to date +CREATE TRIGGER book_metadata__after_insert AFTER INSERT ON book_metadata BEGIN + INSERT INTO fts_book_metadata(rowid, title, isbn, book_id) VALUES (new.rowid, new.title, new.isbn, new.book_id); +END; +CREATE TRIGGER book_metadata__after_delete AFTER DELETE ON book_metadata BEGIN + INSERT INTO fts_book_metadata(fts_book_metadata, rowid, title, isbn, book_id) VALUES('delete', old.rowid, old.title, old.isbn, old.book_id); +END; +CREATE TRIGGER book_metadata__after_update AFTER UPDATE ON book_metadata BEGIN + INSERT INTO fts_book_metadata(fts_book_metadata, rowid, title, isbn, book_id) VALUES('delete', old.rowid, old.title, old.isbn, old.book_id); + INSERT INTO fts_book_metadata(rowid, title, isbn, book_id) VALUES (new.rowid, new.title, new.isbn, new.book_id); +END; + + +-- FTS for SERIES_METADATA +create virtual table fts_series_metadata using fts5(title, publisher, series_id UNINDEXED, content=series_metadata, tokenize = 'porter unicode61 remove_diacritics 2'); +INSERT INTO fts_series_metadata(fts_series_metadata) VALUES('rebuild'); + +-- Triggers to keep the FTS index up to date +CREATE TRIGGER series_metadata__after_insert AFTER INSERT ON series_metadata BEGIN + INSERT INTO fts_series_metadata(rowid, title, publisher, series_id) VALUES (new.rowid, new.title, new.publisher, new.series_id); +END; +CREATE TRIGGER series_metadata__after_delete AFTER DELETE ON series_metadata BEGIN + INSERT INTO fts_series_metadata(fts_series_metadata, rowid, title, publisher, series_id) VALUES('delete', old.rowid, old.title, old.publisher, old.series_id); +END; +CREATE TRIGGER series_metadata__after_update AFTER UPDATE ON series_metadata BEGIN + INSERT INTO fts_series_metadata(fts_series_metadata, rowid, title, publisher, series_id) VALUES('delete', old.rowid, old.title, old.publisher, old.series_id); + INSERT INTO fts_series_metadata(rowid, title, publisher, series_id) VALUES (new.rowid, new.title, new.publisher, new.series_id); +END; + + +-- FTS for COLLECTION +create virtual table fts_collection using fts5(name, id UNINDEXED, content=collection, tokenize = 'porter unicode61 remove_diacritics 2'); +INSERT INTO fts_collection(fts_collection) VALUES('rebuild'); + +-- Triggers to keep the FTS index up to date +CREATE TRIGGER collection__after_insert AFTER INSERT ON collection BEGIN + INSERT INTO fts_collection(rowid, name, id) VALUES (new.rowid, new.name, new.id); +END; +CREATE TRIGGER collection__after_delete AFTER DELETE ON collection BEGIN + INSERT INTO fts_collection(fts_collection, rowid, name, id) VALUES('delete', old.rowid, old.name, old.id); +END; +CREATE TRIGGER collection__after_update AFTER UPDATE ON collection BEGIN + INSERT INTO fts_collection(fts_collection, rowid, name, id) VALUES('delete', old.rowid, old.name, old.id); + INSERT INTO fts_collection(rowid, name, id) VALUES (new.rowid, new.name, new.id); +END; + + +-- FTS for READLIST +create virtual table fts_readlist using fts5(name, id UNINDEXED, content=readlist, tokenize = 'porter unicode61 remove_diacritics 2'); +INSERT INTO fts_readlist(fts_readlist) VALUES('rebuild'); + +-- Triggers to keep the FTS index up to date +CREATE TRIGGER readlist__after_insert AFTER INSERT ON readlist BEGIN + INSERT INTO fts_readlist(rowid, name, id) VALUES (new.rowid, new.name, new.id); +END; +CREATE TRIGGER readlist__after_delete AFTER DELETE ON readlist BEGIN + INSERT INTO fts_readlist(fts_readlist, rowid, name, id) VALUES('delete', old.rowid, old.name, old.id); +END; +CREATE TRIGGER readlist__after_update AFTER UPDATE ON readlist BEGIN + INSERT INTO fts_readlist(fts_readlist, rowid, name, id) VALUES('delete', old.rowid, old.name, old.id); + INSERT INTO fts_readlist(rowid, name, id) VALUES (new.rowid, new.name, new.id); +END; + + +-- FTS for BOOK_METADATA_AGGREGATION_AUTHORS +create virtual table fts_book_metadata_aggregation_author using fts5(name, series_id UNINDEXED, content=book_metadata_aggregation_author, tokenize = 'porter unicode61 remove_diacritics 2'); +INSERT INTO fts_book_metadata_aggregation_author(fts_book_metadata_aggregation_author) VALUES('rebuild'); + +-- Triggers to keep the FTS index up to date +CREATE TRIGGER book_metadata_aggregation_author__after_insert AFTER INSERT ON book_metadata_aggregation_author BEGIN + INSERT INTO fts_book_metadata_aggregation_author(rowid, name, series_id) VALUES (new.rowid, new.name, new.series_id); +END; +CREATE TRIGGER book_metadata_aggregation_author__after_delete AFTER DELETE ON book_metadata_aggregation_author BEGIN + INSERT INTO fts_book_metadata_aggregation_author(fts_book_metadata_aggregation_author, rowid, name, series_id) VALUES('delete', old.rowid, old.name, old.series_id); +END; +CREATE TRIGGER book_metadata_aggregation_author__after_update AFTER UPDATE ON book_metadata_aggregation_author BEGIN + INSERT INTO fts_book_metadata_aggregation_author(fts_book_metadata_aggregation_author, rowid, name, series_id) VALUES('delete', old.rowid, old.name, old.series_id); + INSERT INTO fts_book_metadata_aggregation_author(rowid, name, series_id) VALUES (new.rowid, new.name, new.series_id); +END; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210729173532__series_metadata_total_book_count.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210729173532__series_metadata_total_book_count.sql new file mode 100644 index 000000000..237c2175f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210729173532__series_metadata_total_book_count.sql @@ -0,0 +1,4 @@ +alter table series_metadata + add column TOTAL_BOOK_COUNT int NULL; +alter table series_metadata + add column TOTAL_BOOK_COUNT_LOCK boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730111835__book_metadata_aggregation_tag.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730111835__book_metadata_aggregation_tag.sql new file mode 100644 index 000000000..3aa42d3fe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730111835__book_metadata_aggregation_tag.sql @@ -0,0 +1,12 @@ +CREATE TABLE BOOK_METADATA_AGGREGATION_TAG +( + TAG varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +-- aggregate existing data +insert into BOOK_METADATA_AGGREGATION_TAG +select distinct bt.TAG, b.SERIES_ID +from BOOK_METADATA_TAG bt + left join BOOK B on B.ID = bt.BOOK_ID; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730151147__readlist_summary.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730151147__readlist_summary.sql new file mode 100644 index 000000000..e68907cf7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210730151147__readlist_summary.sql @@ -0,0 +1,2 @@ +alter table readlist + add column SUMMARY varchar not NULL default ''; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210805174355__remove_full_text_search.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210805174355__remove_full_text_search.sql new file mode 100644 index 000000000..b0f4a6f5b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210805174355__remove_full_text_search.sql @@ -0,0 +1,24 @@ +drop table fts_book_metadata; +DROP TRIGGER book_metadata__after_insert; +DROP TRIGGER book_metadata__after_delete; +DROP TRIGGER book_metadata__after_update; + +drop table fts_series_metadata; +DROP TRIGGER series_metadata__after_insert; +DROP TRIGGER series_metadata__after_delete; +DROP TRIGGER series_metadata__after_update; + +drop table fts_collection; +DROP TRIGGER collection__after_insert; +DROP TRIGGER collection__after_delete; +DROP TRIGGER collection__after_update; + +drop table fts_readlist; +DROP TRIGGER readlist__after_insert; +DROP TRIGGER readlist__after_delete; +DROP TRIGGER readlist__after_update; + +drop table fts_book_metadata_aggregation_author; +DROP TRIGGER book_metadata_aggregation_author__after_insert; +DROP TRIGGER book_metadata_aggregation_author__after_delete; +DROP TRIGGER book_metadata_aggregation_author__after_update; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210816113108__library_unavailable.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210816113108__library_unavailable.sql new file mode 100644 index 000000000..836017645 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210816113108__library_unavailable.sql @@ -0,0 +1,2 @@ +ALTER TABLE LIBRARY + ADD COLUMN UNAVAILABLE_DATE datetime NULL DEFAULT NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210831113524__temp_table_urls.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210831113524__temp_table_urls.sql new file mode 100644 index 000000000..1f32483f4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210831113524__temp_table_urls.sql @@ -0,0 +1,4 @@ +CREATE TABLE TEMP_URL_LIST +( + URL varchar NOT NULL +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210902100930__series_thumbnail_blob.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210902100930__series_thumbnail_blob.sql new file mode 100644 index 000000000..e3a69b810 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210902100930__series_thumbnail_blob.sql @@ -0,0 +1,20 @@ +ALTER TABLE THUMBNAIL_SERIES RENAME TO TMP_THUMBNAIL_SERIES; + +CREATE TABLE THUMBNAIL_SERIES +( + ID varchar NOT NULL PRIMARY KEY, + URL varchar NULL DEFAULT NULL, + SELECTED boolean NOT NULL DEFAULT 0, + THUMBNAIL blob NULL DEFAULT NULL, + TYPE varchar not null, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +INSERT INTO THUMBNAIL_SERIES(ID, URL, SELECTED, CREATED_DATE, LAST_MODIFIED_DATE, SERIES_ID, TYPE) +SELECT ID, URL, SELECTED, CREATED_DATE, LAST_MODIFIED_DATE, SERIES_ID, "SIDECAR" AS TYPE +FROM TMP_THUMBNAIL_SERIES; + +DROP TABLE TMP_THUMBNAIL_SERIES; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210906163333__series_metadata_lowercase_language.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210906163333__series_metadata_lowercase_language.sql new file mode 100644 index 000000000..8af8f66fb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210906163333__series_metadata_lowercase_language.sql @@ -0,0 +1,2 @@ +update SERIES_METADATA +set language = lower(language); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210907115532__spring_session.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210907115532__spring_session.sql new file mode 100644 index 000000000..15a83f772 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210907115532__spring_session.sql @@ -0,0 +1,24 @@ +CREATE TABLE SPRING_SESSION +( + PRIMARY_ID CHARACTER(36) NOT NULL, + SESSION_ID CHARACTER(36) NOT NULL, + CREATION_TIME INTEGER NOT NULL, + LAST_ACCESS_TIME INTEGER NOT NULL, + MAX_INACTIVE_INTERVAL INTEGER NOT NULL, + EXPIRY_TIME INTEGER NOT NULL, + PRINCIPAL_NAME VARCHAR(100), + CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID) +); + +CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID); +CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME); +CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME); + +CREATE TABLE SPRING_SESSION_ATTRIBUTES +( + SESSION_PRIMARY_ID CHAR(36) NOT NULL, + ATTRIBUTE_NAME VARCHAR(200) NOT NULL, + ATTRIBUTE_BYTES BLOB NOT NULL, + CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME), + CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION (PRIMARY_ID) ON DELETE CASCADE +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210914111439__read_progress_read_date.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210914111439__read_progress_read_date.sql new file mode 100644 index 000000000..133b642cf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210914111439__read_progress_read_date.sql @@ -0,0 +1,21 @@ +ALTER TABLE READ_PROGRESS RENAME TO TMP_READ_PROGRESS; + +CREATE TABLE READ_PROGRESS +( + BOOK_ID varchar NOT NULL, + USER_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PAGE int NOT NULL, + COMPLETED boolean NOT NULL, + READ_DATE datetime NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (BOOK_ID, USER_ID), + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID), + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +INSERT INTO READ_PROGRESS(BOOK_ID, USER_ID, CREATED_DATE, LAST_MODIFIED_DATE, PAGE, COMPLETED, READ_DATE) +SELECT BOOK_ID, USER_ID, CREATED_DATE, LAST_MODIFIED_DATE, PAGE, COMPLETED, LAST_MODIFIED_DATE +FROM TMP_READ_PROGRESS; + +DROP TABLE TMP_READ_PROGRESS; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql new file mode 100644 index 000000000..23d92a655 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20210926222612__authentication_activity_source.sql @@ -0,0 +1,2 @@ +ALTER TABLE AUTHENTICATION_ACTIVITY + ADD COLUMN SOURCE varchar NULL DEFAULT NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211006141333__remove_spring_session.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211006141333__remove_spring_session.sql new file mode 100644 index 000000000..c376f04a2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211006141333__remove_spring_session.sql @@ -0,0 +1,2 @@ +drop table if exists SPRING_SESSION_ATTRIBUTES; +drop table if exists SPRING_SESSION; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206100610__temp_table_ids.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206100610__temp_table_ids.sql new file mode 100644 index 000000000..7f6d7b099 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206100610__temp_table_ids.sql @@ -0,0 +1,6 @@ +CREATE TABLE TEMP_STRING_LIST +( + STRING varchar NOT NULL +); + +DROP TABLE TEMP_URL_LIST; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql new file mode 100644 index 000000000..eeea1446b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql @@ -0,0 +1,23 @@ +CREATE TABLE THUMBNAIL_COLLECTION +( + ID varchar NOT NULL PRIMARY KEY, + SELECTED boolean NOT NULL DEFAULT 0, + THUMBNAIL blob NOT NULL, + TYPE varchar NOT NULL, + COLLECTION_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (COLLECTION_ID) REFERENCES COLLECTION (ID) +); + +CREATE TABLE THUMBNAIL_READLIST +( + ID varchar NOT NULL PRIMARY KEY, + SELECTED boolean NOT NULL DEFAULT 0, + THUMBNAIL blob NOT NULL, + TYPE varchar NOT NULL, + READLIST_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (READLIST_ID) REFERENCES READLIST (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211228152113__book_metadata_link.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211228152113__book_metadata_link.sql new file mode 100644 index 000000000..104b34677 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20211228152113__book_metadata_link.sql @@ -0,0 +1,10 @@ +CREATE TABLE BOOK_METADATA_LINK +( + LABEL varchar NOT NULL, + URL varchar NOT NULL, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); + +alter table book_metadata + add column LINKS_LOCK boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220101120232__page_hash.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220101120232__page_hash.sql new file mode 100644 index 000000000..9de5e6851 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220101120232__page_hash.sql @@ -0,0 +1,2 @@ +ALTER TABLE MEDIA_PAGE + ADD COLUMN FILE_HASH varchar NOT NULL DEFAULT ''; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220105143154__library_hashing_options.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220105143154__library_hashing_options.sql new file mode 100644 index 000000000..14bb55a4d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220105143154__library_hashing_options.sql @@ -0,0 +1,6 @@ +alter table library + add column HASH_FILES boolean NOT NULL DEFAULT ${library-file-hashing}; +alter table library + add column HASH_PAGES boolean NOT NULL DEFAULT 0; +alter table library + add column ANALYZE_DIMENSIONS boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220106143755__page_file_size.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220106143755__page_file_size.sql new file mode 100644 index 000000000..20024299e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220106143755__page_file_size.sql @@ -0,0 +1,2 @@ +alter table media_page + add column FILE_SIZE int8 NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220128152310__page_hash.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220128152310__page_hash.sql new file mode 100644 index 000000000..efaa0d1d0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220128152310__page_hash.sql @@ -0,0 +1,30 @@ +CREATE TABLE PAGE_HASH +( + HASH varchar NOT NULL, + MEDIA_TYPE varchar NOT NULL, + SIZE int8 NULL, + ACTION varchar NOT NULL, + DELETE_COUNT int NOT NULL default 0, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (HASH, MEDIA_TYPE, SIZE) +); + +CREATE TABLE PAGE_HASH_THUMBNAIL +( + HASH varchar NOT NULL, + MEDIA_TYPE varchar NOT NULL, + SIZE int8 NULL, + THUMBNAIL blob NOT NULL, + PRIMARY KEY (HASH, MEDIA_TYPE, SIZE) +); + +UPDATE MEDIA_PAGE +SET FILE_HASH = '' +WHERE BOOK_ID IN ( + SELECT DISTINCT m.BOOK_ID + FROM MEDIA m + LEFT JOIN MEDIA_PAGE MP on m.BOOK_ID = MP.BOOK_ID + WHERE mp.FILE_HASH <> '' + AND m.MEDIA_TYPE <> 'application/zip' +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220208193203__fix_books_with_missing_pages.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220208193203__fix_books_with_missing_pages.sql new file mode 100644 index 000000000..5bbb95db6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220208193203__fix_books_with_missing_pages.sql @@ -0,0 +1,8 @@ +UPDATE MEDIA +SET STATUS = 'OUTDATED' +WHERE BOOK_ID IN ( + SELECT M.BOOK_ID + FROM MEDIA M + LEFT JOIN MEDIA_PAGE MP on M.BOOK_ID = MP.BOOK_ID + GROUP BY M.BOOK_ID, M.PAGE_COUNT + HAVING M.PAGE_COUNT <> count(MP.BOOK_ID)); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql new file mode 100644 index 000000000..e1a98453d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220218111455__historical_events.sql @@ -0,0 +1,17 @@ +CREATE TABLE HISTORICAL_EVENT +( + ID varchar PRIMARY KEY, + TYPE varchar NOT NULL, + BOOK_ID varchar NULL, + SERIES_ID varchar NULL, + TIMESTAMP datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE HISTORICAL_EVENT_PROPERTIES +( + ID varchar NOT NULL, + KEY varchar NOT NULL, + VALUE varchar NOT NULL, + PRIMARY KEY (ID, KEY), + FOREIGN KEY (ID) REFERENCES HISTORICAL_EVENT (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220224112015__series_sharing_labels.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220224112015__series_sharing_labels.sql new file mode 100644 index 000000000..f02aa5e65 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220224112015__series_sharing_labels.sql @@ -0,0 +1,9 @@ +CREATE TABLE SERIES_METADATA_SHARING +( + LABEL varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +alter table SERIES_METADATA + add column SHARING_LABELS_LOCK boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220225164240__user_restrictions.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220225164240__user_restrictions.sql new file mode 100644 index 000000000..28219035d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220225164240__user_restrictions.sql @@ -0,0 +1,13 @@ +CREATE TABLE USER_SHARING +( + LABEL varchar NOT NULL, + ALLOW boolean NOT NULL, + USER_ID varchar NOT NULL, + PRIMARY KEY (LABEL, ALLOW, USER_ID), + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +ALTER TABLE USER + add column AGE_RESTRICTION integer NULL; +ALTER TABLE USER + add column AGE_RESTRICTION_ALLOW_ONLY boolean NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220715213721__perf_indices.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220715213721__perf_indices.sql new file mode 100644 index 000000000..c07827c78 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20220715213721__perf_indices.sql @@ -0,0 +1,31 @@ +-- missing foreign key indices +create index if not exists idx__book_metadata_link__book_id + on BOOK_METADATA_LINK (BOOK_ID); +create index if not exists idx__series_metadata_sharing__series_id + on SERIES_METADATA_SHARING (SERIES_ID); +create index if not exists idx__book_metadata_aggregation_tag__series_id + on BOOK_METADATA_AGGREGATION_TAG (SERIES_ID); +create index if not exists idx__thumbnail_collection__collection_id + on THUMBNAIL_COLLECTION (COLLECTION_ID); +create index if not exists idx__thumbnail_readlist__readlist_id + on THUMBNAIL_READLIST (READLIST_ID); +create index if not exists idx__thumbnail_series__series_id + on THUMBNAIL_SERIES (SERIES_ID); +create index if not exists idx__authentication_activity__user_id + on AUTHENTICATION_ACTIVITY (USER_ID); + +-- if you sort by it, index it +create index if not exists idx__book_metadata__number_sort + on BOOK_METADATA (NUMBER_SORT); +create index if not exists idx__series__last_modified_date + on SERIES (LAST_MODIFIED_DATE); +create index if not exists idx__series__created_date + on SERIES (CREATED_DATE); +create index if not exists idx__book_metadata__release_date + on BOOK_METADATA (RELEASE_DATE); +create index if not exists idx__book__created_date + on BOOK (CREATED_DATE); +create index if not exists idx__read_progress__last_modified_date + on READ_PROGRESS (LAST_MODIFIED_DATE); +create index if not exists idx__media__status + on MEDIA (STATUS); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230112151202__library_comicinfo_append_volume.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230112151202__library_comicinfo_append_volume.sql new file mode 100644 index 000000000..57853490e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230112151202__library_comicinfo_append_volume.sql @@ -0,0 +1,2 @@ +ALTER TABLE LIBRARY + add column IMPORT_COMICINFO_SERIES_APPEND_VOLUME boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230113140806__series_metadata_link.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230113140806__series_metadata_link.sql new file mode 100644 index 000000000..1c8d1268f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230113140806__series_metadata_link.sql @@ -0,0 +1,13 @@ +CREATE TABLE SERIES_METADATA_LINK +( + LABEL varchar NOT NULL, + URL varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +alter table series_metadata + add column LINKS_LOCK boolean NOT NULL DEFAULT 0; + +create index idx__series_metadata_link__series_id + on SERIES_METADATA_LINK (SERIES_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230116112647__series_alternate_title.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230116112647__series_alternate_title.sql new file mode 100644 index 000000000..996a2b4e8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230116112647__series_alternate_title.sql @@ -0,0 +1,13 @@ +CREATE TABLE SERIES_METADATA_ALTERNATE_TITLE +( + LABEL varchar NOT NULL, + TITLE varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +alter table series_metadata + add column ALTERNATE_TITLES_LOCK boolean NOT NULL DEFAULT 0; + +create index idx__series_metadata_alternate_title__series_id + on SERIES_METADATA_ALTERNATE_TITLE (SERIES_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230221170726__readlist_ordered.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230221170726__readlist_ordered.sql new file mode 100644 index 000000000..1922ac3e4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230221170726__readlist_ordered.sql @@ -0,0 +1,2 @@ +alter table READLIST + add column ORDERED boolean NOT NULL DEFAULT 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230310121946__series_metadata_index.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230310121946__series_metadata_index.sql new file mode 100644 index 000000000..4e869ce5d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230310121946__series_metadata_index.sql @@ -0,0 +1,2 @@ +create index if not exists idx__series_metadata__title + on SERIES_METADATA (TITLE); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626150454__xxhash128.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626150454__xxhash128.sql new file mode 100644 index 000000000..6c6c76fde --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626150454__xxhash128.sql @@ -0,0 +1,4 @@ +delete from PAGE_HASH; +delete from PAGE_HASH_THUMBNAIL; +update BOOK set FILE_HASH = ''; +update MEDIA_PAGE set FILE_HASH = ''; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626151717__missing_page_size.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626151717__missing_page_size.sql new file mode 100644 index 000000000..c28dceea6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626151717__missing_page_size.sql @@ -0,0 +1,8 @@ +update media +set STATUS = 'OUTDATED' +WHERE BOOK_ID in + (select distinct M.BOOK_ID + from MEDIA_PAGE P + inner join MEDIA M on P.BOOK_ID = M.BOOK_ID + where M.MEDIA_TYPE in ('application/zip', 'application/x-rar-compressed; version=4') + and P.FILE_SIZE is null); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626170856__page_hash_remove_columns.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626170856__page_hash_remove_columns.sql new file mode 100644 index 000000000..3c1e1f1f5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230626170856__page_hash_remove_columns.sql @@ -0,0 +1,33 @@ +ALTER TABLE PAGE_HASH + RENAME TO _PAGE_HASH_OLD; + +CREATE TABLE PAGE_HASH +( + HASH varchar NOT NULL PRIMARY KEY, + SIZE int8 NULL, + ACTION varchar NOT NULL, + DELETE_COUNT int NOT NULL default 0, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO PAGE_HASH(HASH, ACTION, SIZE, DELETE_COUNT, CREATED_DATE, LAST_MODIFIED_DATE) +SELECT HASH, ACTION, SIZE, DELETE_COUNT, CREATED_DATE, LAST_MODIFIED_DATE +FROM _PAGE_HASH_OLD; + +DROP TABLE _PAGE_HASH_OLD; + +ALTER TABLE PAGE_HASH_THUMBNAIL + RENAME TO _PAGE_HASH_THUMBNAIL_OLD; + +CREATE TABLE PAGE_HASH_THUMBNAIL +( + HASH varchar NOT NULL PRIMARY KEY, + THUMBNAIL blob NOT NULL +); + +INSERT INTO PAGE_HASH_THUMBNAIL(HASH, THUMBNAIL) +SELECT HASH, THUMBNAIL +FROM _PAGE_HASH_THUMBNAIL_OLD; + +DROP TABLE _PAGE_HASH_THUMBNAIL_OLD; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230629114356__remove_library_scandeep_column.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230629114356__remove_library_scandeep_column.sql new file mode 100644 index 000000000..fa4477ed6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230629114356__remove_library_scandeep_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE LIBRARY + RENAME COLUMN SCAN_DEEP to _UNUSED; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql new file mode 100644 index 000000000..2adca4728 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql @@ -0,0 +1,7 @@ +create table ANNOUNCEMENTS_READ +( + USER_ID varchar NOT NULL, + ANNOUNCEMENT_ID varchar NOT NULL, + PRIMARY KEY (USER_ID, ANNOUNCEMENT_ID), + FOREIGN KEY (USER_ID) references USER (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230724114349__oneshots.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230724114349__oneshots.sql new file mode 100644 index 000000000..9d792af0f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230724114349__oneshots.sql @@ -0,0 +1,8 @@ +alter table BOOK + add column oneshot boolean NOT NULL DEFAULT 0; + +alter table SERIES + add column oneshot boolean NOT NULL DEFAULT 0; + +ALTER TABLE LIBRARY + add column ONESHOTS_DIRECTORY varchar NULL DEFAULT NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230918114751__library_settings.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230918114751__library_settings.sql new file mode 100644 index 000000000..49abb266a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230918114751__library_settings.sql @@ -0,0 +1,12 @@ +ALTER TABLE LIBRARY + RENAME COLUMN _UNUSED to SCAN_STARTUP; +UPDATE LIBRARY +SET SCAN_STARTUP = ${library-scan-startup}; +ALTER TABLE LIBRARY + add column SCAN_CBX boolean NOT NULL DEFAULT 1; +ALTER TABLE LIBRARY + add column SCAN_PDF boolean NOT NULL DEFAULT 1; +ALTER TABLE LIBRARY + add column SCAN_EPUB boolean NOT NULL DEFAULT 1; +ALTER TABLE LIBRARY + add column SCAN_INTERVAL varchar NOT NULL DEFAULT 'EVERY_6H'; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230921112923__library_directory_exclusions.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230921112923__library_directory_exclusions.sql new file mode 100644 index 000000000..b52b72fc2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230921112923__library_directory_exclusions.sql @@ -0,0 +1,17 @@ +CREATE TABLE LIBRARY_EXCLUSIONS +( + LIBRARY_ID varchar NOT NULL, + EXCLUSION varchar NOT NULL, + PRIMARY KEY (LIBRARY_ID, EXCLUSION), + FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY (ID) +); + +CREATE INDEX idx__library_exclusions__library_id on LIBRARY_EXCLUSIONS (LIBRARY_ID); + +INSERT INTO LIBRARY_EXCLUSIONS +WITH cte_exclusions(exclude) AS (VALUES ('#recycle'), + ('@eaDir'), + ('@Recycle')) +SELECT LIBRARY.ID, cte_exclusions.exclude +FROM LIBRARY + cross join cte_exclusions; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230922143307__server_settings.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230922143307__server_settings.sql new file mode 100644 index 000000000..9f918c4df --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20230922143307__server_settings.sql @@ -0,0 +1,14 @@ +CREATE TABLE SERVER_SETTINGS +( + KEY varchar NOT NULL PRIMARY KEY, + VALUE varchar NULL +); + +INSERT INTO SERVER_SETTINGS +VALUES ('DELETE_EMPTY_COLLECTIONS', ${delete-empty-collections}); +INSERT INTO SERVER_SETTINGS +VALUES ('DELETE_EMPTY_READLISTS', ${delete-empty-read-lists}); +INSERT INTO SERVER_SETTINGS +VALUES ('REMEMBER_ME_KEY', hex(randomblob(32))); +INSERT INTO SERVER_SETTINGS +VALUES ('REMEMBER_ME_DURATION', 365); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231005165322__thumbnails_additional_info.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231005165322__thumbnails_additional_info.sql new file mode 100644 index 000000000..56d794507 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231005165322__thumbnails_additional_info.sql @@ -0,0 +1,36 @@ +ALTER TABLE main.THUMBNAIL_BOOK + add column WIDTH int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_BOOK + add column HEIGHT int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_BOOK + add column MEDIA_TYPE varchar NOT NULL default ''; +ALTER TABLE main.THUMBNAIL_BOOK + add column FILE_SIZE int8 NOT NULL default 0; + +ALTER TABLE main.THUMBNAIL_SERIES + add column WIDTH int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_SERIES + add column HEIGHT int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_SERIES + add column MEDIA_TYPE varchar NOT NULL default ''; +ALTER TABLE main.THUMBNAIL_SERIES + add column FILE_SIZE int8 NOT NULL default 0; + +ALTER TABLE main.THUMBNAIL_COLLECTION + add column WIDTH int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_COLLECTION + add column HEIGHT int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_COLLECTION + add column MEDIA_TYPE varchar NOT NULL default ''; +ALTER TABLE main.THUMBNAIL_COLLECTION + add column FILE_SIZE int8 NOT NULL default 0; + +ALTER TABLE main.THUMBNAIL_READLIST + add column WIDTH int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_READLIST + add column HEIGHT int NOT NULL default 0; +ALTER TABLE main.THUMBNAIL_READLIST + add column MEDIA_TYPE varchar NOT NULL default ''; +ALTER TABLE main.THUMBNAIL_READLIST + add column FILE_SIZE int8 NOT NULL default 0; + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231026164836__thumbnail_book_indices.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231026164836__thumbnail_book_indices.sql new file mode 100644 index 000000000..e405ae3d3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231026164836__thumbnail_book_indices.sql @@ -0,0 +1,6 @@ +create index idx__thumbnail_book__width + on THUMBNAIL_BOOK (WIDTH); +create index idx__thumbnail_book__height + on THUMBNAIL_BOOK (HEIGHT); +create index idx__thumbnail_book__file_size + on THUMBNAIL_BOOK (FILE_SIZE); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql new file mode 100644 index 000000000..50f7a8dea --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql @@ -0,0 +1,4 @@ +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/pdf' + and STATUS = 'READY'; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231116162048__epub.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231116162048__epub.sql new file mode 100644 index 000000000..f172a709c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231116162048__epub.sql @@ -0,0 +1,16 @@ +alter table MEDIA_FILE + add column MEDIA_TYPE varchar NULL; +alter table MEDIA_FILE + add column SUB_TYPE varchar NULL; +alter table MEDIA_FILE + add column FILE_SIZE int8 NULL; + +alter table MEDIA + add column EXTENSION_CLASS varchar NULL; +alter table MEDIA + add column EXTENSION_VALUE varchar NULL; + +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/epub+zip' + and STATUS = 'READY'; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql new file mode 100644 index 000000000..8bb13334c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql @@ -0,0 +1,13 @@ +update MEDIA +set EXTENSION_VALUE = null; + +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/epub+zip' + and STATUS = 'READY'; + +ALTER TABLE MEDIA + RENAME COLUMN EXTENSION_VALUE to _UNUSED; + +alter table MEDIA + add column EXTENSION_VALUE_BLOB blob NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql new file mode 100644 index 000000000..53a206967 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql @@ -0,0 +1,6 @@ +ALTER TABLE READ_PROGRESS + ADD COLUMN device_id varchar default ''; +ALTER TABLE READ_PROGRESS + ADD COLUMN device_name varchar default ''; +ALTER TABLE READ_PROGRESS + ADD COLUMN locator blob null; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231213170042__cleanup_empty_thumbnails.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231213170042__cleanup_empty_thumbnails.sql new file mode 100644 index 000000000..33293b5b3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231213170042__cleanup_empty_thumbnails.sql @@ -0,0 +1,4 @@ +delete +from THUMBNAIL_BOOK +where TYPE = 'GENERATED' + and THUMBNAIL is null; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214152305__epub_divina_compatibility.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214152305__epub_divina_compatibility.sql new file mode 100644 index 000000000..d3214f098 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214152305__epub_divina_compatibility.sql @@ -0,0 +1,2 @@ +alter table MEDIA + add column EPUB_DIVINA_COMPATIBLE boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214163213__reanalyze_epub.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214163213__reanalyze_epub.sql new file mode 100644 index 000000000..9aa1d0634 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20231214163213__reanalyze_epub.sql @@ -0,0 +1,9 @@ +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/epub+zip'; + +update media +set STATUS = 'OUTDATED' +where BOOK_ID in (select ID + from BOOK + where URL like '%.epub' collate NOCASE); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql new file mode 100644 index 000000000..421002b8a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql @@ -0,0 +1,18 @@ +CREATE TABLE USER_API_KEY +( + ID varchar NOT NULL PRIMARY KEY, + USER_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + API_KEY varchar NOT NULL UNIQUE, + COMMENT varchar NOT NULL, + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +create index if not exists idx__user_api_key__user_id + on USER_API_KEY (USER_ID); + +ALTER TABLE AUTHENTICATION_ACTIVITY + ADD COLUMN API_KEY_ID varchar NULL DEFAULT NULL; +ALTER TABLE AUTHENTICATION_ACTIVITY + ADD COLUMN API_KEY_COMMENT varchar NULL DEFAULT NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql new file mode 100644 index 000000000..e36b0239c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql @@ -0,0 +1,41 @@ +CREATE TABLE SYNC_POINT +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + USER_ID varchar NOT NULL, + API_KEY_ID varchar NULL, + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +create index if not exists idx__sync_point__user_id + on SYNC_POINT (USER_ID); + +CREATE TABLE SYNC_POINT_BOOK_REMOVED_SYNCED +( + SYNC_POINT_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + PRIMARY KEY (SYNC_POINT_ID, BOOK_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_book_removed_status__sync_point_id + on SYNC_POINT_BOOK_REMOVED_SYNCED (SYNC_POINT_ID); + +CREATE TABLE SYNC_POINT_BOOK +( + SYNC_POINT_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + BOOK_CREATED_DATE datetime NOT NULL, + BOOK_LAST_MODIFIED_DATE datetime NOT NULL, + BOOK_FILE_LAST_MODIFIED datetime NOT NULL, + BOOK_FILE_SIZE int8 NOT NULL, + BOOK_FILE_HASH varchar NOT NULL, + BOOK_METADATA_LAST_MODIFIED_DATE datetime NOT NULL, + BOOK_READ_PROGRESS_LAST_MODIFIED_DATE datetime NULL, + SYNCED boolean NOT NULL default false, + PRIMARY KEY (SYNC_POINT_ID, BOOK_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_book__sync_point_id + on SYNC_POINT_BOOK (SYNC_POINT_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql new file mode 100644 index 000000000..2e0cda28c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql @@ -0,0 +1,6 @@ +alter table USER + add column ROLE_KOBO_SYNC boolean NOT NULL DEFAULT 0; + +update USER +set ROLE_KOBO_SYNC = 1 +where ROLE_ADMIN = 1; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240906152500__read_progress_series_read_date.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240906152500__read_progress_series_read_date.sql new file mode 100644 index 000000000..8f5b35a77 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240906152500__read_progress_series_read_date.sql @@ -0,0 +1,16 @@ +alter table READ_PROGRESS_SERIES + add MOST_RECENT_READ_DATE datetime NULL; +alter table READ_PROGRESS_SERIES + add LAST_MODIFIED_DATE datetime NULL; + +update READ_PROGRESS_SERIES +set MOST_RECENT_READ_DATE = (select max(r.READ_DATE) + from READ_PROGRESS r + inner join BOOK b on r.BOOK_ID = b.ID + where b.SERIES_ID = READ_PROGRESS_SERIES.SERIES_ID); + +update READ_PROGRESS_SERIES +set LAST_MODIFIED_DATE = (select max(r.LAST_MODIFIED_DATE) + from READ_PROGRESS r + inner join BOOK b on r.BOOK_ID = b.ID + where b.SERIES_ID = READ_PROGRESS_SERIES.SERIES_ID); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240909112827__syncpoint_readlists.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240909112827__syncpoint_readlists.sql new file mode 100644 index 000000000..d56d9d08f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240909112827__syncpoint_readlists.sql @@ -0,0 +1,34 @@ +CREATE TABLE SYNC_POINT_READLIST +( + SYNC_POINT_ID varchar NOT NULL, + READLIST_ID varchar NOT NULL, + READLIST_NAME varchar NOT NULL, + READLIST_CREATED_DATE datetime NOT NULL, + READLIST_LAST_MODIFIED_DATE datetime NOT NULL, + SYNCED boolean NOT NULL default false, + PRIMARY KEY (SYNC_POINT_ID, READLIST_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_readlist__sync_point_id + on SYNC_POINT_READLIST (SYNC_POINT_ID); + +CREATE TABLE SYNC_POINT_READLIST_BOOK +( + SYNC_POINT_ID varchar NOT NULL, + READLIST_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + PRIMARY KEY (SYNC_POINT_ID, READLIST_ID, BOOK_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_readlist_book__sync_point_id_readlist_id + on SYNC_POINT_READLIST_BOOK (SYNC_POINT_ID, READLIST_ID); + +CREATE TABLE SYNC_POINT_READLIST_REMOVED_SYNCED +( + SYNC_POINT_ID varchar NOT NULL, + READLIST_ID varchar NOT NULL, + PRIMARY KEY (SYNC_POINT_ID, READLIST_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240911175419__kepub.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240911175419__kepub.sql new file mode 100644 index 000000000..98971ecfc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240911175419__kepub.sql @@ -0,0 +1,2 @@ +alter table MEDIA + add column EPUB_IS_KEPUB boolean NOT NULL DEFAULT 0; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240925174446__syncpoint_covers.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240925174446__syncpoint_covers.sql new file mode 100644 index 000000000..dca31cd1c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20240925174446__syncpoint_covers.sql @@ -0,0 +1,2 @@ +ALTER TABLE main.SYNC_POINT_BOOK + ADD COLUMN BOOK_THUMBNAIL_ID varchar NULL; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108115503__user_roles.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108115503__user_roles.sql new file mode 100644 index 000000000..88e7d15bf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108115503__user_roles.sql @@ -0,0 +1,68 @@ +CREATE TABLE USER_ROLE +( + USER_ID varchar NOT NULL, + ROLE varchar NOT NULL, + PRIMARY KEY (USER_ID, ROLE), + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +insert into USER_ROLE +select id, "ADMIN" +from user +where ROLE_ADMIN = 1; + +insert into USER_ROLE +select id, "KOREADER_SYNC" +from user +where ROLE_ADMIN = 1; + +insert into USER_ROLE +select id, "FILE_DOWNLOAD" +from user +where ROLE_FILE_DOWNLOAD = 1; + +insert into USER_ROLE +select id, "PAGE_STREAMING" +from user +where ROLE_PAGE_STREAMING = 1; + +insert into USER_ROLE +select id, "KOBO_SYNC" +from user +where ROLE_KOBO_SYNC = 1; + +-- Remove columns ROLE_ADMIN, ROLE_FILE_DOWNLOAD, ROLE_PAGE_STREAMING, ROLE_KOBO_SYNC from USER +PRAGMA foreign_keys= OFF; + +create table USER_dg_tmp +( + ID varchar not null + primary key, + CREATED_DATE datetime default CURRENT_TIMESTAMP not null, + LAST_MODIFIED_DATE datetime default CURRENT_TIMESTAMP not null, + EMAIL varchar not null + unique, + PASSWORD varchar not null, + SHARED_ALL_LIBRARIES boolean default 1 not null, + AGE_RESTRICTION integer, + AGE_RESTRICTION_ALLOW_ONLY boolean +); + +insert into USER_dg_tmp(ID, CREATED_DATE, LAST_MODIFIED_DATE, EMAIL, PASSWORD, SHARED_ALL_LIBRARIES, AGE_RESTRICTION, + AGE_RESTRICTION_ALLOW_ONLY) +select ID, + CREATED_DATE, + LAST_MODIFIED_DATE, + EMAIL, + PASSWORD, + SHARED_ALL_LIBRARIES, + AGE_RESTRICTION, + AGE_RESTRICTION_ALLOW_ONLY +from USER; + +drop table USER; + +alter table USER_dg_tmp + rename to USER; + +PRAGMA foreign_keys= ON; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108172343__koreader_hash.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108172343__koreader_hash.sql new file mode 100644 index 000000000..1700281a3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250108172343__koreader_hash.sql @@ -0,0 +1,6 @@ +ALTER TABLE LIBRARY + add column HASH_KOREADER boolean NOT NULL DEFAULT 0; + +ALTER TABLE BOOK + ADD COLUMN FILE_HASH_KOREADER varchar NOT NULL DEFAULT ''; + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250205151235__client_settings.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250205151235__client_settings.sql new file mode 100644 index 000000000..759504f4a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250205151235__client_settings.sql @@ -0,0 +1,15 @@ +CREATE TABLE CLIENT_SETTINGS_GLOBAL +( + KEY varchar NOT NULL PRIMARY KEY, + VALUE varchar NOT NULL, + ALLOW_UNAUTHORIZED boolean NOT NULL default 0 +); + +CREATE TABLE CLIENT_SETTINGS_USER +( + USER_ID varchar NOT NULL, + KEY varchar NOT NULL, + VALUE varchar NOT NULL, + FOREIGN KEY (USER_ID) REFERENCES USER (ID), + PRIMARY KEY (KEY, USER_ID) +); diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250730173126__remove_temp_string_table.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250730173126__remove_temp_string_table.sql new file mode 100644 index 000000000..ed9da7fb6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20250730173126__remove_temp_string_table.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS TEMP_STRING_LIST; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20260225161438__tags_view.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20260225161438__tags_view.sql new file mode 100644 index 000000000..8e387d9bd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/db/migration/sqlite/V20260225161438__tags_view.sql @@ -0,0 +1,6 @@ +CREATE VIEW SERIES_AND_BOOK_TAG AS +SELECT BMAT.TAG, BMAT.SERIES_ID +FROM BOOK_METADATA_AGGREGATION_TAG BMAT +UNION ALL +SELECT SMT.TAG, SMT.SERIES_ID +FROM SERIES_METADATA_TAG SMT; diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/tasks/migration/sqlite/V20231013114850__tasks.sql b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/tasks/migration/sqlite/V20231013114850__tasks.sql new file mode 100644 index 000000000..0d0775a94 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/flyway/resources/tasks/migration/sqlite/V20231013114850__tasks.sql @@ -0,0 +1,16 @@ +CREATE TABLE TASK +( + ID varchar NOT NULL PRIMARY KEY, + PRIORITY int NOT NULL, + GROUP_ID varchar NULL, + CLASS varchar NOT NULL, + SIMPLE_TYPE varchar NOT NULL, + PAYLOAD varchar NOT NULL, + OWNER varchar NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +create index idx__tasks__owner_group_id + on TASK (OWNER, GROUP_ID); + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/Application.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/Application.kt new file mode 100644 index 000000000..e85b8d152 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/Application.kt @@ -0,0 +1,19 @@ +package org.gotson.komga + +import org.gotson.komga.infrastructure.util.checkTempDirectory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@EnableScheduling +class Application + +fun main(args: Array) { + checkTempDirectory() + + System.setProperty("org.jooq.no-logo", "true") + System.setProperty("org.jooq.no-tips", "true") + + runApplication(*args) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/events/AsynchronousSpringEventsConfig.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/events/AsynchronousSpringEventsConfig.kt new file mode 100644 index 000000000..43c3be0ca --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/events/AsynchronousSpringEventsConfig.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.application.events + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.context.event.ApplicationEventMulticaster +import org.springframework.context.event.SimpleApplicationEventMulticaster +import org.springframework.core.task.AsyncTaskExecutor + +@Profile("!test") +@Configuration +class AsynchronousSpringEventsConfig( + private val applicationTaskExecutor: AsyncTaskExecutor, +) { + @Bean("applicationEventMulticaster") + fun simpleApplicationEventMulticaster(): ApplicationEventMulticaster = + SimpleApplicationEventMulticaster().apply { + setTaskExecutor(applicationTaskExecutor) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/scheduler/LibraryScanScheduler.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/scheduler/LibraryScanScheduler.kt new file mode 100644 index 000000000..f002a1c6e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/scheduler/LibraryScanScheduler.kt @@ -0,0 +1,65 @@ +package org.gotson.komga.application.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Library.ScanInterval.DAILY +import org.gotson.komga.domain.model.Library.ScanInterval.DISABLED +import org.gotson.komga.domain.model.Library.ScanInterval.EVERY_12H +import org.gotson.komga.domain.model.Library.ScanInterval.EVERY_6H +import org.gotson.komga.domain.model.Library.ScanInterval.HOURLY +import org.gotson.komga.domain.model.Library.ScanInterval.WEEKLY +import org.springframework.scheduling.TaskScheduler +import org.springframework.scheduling.config.FixedRateTask +import org.springframework.scheduling.config.ScheduledTask +import org.springframework.scheduling.config.ScheduledTaskHolder +import org.springframework.scheduling.config.ScheduledTaskRegistrar +import org.springframework.stereotype.Service +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger {} + +@Service +class LibraryScanScheduler( + taskScheduler: TaskScheduler, + private val taskEmitter: TaskEmitter, +) : ScheduledTaskHolder { + // map the libraryId to the scan scheduled task + private val registry = ConcurrentHashMap() + + private val registrar = + ScheduledTaskRegistrar().apply { + setTaskScheduler(taskScheduler) + } + + fun scheduleScan(library: Library) { + registry.remove(library.id)?.cancel(false) + if (library.scanInterval != DISABLED) { + registrar + .scheduleFixedRateTask( + FixedRateTask( + { + logger.info { "Periodic scan for library: ${library.name}" } + taskEmitter.scanLibrary(library.id) + }, + library.scanInterval.toDuration(), + library.scanInterval.toDuration(), + ), + )?.let { registry[library.id] = it } + } + } + + // the '/actuator/scheduledtasks' endpoint will pick up any ScheduledTaskHolder and display its tasks + override fun getScheduledTasks(): MutableSet = registry.values.toMutableSet() +} + +private fun Library.ScanInterval.toDuration(): Duration = + when (this) { + DISABLED -> throw IllegalArgumentException("Cannot convert DISABLED to Duration") + HOURLY -> Duration.ofHours(1) + EVERY_6H -> Duration.ofHours(6) + EVERY_12H -> Duration.ofHours(12) + DAILY -> Duration.ofDays(1) + WEEKLY -> Duration.ofDays(7) + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt new file mode 100644 index 000000000..bafedca4b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt @@ -0,0 +1,245 @@ +package org.gotson.komga.application.tasks + +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.infrastructure.search.LuceneEntity + +const val HIGHEST_PRIORITY = 8 +const val HIGH_PRIORITY = 6 +const val DEFAULT_PRIORITY = 4 +const val LOW_PRIORITY = 2 +const val LOWEST_PRIORITY = 0 + +sealed class Task( + val priority: Int = DEFAULT_PRIORITY, + val groupId: String? = null, +) { + abstract val uniqueId: String + + class ScanLibrary( + val libraryId: String, + val scanDeep: Boolean, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "SCAN_LIBRARY_${libraryId}_DEEP_$scanDeep" + + override fun toString(): String = "ScanLibrary(libraryId='$libraryId', scanDeep='$scanDeep', priority='$priority')" + } + + class FindBooksToConvert( + val libraryId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "FIND_BOOKS_TO_CONVERT_$libraryId" + + override fun toString(): String = "FindBooksToConvert(libraryId='$libraryId', priority='$priority')" + } + + class FindBooksWithMissingPageHash( + val libraryId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "FIND_BOOKS_WITH_MISSING_PAGE_HASH_$libraryId" + + override fun toString(): String = "FindBooksWithMissingPageHash(libraryId='$libraryId', priority='$priority')" + } + + class FindDuplicatePagesToDelete( + val libraryId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "FIND_DUPLICATE_PAGES_TO_DELETE_$libraryId" + + override fun toString(): String = "FindDuplicatePagesToDelete(libraryId='$libraryId', priority='$priority')" + } + + class EmptyTrash( + val libraryId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "EMPTY_TRASH_$libraryId" + + override fun toString(): String = "EmptyTrash(libraryId='$libraryId', priority='$priority')" + } + + class AnalyzeBook( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + groupId: String, + ) : Task(priority, groupId) { + override val uniqueId = "ANALYZE_BOOK_$bookId" + + override fun toString(): String = "AnalyzeBook(bookId='$bookId', priority='$priority')" + } + + class GenerateBookThumbnail( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "GENERATE_BOOK_THUMBNAIL_$bookId" + + override fun toString(): String = "GenerateBookThumbnail(bookId='$bookId', priority='$priority')" + } + + class RefreshBookMetadata( + val bookId: String, + val capabilities: Set, + priority: Int = DEFAULT_PRIORITY, + groupId: String, + ) : Task(priority, groupId) { + override val uniqueId = "REFRESH_BOOK_METADATA_$bookId" + + override fun toString(): String = "RefreshBookMetadata(bookId='$bookId', capabilities=$capabilities, priority='$priority')" + } + + class HashBook( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "HASH_BOOK_$bookId" + + override fun toString(): String = "HashBook(bookId='$bookId', priority='$priority')" + } + + class HashBookPages( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "HASH_BOOK_PAGES_$bookId" + + override fun toString(): String = "HashBookPages(bookId='$bookId', priority='$priority')" + } + + class HashBookKoreader( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "HASH_BOOK_KOREADER_$bookId" + + override fun toString(): String = "HashBookKoreader(bookId='$bookId', priority='$priority')" + } + + class RefreshSeriesMetadata( + val seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority, seriesId) { + override val uniqueId = "REFRESH_SERIES_METADATA_$seriesId" + + override fun toString(): String = "RefreshSeriesMetadata(seriesId='$seriesId', priority='$priority')" + } + + class AggregateSeriesMetadata( + val seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority, seriesId) { + override val uniqueId = "AGGREGATE_SERIES_METADATA_$seriesId" + + override fun toString(): String = "AggregateSeriesMetadata(seriesId='$seriesId', priority='$priority')" + } + + class RefreshBookLocalArtwork( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId: String = "REFRESH_BOOK_LOCAL_ARTWORK_$bookId" + + override fun toString(): String = "RefreshBookLocalArtwork(bookId='$bookId', priority='$priority')" + } + + class RefreshSeriesLocalArtwork( + val seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId: String = "REFRESH_SERIES_LOCAL_ARTWORK_$seriesId" + + override fun toString(): String = "RefreshSeriesLocalArtwork(seriesId=$seriesId, priority='$priority')" + } + + class ImportBook( + val sourceFile: String, + val seriesId: String, + val copyMode: CopyMode, + val destinationName: String?, + val upgradeBookId: String?, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority, seriesId) { + override val uniqueId: String = "IMPORT_BOOK_${seriesId}_$sourceFile" + + override fun toString(): String = "ImportBook(sourceFile='$sourceFile', seriesId='$seriesId', copyMode=$copyMode, destinationName=$destinationName, upgradeBookId=$upgradeBookId, priority='$priority')" + } + + class ConvertBook( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + groupId: String, + ) : Task(priority, groupId) { + override val uniqueId: String = "CONVERT_BOOK_$bookId" + + override fun toString(): String = "ConvertBook(bookId='$bookId', priority='$priority')" + } + + class RepairExtension( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + groupId: String, + ) : Task(priority, groupId) { + override val uniqueId: String = "REPAIR_EXTENSION_$bookId" + + override fun toString(): String = "RepairExtension(bookId='$bookId', priority='$priority')" + } + + class RemoveHashedPages( + val bookId: String, + val pages: Collection, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId: String = "REMOVE_HASHED_PAGES_$bookId" + + override fun toString(): String = "RemoveHashedPages(bookId='$bookId', priority='$priority')" + } + + class RebuildIndex( + val entities: Set?, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "REBUILD_INDEX" + + override fun toString(): String = "RebuildIndex(priority='$priority',entities='${entities?.map { it.type }}')" + } + + class UpgradeIndex( + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "UPGRADE_INDEX" + + override fun toString(): String = "UpgradeIndex(priority='$priority')" + } + + class DeleteBook( + val bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "DELETE_BOOK_$bookId" + + override fun toString(): String = "DeleteBook(bookId='$bookId', priority='$priority')" + } + + class DeleteSeries( + val seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "DELETE_SERIES_$seriesId" + + override fun toString(): String = "DeleteSeries(seriesId='$seriesId', priority='$priority')" + } + + class FindBookThumbnailsToRegenerate( + val forBiggerResultOnly: Boolean, + priority: Int = DEFAULT_PRIORITY, + ) : Task(priority) { + override val uniqueId = "FIND_BOOK_THUMBNAILS_TO_REGENERATE" + + override fun toString(): String = "FindBookThumbnailsToRegenerate(forBiggerResultOnly='$forBiggerResultOnly', priority='$priority')" + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskAddedEvent.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskAddedEvent.kt new file mode 100644 index 000000000..1144b929b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskAddedEvent.kt @@ -0,0 +1,3 @@ +package org.gotson.komga.application.tasks + +data object TaskAddedEvent diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskEmitter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskEmitter.kt new file mode 100644 index 000000000..3cd4b6f2e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskEmitter.kt @@ -0,0 +1,295 @@ +package org.gotson.komga.application.tasks + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.service.BookConverter +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class TaskEmitter( + private val bookRepository: BookRepository, + private val bookConverter: BookConverter, + private val tasksRepository: TasksRepository, + private val eventPublisher: ApplicationEventPublisher, +) { + fun scanLibrary( + libraryId: String, + scanDeep: Boolean = false, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.ScanLibrary(libraryId, scanDeep, priority)) + } + + fun emptyTrash( + libraryId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.EmptyTrash(libraryId, priority)) + } + + fun analyzeUnknownAndOutdatedBooks(library: Library) { + bookRepository + .findAll( + SearchCondition.AllOfBook( + SearchCondition.LibraryId(SearchOperator.Is(library.id)), + SearchCondition.AnyOfBook( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.UNKNOWN)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.OUTDATED)), + ), + ), + SearchContext.empty(), + UnpagedSorted(Sort.by(Sort.Order.asc("seriesId"), Sort.Order.asc("number"))), + ).content + .map { Task.AnalyzeBook(it.id, groupId = it.seriesId) } + .let { submitTasks(it) } + } + + fun hashBooksWithoutHash(library: Library) { + if (library.hashFiles) + bookRepository + .findAllByLibraryIdAndWithEmptyHash(library.id) + .map { Task.HashBook(it.id, LOWEST_PRIORITY) } + .let { submitTasks(it) } + } + + fun hashBooksWithoutHashKoreader(library: Library) { + if (library.hashKoreader) + bookRepository + .findAllByLibraryIdAndWithEmptyHashKoreader(library.id) + .map { Task.HashBookKoreader(it.id, LOWEST_PRIORITY) } + .let { submitTasks(it) } + } + + fun findBooksWithMissingPageHash( + library: Library, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.FindBooksWithMissingPageHash(library.id, priority)) + } + + fun hashBookPages( + bookIdToSeriesId: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + bookIdToSeriesId + .map { Task.HashBookPages(it, priority) } + .let { submitTasks(it) } + } + + fun findBooksToConvert( + library: Library, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.FindBooksToConvert(library.id, priority)) + } + + fun convertBookToCbz( + books: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + books + .map { Task.ConvertBook(it.id, priority, it.seriesId) } + .let { submitTasks(it) } + } + + fun repairExtensions( + library: Library, + priority: Int = DEFAULT_PRIORITY, + ) { + if (library.repairExtensions) + bookConverter + .getMismatchedExtensionBooks(library) + .map { Task.RepairExtension(it.id, priority, it.seriesId) } + .let { submitTasks(it) } + } + + fun findDuplicatePagesToDelete( + library: Library, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.FindDuplicatePagesToDelete(library.id, priority)) + } + + fun removeDuplicatePages( + bookId: String, + pages: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.RemoveHashedPages(bookId, pages, priority)) + } + + fun removeDuplicatePages( + bookIdToPages: Map>, + priority: Int = DEFAULT_PRIORITY, + ) { + bookIdToPages + .map { Task.RemoveHashedPages(it.key, it.value, priority) } + .let { submitTasks(it) } + } + + fun analyzeBook( + book: Book, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.AnalyzeBook(book.id, priority, book.seriesId)) + } + + fun analyzeBook( + books: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + books + .map { Task.AnalyzeBook(it.id, priority, it.seriesId) } + .let { submitTasks(it) } + } + + fun generateBookThumbnail( + bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.GenerateBookThumbnail(bookId, priority)) + } + + fun generateBookThumbnail( + bookIds: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + bookIds + .map { Task.GenerateBookThumbnail(it, priority) } + .let { submitTasks(it) } + } + + fun refreshBookMetadata( + book: Book, + capabilities: Set = BookMetadataPatchCapability.entries.toSet(), + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.RefreshBookMetadata(book.id, capabilities, priority, book.seriesId)) + } + + fun refreshBookMetadata( + books: Collection, + capabilities: Set = BookMetadataPatchCapability.entries.toSet(), + priority: Int = DEFAULT_PRIORITY, + ) { + books + .map { Task.RefreshBookMetadata(it.id, capabilities, priority, it.seriesId) } + .let { submitTasks(it) } + } + + fun refreshSeriesMetadata( + seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.RefreshSeriesMetadata(seriesId, priority)) + } + + fun aggregateSeriesMetadata( + seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.AggregateSeriesMetadata(seriesId, priority)) + } + + fun refreshBookLocalArtwork( + book: Book, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.RefreshBookLocalArtwork(book.id, priority)) + } + + fun refreshBookLocalArtwork( + books: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + books + .map { Task.RefreshBookLocalArtwork(it.id, priority) } + .let { submitTasks(it) } + } + + fun refreshSeriesLocalArtwork( + seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.RefreshSeriesLocalArtwork(seriesId, priority)) + } + + fun refreshSeriesLocalArtwork( + seriesIds: Collection, + priority: Int = DEFAULT_PRIORITY, + ) { + seriesIds + .map { Task.RefreshSeriesLocalArtwork(it, priority) } + .let { submitTasks(it) } + } + + fun importBook( + sourceFile: String, + seriesId: String, + copyMode: CopyMode, + destinationName: String?, + upgradeBookId: String?, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.ImportBook(sourceFile, seriesId, copyMode, destinationName, upgradeBookId, priority)) + } + + fun rebuildIndex( + priority: Int = DEFAULT_PRIORITY, + entities: Set? = null, + ) { + submitTask(Task.RebuildIndex(entities, priority)) + } + + fun upgradeIndex(priority: Int = DEFAULT_PRIORITY) { + submitTask(Task.UpgradeIndex(priority)) + } + + fun deleteBook( + bookId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.DeleteBook(bookId, priority)) + } + + fun deleteSeries( + seriesId: String, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.DeleteSeries(seriesId, priority)) + } + + fun findBookThumbnailsToRegenerate( + forBiggerResultOnly: Boolean, + priority: Int = DEFAULT_PRIORITY, + ) { + submitTask(Task.FindBookThumbnailsToRegenerate(forBiggerResultOnly, priority)) + } + + private fun submitTask(task: Task) { + logger.info { "Sending task: $task" } + tasksRepository.save(task) + eventPublisher.publishEvent(TaskAddedEvent) + } + + private fun submitTasks(tasks: Collection) { + logger.info { "Sending tasks: $tasks" } + tasksRepository.save(tasks) + eventPublisher.publishEvent(TaskAddedEvent) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt new file mode 100644 index 000000000..6038c8670 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt @@ -0,0 +1,194 @@ +package org.gotson.komga.application.tasks + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.core.instrument.MeterRegistry +import org.gotson.komga.domain.model.BookAction +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookConverter +import org.gotson.komga.domain.service.BookImporter +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.BookMetadataLifecycle +import org.gotson.komga.domain.service.BookPageEditor +import org.gotson.komga.domain.service.LibraryContentLifecycle +import org.gotson.komga.domain.service.LocalArtworkLifecycle +import org.gotson.komga.domain.service.PageHashLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.domain.service.SeriesMetadataLifecycle +import org.gotson.komga.infrastructure.search.SearchIndexLifecycle +import org.gotson.komga.interfaces.scheduler.METER_TASKS_EXECUTION +import org.gotson.komga.interfaces.scheduler.METER_TASKS_FAILURE +import org.springframework.stereotype.Service +import java.nio.file.Paths +import kotlin.time.measureTime +import kotlin.time.toJavaDuration + +private val logger = KotlinLogging.logger {} + +@Service +class TaskHandler( + private val taskEmitter: TaskEmitter, + private val libraryRepository: LibraryRepository, + private val bookRepository: BookRepository, + private val seriesRepository: SeriesRepository, + private val libraryContentLifecycle: LibraryContentLifecycle, + private val bookLifecycle: BookLifecycle, + private val bookMetadataLifecycle: BookMetadataLifecycle, + private val seriesLifecycle: SeriesLifecycle, + private val seriesMetadataLifecycle: SeriesMetadataLifecycle, + private val localArtworkLifecycle: LocalArtworkLifecycle, + private val bookImporter: BookImporter, + private val bookConverter: BookConverter, + private val bookPageEditor: BookPageEditor, + private val searchIndexLifecycle: SearchIndexLifecycle, + private val pageHashLifecycle: PageHashLifecycle, + private val meterRegistry: MeterRegistry, +) { + fun handleTask(task: Task) { + logger.info { "Executing task: $task" } + try { + measureTime { + when (task) { + is Task.ScanLibrary -> + libraryRepository.findByIdOrNull(task.libraryId)?.let { library -> + libraryContentLifecycle.scanRootFolder(library, task.scanDeep) + taskEmitter.analyzeUnknownAndOutdatedBooks(library) + taskEmitter.repairExtensions(library, LOW_PRIORITY) + taskEmitter.findBooksToConvert(library, LOWEST_PRIORITY) + taskEmitter.findBooksWithMissingPageHash(library, LOWEST_PRIORITY) + taskEmitter.findDuplicatePagesToDelete(library, LOWEST_PRIORITY) + taskEmitter.hashBooksWithoutHash(library) + taskEmitter.hashBooksWithoutHashKoreader(library) + } ?: logger.warn { "Cannot execute task $task: Library does not exist" } + + is Task.FindBooksToConvert -> + libraryRepository.findByIdOrNull(task.libraryId)?.let { library -> + taskEmitter.convertBookToCbz(bookConverter.getConvertibleBooks(library), task.priority + 1) + } ?: logger.warn { "Cannot execute task $task: Library does not exist" } + + is Task.FindBooksWithMissingPageHash -> + libraryRepository.findByIdOrNull(task.libraryId)?.let { library -> + taskEmitter.hashBookPages(pageHashLifecycle.getBookIdsWithMissingPageHash(library), task.priority + 1) + } ?: logger.warn { "Cannot execute task $task: Library does not exist" } + + is Task.FindDuplicatePagesToDelete -> + libraryRepository.findByIdOrNull(task.libraryId)?.let { library -> + taskEmitter.removeDuplicatePages(pageHashLifecycle.getBookPagesToDeleteAutomatically(library), task.priority + 1) + } ?: logger.warn { "Cannot execute task $task: Library does not exist" } + + is Task.EmptyTrash -> + libraryRepository.findByIdOrNull(task.libraryId)?.let { library -> + libraryContentLifecycle.emptyTrash(library) + } ?: logger.warn { "Cannot execute task $task: Library does not exist" } + + is Task.AnalyzeBook -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + val actions = bookLifecycle.analyzeAndPersist(book) + if (actions.contains(BookAction.GENERATE_THUMBNAIL)) taskEmitter.generateBookThumbnail(book.id, priority = task.priority + 1) + if (actions.contains(BookAction.REFRESH_METADATA)) taskEmitter.refreshBookMetadata(book, priority = task.priority + 1) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.GenerateBookThumbnail -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookLifecycle.generateThumbnailAndPersist(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RefreshBookMetadata -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookMetadataLifecycle.refreshMetadata(book, task.capabilities) + taskEmitter.refreshSeriesMetadata(book.seriesId, priority = task.priority - 1) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RefreshSeriesMetadata -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + seriesMetadataLifecycle.refreshMetadata(series) + taskEmitter.aggregateSeriesMetadata(series.id, priority = task.priority) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + + is Task.AggregateSeriesMetadata -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + seriesMetadataLifecycle.aggregateMetadata(series) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + + is Task.RefreshBookLocalArtwork -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + localArtworkLifecycle.refreshLocalArtwork(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RefreshSeriesLocalArtwork -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + localArtworkLifecycle.refreshLocalArtwork(series) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + + is Task.ImportBook -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + val importedBook = bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId) + taskEmitter.analyzeBook(importedBook, priority = task.priority + 1) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + + is Task.ConvertBook -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookConverter.convertToCbz(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RepairExtension -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookConverter.repairExtension(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RemoveHashedPages -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + if (bookPageEditor.removeHashedPages(book, task.pages) == BookAction.GENERATE_THUMBNAIL) { + taskEmitter.generateBookThumbnail(book.id, priority = task.priority + 1) + } + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.HashBook -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookLifecycle.hashAndPersist(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.HashBookKoreader -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookLifecycle.hashKoreaderAndPersist(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.HashBookPages -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookLifecycle.hashPagesAndPersist(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RebuildIndex -> searchIndexLifecycle.rebuildIndex(task.entities) + + is Task.UpgradeIndex -> searchIndexLifecycle.upgradeIndex() + + is Task.DeleteBook -> { + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + if (book.oneshot) + seriesLifecycle.deleteSeriesFiles(seriesRepository.findByIdOrNull(book.seriesId)!!) + else + bookLifecycle.deleteBookFiles(book) + } + } + + is Task.DeleteSeries -> { + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + seriesLifecycle.deleteSeriesFiles(series) + } + } + + is Task.FindBookThumbnailsToRegenerate -> { + taskEmitter.generateBookThumbnail(bookLifecycle.findBookThumbnailsToRegenerate(task.forBiggerResultOnly), task.priority) + } + } + }.also { + logger.info { "Task $task executed in $it" } + meterRegistry.timer(METER_TASKS_EXECUTION, "type", task.javaClass.simpleName).record(it.toJavaDuration()) + } + } catch (e: Exception) { + logger.error(e) { "Task $task execution failed" } + meterRegistry.counter(METER_TASKS_FAILURE, "type", task.javaClass.simpleName).increment() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskProcessor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskProcessor.kt new file mode 100644 index 000000000..113b2803f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskProcessor.kt @@ -0,0 +1,73 @@ +package org.gotson.komga.application.tasks + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.configuration.SettingChangedEvent +import org.springframework.beans.factory.InitializingBean +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder +import org.springframework.context.event.EventListener +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class TaskProcessor( + private val tasksRepository: TasksRepository, + private val taskHandler: TaskHandler, + private val settingsProvider: KomgaSettingsProvider, + taskExecutorBuilder: ThreadPoolTaskExecutorBuilder, +) : InitializingBean { + val executor: ThreadPoolTaskExecutor = + taskExecutorBuilder + .threadNamePrefix("taskProcessor-") + .corePoolSize(settingsProvider.taskPoolSize) + .build() + .apply { initialize() } + + var processTasks = false + + override fun afterPropertiesSet() { + val disowned = tasksRepository.disown() + if (disowned > 0) + logger.info { "Reset $disowned tasks that were not finished" } + processTasks = true + } + + @EventListener(SettingChangedEvent.TaskPoolSize::class) + fun taskPoolSizeChanged() { + executor.corePoolSize = settingsProvider.taskPoolSize + } + + @EventListener(TaskAddedEvent::class, ApplicationReadyEvent::class) + fun processAvailableTask() { + if (processTasks) { + logger.debug { "Active count: ${executor.activeCount}, Core Pool Size: ${executor.corePoolSize}, Pool Size: ${executor.poolSize}" } + if (executor.corePoolSize == 1) { + executor.execute { takeAndProcess() } + } else { + // fan out while threads are available + while (tasksRepository.hasAvailable() && executor.activeCount < executor.corePoolSize) { + executor.execute { takeAndProcess() } + } + } + } else { + logger.debug { "Not processing tasks" } + } + } + + private fun takeAndProcess() { + logger.debug { "Try to process first available task" } + val task = tasksRepository.takeFirst() + if (task != null) { + logger.debug { "Found task to process: $task" } + taskHandler.handleTask(task) + logger.debug { "Task processed, remove it from the queue: $task" } + tasksRepository.delete(task.uniqueId) + processAvailableTask() + } else { + logger.debug { "No available task found" } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TasksRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TasksRepository.kt new file mode 100644 index 000000000..203b27210 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/application/tasks/TasksRepository.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.application.tasks + +interface TasksRepository { + fun hasAvailable(): Boolean + + fun takeFirst(owner: String = Thread.currentThread().name): Task? + + fun findAll(): List + + fun findAllGroupedByOwner(): Map> + + fun count(): Int + + fun countBySimpleType(): Map + + fun save(task: Task) + + fun save(tasks: Collection) + + fun delete(taskId: String) + + fun deleteAll() + + fun deleteAllWithoutOwner(): Int + + fun disown(): Int +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AgeRestriction.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AgeRestriction.kt new file mode 100644 index 000000000..70d63d49e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AgeRestriction.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.domain.model + +data class AgeRestriction( + val age: Int, + val restriction: AllowExclude, +) + +enum class AllowExclude { + ALLOW_ONLY, + EXCLUDE, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AlternateTitle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AlternateTitle.kt new file mode 100644 index 000000000..f781fd1f9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AlternateTitle.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class AlternateTitle( + val label: String, + val title: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt new file mode 100644 index 000000000..af644afcb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.time.LocalDateTime + +data class ApiKey( + val id: String = TsidCreator.getTsid256().toString(), + val userId: String, + val key: String, + val comment: String, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Auditable.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Auditable.kt new file mode 100644 index 000000000..5add0fbb0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Auditable.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +interface Auditable { + val createdDate: LocalDateTime + val lastModifiedDate: LocalDateTime +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt new file mode 100644 index 000000000..df9f0858f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +data class AuthenticationActivity( + val userId: String? = null, + val email: String? = null, + val apiKeyId: String? = null, + val apiKeyComment: String? = null, + val ip: String? = null, + val userAgent: String? = null, + val success: Boolean, + val error: String? = null, + val dateTime: LocalDateTime = LocalDateTime.now(), + val source: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt new file mode 100644 index 000000000..b1b4591e5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.domain.model + +class Author( + name: String, + role: String, +) { + val name = name.trim() + val role = role.trim().lowercase() + + override fun toString(): String = "Author($name, $role)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Author) return false + + if (name != other.name) return false + if (role != other.role) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + role.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BCP47TagValidator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BCP47TagValidator.kt new file mode 100644 index 000000000..b7086d7fb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BCP47TagValidator.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.model + +import com.ibm.icu.util.ULocale + +object BCP47TagValidator { + private val languages by lazy { ULocale.getISOLanguages().toSet() } + + fun isValid(value: String?): Boolean { + if (value == null) return false + return ULocale.forLanguageTag(value).let { + it.language.isNotBlank() && languages.contains(it.language) + } + } + + fun normalize(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + ULocale.forLanguageTag(value).toLanguageTag() + } catch (e: Exception) { + "" + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt new file mode 100644 index 000000000..b27ff81f8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.io.path.toPath + +data class Book( + val name: String, + val url: URL, + val fileLastModified: LocalDateTime, + val fileSize: Long = 0, + val fileHash: String = "", + val fileHashKoreader: String = "", + val number: Int = 0, + val id: String = TsidCreator.getTsid256().toString(), + val seriesId: String = "", + val libraryId: String = "", + val deletedDate: LocalDateTime? = null, + val oneshot: Boolean = false, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + @delegate:Transient + val path: Path by lazy { this.url.toURI().toPath() } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookAction.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookAction.kt new file mode 100644 index 000000000..7cbc40ed7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookAction.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +enum class BookAction { + REFRESH_METADATA, + GENERATE_THUMBNAIL, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt new file mode 100644 index 000000000..07e84f003 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt @@ -0,0 +1,82 @@ +package org.gotson.komga.domain.model + +import org.gotson.komga.language.lowerNotBlank +import java.time.LocalDate +import java.time.LocalDateTime + +class BookMetadata( + title: String, + summary: String = "", + number: String, + val numberSort: Float, + val releaseDate: LocalDate? = null, + val authors: List = emptyList(), + tags: Set = emptySet(), + val isbn: String = "", + val links: List = emptyList(), + val titleLock: Boolean = false, + val summaryLock: Boolean = false, + val numberLock: Boolean = false, + val numberSortLock: Boolean = false, + val releaseDateLock: Boolean = false, + val authorsLock: Boolean = false, + val tagsLock: Boolean = false, + val isbnLock: Boolean = false, + val linksLock: Boolean = false, + val bookId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + val title = title.trim() + val summary = summary.trim() + val number = number.trim() + val tags = tags.lowerNotBlank().toSet() + + fun copy( + title: String = this.title, + summary: String = this.summary, + number: String = this.number, + numberSort: Float = this.numberSort, + releaseDate: LocalDate? = this.releaseDate, + authors: List = this.authors.toList(), + tags: Set = this.tags, + isbn: String = this.isbn, + links: List = this.links, + titleLock: Boolean = this.titleLock, + summaryLock: Boolean = this.summaryLock, + numberLock: Boolean = this.numberLock, + numberSortLock: Boolean = this.numberSortLock, + releaseDateLock: Boolean = this.releaseDateLock, + authorsLock: Boolean = this.authorsLock, + tagsLock: Boolean = this.tagsLock, + isbnLock: Boolean = this.isbnLock, + linksLock: Boolean = this.linksLock, + bookId: String = this.bookId, + createdDate: LocalDateTime = this.createdDate, + lastModifiedDate: LocalDateTime = this.lastModifiedDate, + ) = BookMetadata( + title = title, + summary = summary, + number = number, + numberSort = numberSort, + releaseDate = releaseDate, + authors = authors, + tags = tags, + isbn = isbn, + links = links, + titleLock = titleLock, + summaryLock = summaryLock, + numberLock = numberLock, + numberSortLock = numberSortLock, + releaseDateLock = releaseDateLock, + authorsLock = authorsLock, + tagsLock = tagsLock, + isbnLock = isbnLock, + linksLock = linksLock, + bookId = bookId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) + + override fun toString(): String = "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', links=$links, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, isbnLock=$isbnLock, linksLock=$linksLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt new file mode 100644 index 000000000..4763ea276 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDate +import java.time.LocalDateTime + +data class BookMetadataAggregation( + val authors: List = emptyList(), + val tags: Set = emptySet(), + val releaseDate: LocalDate? = null, + val summary: String = "", + val summaryNumber: String = "", + val seriesId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt new file mode 100644 index 000000000..8c902773f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDate + +data class BookMetadataPatch( + val title: String? = null, + val summary: String? = null, + val number: String? = null, + val numberSort: Float? = null, + val releaseDate: LocalDate? = null, + val authors: List? = null, + val isbn: String? = null, + val links: List? = null, + val tags: Set? = null, + val readLists: List = emptyList(), +) { + data class ReadListEntry( + val name: String, + val number: Int? = null, + ) +} + +enum class BookMetadataPatchCapability { + TITLE, + SUMMARY, + NUMBER, + NUMBER_SORT, + RELEASE_DATE, + AUTHORS, + TAGS, + ISBN, + READ_LISTS, + THUMBNAILS, + LINKS, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt new file mode 100644 index 000000000..a7240365e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt @@ -0,0 +1,37 @@ +package org.gotson.komga.domain.model + +open class BookPage( + val fileName: String, + val mediaType: String, + val dimension: Dimension? = null, + val fileHash: String = "", + val fileSize: Long? = null, +) { + override fun toString(): String = "BookPage(fileName='$fileName', mediaType='$mediaType', dimension=$dimension, fileHash='$fileHash', fileSize=$fileSize)" + + fun copy( + fileName: String = this.fileName, + mediaType: String = this.mediaType, + dimension: Dimension? = this.dimension, + fileHash: String = this.fileHash, + fileSize: Long? = this.fileSize, + ) = BookPage( + fileName = fileName, + mediaType = mediaType, + dimension = dimension, + fileHash = fileHash, + fileSize = fileSize, + ) +} + +fun Collection.restoreHashFrom(restoreFrom: Collection): List = + this.map { newPage -> + restoreFrom + .find { + it.fileSize == newPage.fileSize && + it.mediaType == newPage.mediaType && + it.fileName == newPage.fileName && + it.fileHash.isNotBlank() + }?.let { newPage.copy(fileHash = it.fileHash) } + ?: newPage + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPageNumbered.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPageNumbered.kt new file mode 100644 index 000000000..284bfbfa7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPageNumbered.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.domain.model + +class BookPageNumbered( + fileName: String, + mediaType: String, + dimension: Dimension? = null, + fileHash: String = "", + fileSize: Long? = null, + val pageNumber: Int, +) : BookPage( + fileName = fileName, + mediaType = mediaType, + dimension = dimension, + fileHash = fileHash, + fileSize = fileSize, + ) { + override fun toString(): String = "BookPageNumbered(fileName='$fileName', mediaType='$mediaType', dimension=$dimension, fileHash='$fileHash', fileSize=$fileSize, pageNumber=$pageNumber)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt new file mode 100644 index 000000000..b6918831a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BookSearch( + val condition: SearchCondition.Book? = null, + val fullTextSearch: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt new file mode 100644 index 000000000..e4185d029 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class BookWithMedia( + val book: Book, + val media: Media, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt new file mode 100644 index 000000000..28479ccc4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt @@ -0,0 +1,40 @@ +package org.gotson.komga.domain.model + +import org.gotson.komga.language.lowerNotBlank + +class ContentRestrictions( + val ageRestriction: AgeRestriction? = null, + labelsAllow: Set = emptySet(), + labelsExclude: Set = emptySet(), +) { + val labelsAllow = + labelsAllow + .lowerNotBlank() + .toSet() + .minus(labelsExclude.lowerNotBlank().toSet()) + + val labelsExclude = labelsExclude.lowerNotBlank().toSet() + + @delegate:Transient + val isRestricted: Boolean by lazy { ageRestriction != null || labelsAllow.isNotEmpty() || labelsExclude.isNotEmpty() } + + override fun toString(): String = "ContentRestrictions(ageRestriction=$ageRestriction, labelsAllow=$labelsAllow, labelsExclude=$labelsExclude)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ContentRestrictions) return false + + if (ageRestriction != other.ageRestriction) return false + if (labelsAllow != other.labelsAllow) return false + if (labelsExclude != other.labelsExclude) return false + + return true + } + + override fun hashCode(): Int { + var result = ageRestriction?.hashCode() ?: 0 + result = 31 * result + labelsAllow.hashCode() + result = 31 * result + labelsExclude.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt new file mode 100644 index 000000000..f885b9de8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +enum class CopyMode { + MOVE, + COPY, + HARDLINK, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Dimension.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Dimension.kt new file mode 100644 index 000000000..de010e6ac --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Dimension.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class Dimension( + val width: Int, + val height: Int, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt new file mode 100644 index 000000000..47a281278 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt @@ -0,0 +1,135 @@ +package org.gotson.komga.domain.model + +import java.net.URL + +sealed class DomainEvent { + data class LibraryAdded( + val library: Library, + ) : DomainEvent() + + data class LibraryUpdated( + val library: Library, + ) : DomainEvent() + + data class LibraryDeleted( + val library: Library, + ) : DomainEvent() + + data class LibraryScanned( + val library: Library, + ) : DomainEvent() + + data class SeriesAdded( + val series: Series, + ) : DomainEvent() + + data class SeriesUpdated( + val series: Series, + ) : DomainEvent() + + data class SeriesDeleted( + val series: Series, + ) : DomainEvent() + + data class BookAdded( + val book: Book, + ) : DomainEvent() + + data class BookUpdated( + val book: Book, + ) : DomainEvent() + + data class BookDeleted( + val book: Book, + ) : DomainEvent() + + data class BookImported( + val book: Book?, + val sourceFile: URL, + val success: Boolean, + val message: String? = null, + ) : DomainEvent() + + data class CollectionAdded( + val collection: SeriesCollection, + ) : DomainEvent() + + data class CollectionUpdated( + val collection: SeriesCollection, + ) : DomainEvent() + + data class CollectionDeleted( + val collection: SeriesCollection, + ) : DomainEvent() + + data class ReadListAdded( + val readList: ReadList, + ) : DomainEvent() + + data class ReadListUpdated( + val readList: ReadList, + ) : DomainEvent() + + data class ReadListDeleted( + val readList: ReadList, + ) : DomainEvent() + + data class ReadProgressChanged( + val progress: ReadProgress, + ) : DomainEvent() + + data class ReadProgressDeleted( + val progress: ReadProgress, + ) : DomainEvent() + + data class ReadProgressSeriesChanged( + val seriesId: String, + val userId: String, + ) : DomainEvent() + + data class ReadProgressSeriesDeleted( + val seriesId: String, + val userId: String, + ) : DomainEvent() + + data class ThumbnailBookAdded( + val thumbnail: ThumbnailBook, + ) : DomainEvent() + + data class ThumbnailBookDeleted( + val thumbnail: ThumbnailBook, + ) : DomainEvent() + + data class ThumbnailSeriesAdded( + val thumbnail: ThumbnailSeries, + ) : DomainEvent() + + data class ThumbnailSeriesDeleted( + val thumbnail: ThumbnailSeries, + ) : DomainEvent() + + data class ThumbnailSeriesCollectionAdded( + val thumbnail: ThumbnailSeriesCollection, + ) : DomainEvent() + + data class ThumbnailSeriesCollectionDeleted( + val thumbnail: ThumbnailSeriesCollection, + ) : DomainEvent() + + data class ThumbnailReadListAdded( + val thumbnail: ThumbnailReadList, + ) : DomainEvent() + + data class ThumbnailReadListDeleted( + val thumbnail: ThumbnailReadList, + ) : DomainEvent() + + data class UserUpdated( + val user: KomgaUser, + val expireSession: Boolean, + ) : DomainEvent() + + data class UserDeleted( + val user: KomgaUser, + ) : DomainEvent() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/EpubTocEntry.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/EpubTocEntry.kt new file mode 100644 index 000000000..4c86bef39 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/EpubTocEntry.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +data class EpubTocEntry( + val title: String, + val href: String?, + val children: List = emptyList(), +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt new file mode 100644 index 000000000..631a32815 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt @@ -0,0 +1,68 @@ +package org.gotson.komga.domain.model + +open class CodedException : Exception { + val code: String + + constructor(cause: Throwable, code: String) : super(cause) { + this.code = code + } + + constructor(message: String, code: String) : super(message) { + this.code = code + } +} + +fun Exception.withCode(code: String) = CodedException(this, code) + +class MediaNotReadyException : Exception() + +class NoThumbnailFoundException : Exception() + +class MediaUnsupportedException( + message: String, + code: String = "", +) : CodedException(message, code) + +class ImageConversionException( + message: String, + code: String = "", +) : CodedException(message, code) + +class DirectoryNotFoundException( + message: String, + code: String = "", +) : CodedException(message, code) + +class DuplicateNameException( + message: String, + code: String = "", +) : CodedException(message, code) + +class PathContainedInPath( + message: String, + code: String = "", +) : CodedException(message, code) + +class UserEmailAlreadyExistsException( + message: String, + code: String = "", +) : CodedException(message, code) + +class BookConversionException( + message: String, +) : Exception(message) + +class ComicRackListException( + message: String, + code: String = "", +) : CodedException(message, code) + +class EntryNotFoundException( + message: String, +) : Exception(message) + +class ConfigurationException( + message: String, +) : Exception(message) + +class EntityNotFoundException : Exception() diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/FilterBy.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/FilterBy.kt new file mode 100644 index 000000000..5ac8bab13 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/FilterBy.kt @@ -0,0 +1,19 @@ +package org.gotson.komga.domain.model + +enum class FilterByEntity { + LIBRARY, + COLLECTION, + SERIES, + READLIST, +} + +data class FilterBy( + val type: FilterByEntity, + val ids: Set, +) + +enum class FilterTags { + SERIES, + BOOK, + BOTH, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt new file mode 100644 index 000000000..097302d4e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/HistoricalEvent.kt @@ -0,0 +1,93 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.nio.file.Path +import java.time.LocalDateTime + +sealed class HistoricalEvent( + val type: String, + val bookId: String? = null, + val seriesId: String? = null, + val properties: Map = emptyMap(), + val timestamp: LocalDateTime = LocalDateTime.now(), + val id: String = TsidCreator.getTsid256().toString(), +) { + class BookFileDeleted( + book: Book, + reason: String, + ) : HistoricalEvent( + type = "BookFileDeleted", + bookId = book.id, + seriesId = book.seriesId, + properties = + mapOf( + "reason" to reason, + "name" to book.path.toString(), + ), + ) + + class SeriesFolderDeleted( + seriesId: String, + seriesPath: Path, + reason: String, + ) : HistoricalEvent( + type = "SeriesFolderDeleted", + seriesId = seriesId, + properties = + mapOf( + "reason" to reason, + "name" to seriesPath.toString(), + ), + ) { + constructor(series: Series, reason: String) : this(series.id, series.path, reason) + } + + class BookConverted( + book: Book, + previous: Book, + ) : HistoricalEvent( + type = "BookConverted", + bookId = book.id, + seriesId = book.seriesId, + properties = + mapOf( + "name" to book.path.toString(), + "former file" to previous.path.toString(), + ), + ) + + class BookImported( + book: Book, + series: Series, + source: Path, + upgrade: Boolean, + ) : HistoricalEvent( + type = "BookImported", + bookId = book.id, + seriesId = series.id, + properties = + mapOf( + "name" to book.path.toString(), + "source" to source.toString(), + "upgrade" to if (upgrade) "Yes" else "No", + ), + ) + + class DuplicatePageDeleted( + book: Book, + page: BookPageNumbered, + ) : HistoricalEvent( + type = "DuplicatePageDeleted", + bookId = book.id, + seriesId = book.seriesId, + properties = + mapOf( + "name" to book.path.toString(), + "page number" to page.pageNumber.toString(), + "page file name" to page.fileName, + "page file hash" to page.fileHash, + "page file size" to page.fileSize.toString(), + "page media type" to page.mediaType, + ), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt new file mode 100644 index 000000000..6920a7043 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +data class KomgaSyncToken( + val version: Int = 1, + val rawKoboSyncToken: String = "", + /** + * Only if a sync is currently ongoing, else null. + */ + val ongoingSyncPointId: String? = null, + /** + * The last successful SyncPoint ID. + */ + val lastSuccessfulSyncPointId: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt new file mode 100644 index 000000000..6d5ef5293 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt @@ -0,0 +1,96 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import org.gotson.komga.language.lowerNotBlank +import java.time.LocalDateTime + +data class KomgaUser( + @Email(regexp = ".+@.+\\..+") + @NotBlank + val email: String, + @NotBlank + val password: String, + val roles: Set = setOf(UserRoles.FILE_DOWNLOAD, UserRoles.PAGE_STREAMING), + val sharedLibrariesIds: Set = emptySet(), + val sharedAllLibraries: Boolean = true, + val restrictions: ContentRestrictions = ContentRestrictions(), + val id: String = TsidCreator.getTsid256().toString(), + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + @delegate:Transient + val isAdmin: Boolean by lazy { + roles.contains(UserRoles.ADMIN) + } + + /** + * Return the list of LibraryIds this user is authorized to view, intersecting the provided list of LibraryIds. + * @param libraryIds an optional list of LibraryIds to filter on + * @return a list of authorised LibraryIds, or null if the user is authorized to see all libraries + */ + fun getAuthorizedLibraryIds(libraryIds: Collection?): Collection? = + when { + // limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries + !canAccessAllLibraries() && libraryIds != null -> libraryIds.intersect(sharedLibrariesIds) + + // limited user: filter on user's authorized libraries + !canAccessAllLibraries() && libraryIds == null -> sharedLibrariesIds + + // non-limited user & libraryIds are specified: filter on provided libraries + libraryIds != null -> libraryIds + + // non-limited user & no libraryIds specified: return null, meaning no filtering + else -> null + } + + fun canAccessAllLibraries(): Boolean = sharedAllLibraries || isAdmin + + fun canAccessLibrary(libraryId: String): Boolean = canAccessAllLibraries() || sharedLibrariesIds.any { it == libraryId } + + fun canAccessLibrary(library: Library): Boolean = canAccessAllLibraries() || sharedLibrariesIds.any { it == library.id } + + fun isContentAllowed( + ageRating: Int? = null, + sharingLabels: Set = emptySet(), + ): Boolean { + val labels = sharingLabels.lowerNotBlank().toSet() + + val ageAllowed = + if (restrictions.ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) + ageRating != null && ageRating <= restrictions.ageRestriction.age + else + null + + val labelAllowed = + if (restrictions.labelsAllow.isNotEmpty()) + restrictions.labelsAllow.intersect(labels).isNotEmpty() + else + null + + val allowed = + when { + ageAllowed == null -> labelAllowed != false + labelAllowed == null -> ageAllowed != false + else -> ageAllowed != false || labelAllowed != false + } + if (!allowed) return false + + val ageDenied = + if (restrictions.ageRestriction?.restriction == AllowExclude.EXCLUDE) + ageRating != null && ageRating >= restrictions.ageRestriction.age + else + false + + val labelDenied = + if (restrictions.labelsExclude.isNotEmpty()) + restrictions.labelsExclude.intersect(labels).isNotEmpty() + else + false + + return !ageDenied && !labelDenied + } + + override fun toString(): String = "KomgaUser(createdDate=$createdDate, email='$email', roles=$roles, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, restrictions=$restrictions, id='$id', lastModifiedDate=$lastModifiedDate)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt new file mode 100644 index 000000000..8ed2fd714 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -0,0 +1,61 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.io.path.toPath + +data class Library( + val name: String, + val root: URL, + val importComicInfoBook: Boolean = true, + val importComicInfoSeries: Boolean = true, + val importComicInfoCollection: Boolean = true, + val importComicInfoReadList: Boolean = true, + val importComicInfoSeriesAppendVolume: Boolean = true, + val importEpubBook: Boolean = true, + val importEpubSeries: Boolean = true, + val importMylarSeries: Boolean = true, + val importLocalArtwork: Boolean = true, + val importBarcodeIsbn: Boolean = true, + val scanForceModifiedTime: Boolean = false, + val scanOnStartup: Boolean = false, + val scanInterval: ScanInterval = ScanInterval.EVERY_6H, + val scanCbx: Boolean = true, + val scanPdf: Boolean = true, + val scanEpub: Boolean = true, + val scanDirectoryExclusions: Set = emptySet(), + val repairExtensions: Boolean = false, + val convertToCbz: Boolean = false, + val emptyTrashAfterScan: Boolean = false, + val seriesCover: SeriesCover = SeriesCover.FIRST, + val hashFiles: Boolean = true, + val hashPages: Boolean = false, + val hashKoreader: Boolean = false, + val analyzeDimensions: Boolean = true, + val oneshotsDirectory: String? = null, + val unavailableDate: LocalDateTime? = null, + val id: String = TsidCreator.getTsid256().toString(), + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + enum class SeriesCover { + FIRST, + FIRST_UNREAD_OR_FIRST, + FIRST_UNREAD_OR_LAST, + LAST, + } + + enum class ScanInterval { + DISABLED, + HOURLY, + EVERY_6H, + EVERY_12H, + DAILY, + WEEKLY, + } + + @delegate:Transient + val path: Path by lazy { this.root.toURI().toPath() } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt new file mode 100644 index 000000000..1ded0d881 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +enum class MarkSelectedPreference { + NO, + YES, + IF_NONE_OR_GENERATED, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt new file mode 100644 index 000000000..6947a7c8c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +data class Media( + val status: Status = Status.UNKNOWN, + val mediaType: String? = null, + val pages: List = emptyList(), + val pageCount: Int = pages.size, + val files: List = emptyList(), + val comment: String? = null, + val extension: MediaExtension? = null, + val bookId: String = "", + val epubDivinaCompatible: Boolean = false, + val epubIsKepub: Boolean = false, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + @delegate:Transient + val profile: MediaProfile? by lazy { MediaType.fromMediaType(mediaType)?.profile } + + enum class Status { + UNKNOWN, + ERROR, + READY, + UNSUPPORTED, + OUTDATED, + } + + override fun toString(): String = "Media(status=$status, mediaType=$mediaType, comment=$comment, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt new file mode 100644 index 000000000..294084e1f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.domain.model + +data class MediaContainerEntry( + val name: String, + val mediaType: String? = null, + val comment: String? = null, + val dimension: Dimension? = null, + val fileSize: Long? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt new file mode 100644 index 000000000..77dc3af3b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.domain.model + +import kotlin.reflect.KClass +import kotlin.reflect.full.isSubclassOf + +interface MediaExtension + +class ProxyExtension private constructor( + val extensionClassName: String, +) : MediaExtension { + companion object { + fun of(extensionClass: String?): ProxyExtension? = + extensionClass?.let { + val kClass = Class.forName(extensionClass).kotlin + if (kClass.qualifiedName != MediaExtension::class.qualifiedName && kClass.isSubclassOf(MediaExtension::class)) + ProxyExtension(extensionClass) + else + null + } + } + + inline fun proxyForType(): Boolean = T::class.qualifiedName == extensionClassName + + fun proxyForType(clazz: KClass): Boolean = clazz.qualifiedName == extensionClassName +} + +data class MediaExtensionEpub( + val toc: List = emptyList(), + val landmarks: List = emptyList(), + val pageList: List = emptyList(), + val isFixedLayout: Boolean = false, + val positions: List = emptyList(), +) : MediaExtension diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaFile.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaFile.kt new file mode 100644 index 000000000..fae7b91ba --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaFile.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.domain.model + +data class MediaFile( + val fileName: String, + val mediaType: String? = null, + val subType: SubType? = null, + val fileSize: Long? = null, +) { + enum class SubType { + EPUB_PAGE, + EPUB_ASSET, + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt new file mode 100644 index 000000000..48e131f1c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +enum class MediaProfile { + DIVINA, + PDF, + EPUB, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt new file mode 100644 index 000000000..842575427 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt @@ -0,0 +1,22 @@ +package org.gotson.komga.domain.model + +enum class MediaType( + val type: String, + val profile: MediaProfile, + val fileExtension: String, + val exportType: String = type, +) { + ZIP("application/zip", MediaProfile.DIVINA, "cbz", "application/vnd.comicbook+zip"), + RAR_GENERIC("application/x-rar-compressed", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"), + RAR_4("application/x-rar-compressed; version=4", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"), + RAR_5("application/x-rar-compressed; version=5", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"), + EPUB("application/epub+zip", MediaProfile.EPUB, "epub"), + PDF("application/pdf", MediaProfile.PDF, "pdf"), + ; + + companion object { + fun fromMediaType(mediaType: String?): MediaType? = entries.firstOrNull { it.type == mediaType } + + fun matchingMediaProfile(mediaProfile: MediaProfile): Collection = entries.filter { it.profile == mediaProfile } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MetadataPatchTarget.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MetadataPatchTarget.kt new file mode 100644 index 000000000..ac6ba2e49 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/MetadataPatchTarget.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +enum class MetadataPatchTarget { + BOOK, + SERIES, + READLIST, + COLLECTION, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHash.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHash.kt new file mode 100644 index 000000000..f12e00d50 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHash.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +open class PageHash( + val hash: String, + size: Long? = null, +) { + val size: Long? = if (size != null && size < 0) null else size +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashKnown.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashKnown.kt new file mode 100644 index 000000000..b427524fc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashKnown.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +class PageHashKnown( + hash: String, + size: Long? = null, + val action: Action, + val deleteCount: Int = 0, + val matchCount: Int = 0, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : PageHash(hash, size), + Auditable { + enum class Action { + DELETE_AUTO, + DELETE_MANUAL, + IGNORE, + } + + fun copy( + hash: String = this.hash, + size: Long? = this.size, + action: Action = this.action, + deleteCount: Int = this.deleteCount, + matchCount: Int = this.matchCount, + ) = PageHashKnown( + hash = hash, + size = size, + action = action, + deleteCount = deleteCount, + matchCount = matchCount, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashMatch.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashMatch.kt new file mode 100644 index 000000000..9ec490165 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashMatch.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.domain.model + +import java.net.URL + +data class PageHashMatch( + val bookId: String, + val url: URL, + val pageNumber: Int, + val fileName: String, + val fileSize: Long, + val mediaType: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashUnknown.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashUnknown.kt new file mode 100644 index 000000000..f03b636d9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/PageHashUnknown.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +class PageHashUnknown( + hash: String, + size: Long? = null, + val matchCount: Int = 0, +) : PageHash(hash, size) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt new file mode 100644 index 000000000..544315cba --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class R2Device( + val id: String, + val name: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt new file mode 100644 index 000000000..4d665fe6c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt @@ -0,0 +1,91 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonInclude + +/** + * Locators are meant to provide a precise location in a publication in a format that can be stored and shared. + * + * There are many different use cases for locators: + * - reporting and saving the current progression + * - bookmarks + * - highlights & annotations + * - search results + * - human-readable (as-in shareable) references + * - jumping to a location + * - enhancing a table of contents + * + * Each locator must contain a reference to a resource in a publication (href and type). href must not point to the fragment of a resource. + * + * It may also contain: + * - a title (`title`) + * - one or more locations in a resource (grouped together in locations) + * - one or more text references, if the resource is a document (`text`) + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class R2Locator( + /** + * The URI of the resource that the Locator Object points to. + */ + val href: String, + /** + * The media type of the resource that the Locator Object points to. + */ + val type: String, + /** + * The title of the chapter or section which is more relevant in the context of this locator. + */ + val title: String? = null, + /** + * One or more alternative expressions of the location. + */ + val locations: Location? = null, + /** + * Textual context of the locator. + */ + val text: Text? = null, + /** + * Komga specific, used to have a mapping between a [R2Locator] and a koboSpan + */ + val koboSpan: String? = null, +) { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Location( + /** + * Contains one or more fragment in the resource referenced by the Locator Object. + */ + val fragments: List = emptyList(), + /** + * Progression in the resource expressed as a percentage. + * Between 0 and 1. + */ + val progression: Float? = null, + /** + * An index in the publication. + */ + val position: Int? = null, + /** + * Progression in the publication expressed as a percentage. + * Between 0 and 1. + */ + val totalProgression: Float? = null, + ) + + /** + * A Locator Text Object contains multiple text fragments, useful to give a context to the Locator or for highlights. + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Text( + /** + * The text after the locator. + */ + val after: String? = null, + /** + * The text before the locator. + */ + val before: String? = null, + /** + * The text at the locator. + */ + val highlight: String? = null, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt new file mode 100644 index 000000000..eaaef05dc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.domain.model + +import org.gotson.komga.language.toUTCZoned +import java.time.ZonedDateTime + +data class R2Progression( + val modified: ZonedDateTime, + val device: R2Device, + val locator: R2Locator, +) + +fun ReadProgress.toR2Progression() = + R2Progression( + modified = readDate.toUTCZoned(), + device = R2Device(deviceId, deviceName), + locator = locator ?: R2Locator("", ""), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt new file mode 100644 index 000000000..7b6b6cc93 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadList.kt @@ -0,0 +1,22 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.time.LocalDateTime +import java.util.SortedMap + +data class ReadList( + val name: String, + val summary: String = "", + /** + * Indicates whether the read list is ordered manually + */ + val ordered: Boolean = true, + val bookIds: SortedMap = sortedMapOf(), + val id: String = TsidCreator.getTsid256().toString(), + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, + /** + * Indicates that the bookIds have been filtered and is not exhaustive. + */ + val filtered: Boolean = false, +) : Auditable diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt new file mode 100644 index 000000000..5330c62b7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDate + +/** + * Represents a request to create a reading list. + */ +data class ReadListRequest( + val name: String, + val books: List, +) + +data class ReadListRequestBook( + val series: Set, + val number: String, +) + +data class ReadListRequestMatch( + val readListMatch: ReadListMatch, + val requests: Collection, + val errorCode: String = "", +) + +data class ReadListMatch( + val name: String, + val errorCode: String = "", +) + +data class ReadListRequestBookMatches( + val request: ReadListRequestBook, + val matches: Map>, +) + +data class ReadListRequestBookMatchSeries( + val id: String, + val title: String, + val releaseDate: LocalDate?, +) + +data class ReadListRequestBookMatchBook( + val id: String, + val number: String, + val title: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt new file mode 100644 index 000000000..c588cda0a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDateTime + +data class ReadProgress( + val bookId: String, + val userId: String, + val page: Int, + val completed: Boolean, + val readDate: LocalDateTime = LocalDateTime.now(), + val deviceId: String = "", + val deviceName: String = "", + val locator: R2Locator? = null, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt new file mode 100644 index 000000000..e1c384313 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +enum class ReadStatus { + UNREAD, + READ, + IN_PROGRESS, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt new file mode 100644 index 000000000..47bd46035 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class ScanResult( + val series: Map>, + val sidecars: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt new file mode 100644 index 000000000..972d0d73f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt @@ -0,0 +1,218 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeInfo +import io.swagger.v3.oas.annotations.media.Schema + +class SearchCondition { + @Schema( + name = "SearchConditionBook", + oneOf = [AnyOfBook::class, AllOfBook::class, LibraryId::class, ReadListId::class, SeriesId::class, Deleted::class, OneShot::class, Title::class, ReleaseDate::class, Tag::class, NumberSort::class, ReadStatus::class, MediaStatus::class, MediaProfile::class, Author::class, Poster::class], + ) + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + sealed interface Book + + @Schema( + name = "SearchConditionSeries", + oneOf = [AnyOfSeries::class, AllOfSeries::class, LibraryId::class, CollectionId::class, Deleted::class, Complete::class, OneShot::class, Title::class, TitleSort::class, ReleaseDate::class, Tag::class, SharingLabel::class, Publisher::class, Language::class, Genre::class, AgeRating::class, ReadStatus::class, SeriesStatus::class, Author::class], + ) + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + sealed interface Series + + @Schema(name = "SearchConditionAnyOfBook") + data class AnyOfBook( + @JsonProperty("anyOf") + val conditions: List, + ) : Book { + constructor(vararg args: Book) : this(args.toList()) + } + + @Schema(name = "SearchConditionAllOfBook") + data class AllOfBook( + @JsonProperty("allOf") + val conditions: List, + ) : Book { + constructor(vararg args: Book) : this(args.toList()) + } + + @Schema(name = "SearchConditionAnyOfSeries") + data class AnyOfSeries( + @JsonProperty("anyOf") + val conditions: List, + ) : Series { + constructor(vararg args: Series) : this(args.toList()) + } + + @Schema(name = "SearchConditionAllOfSeries") + data class AllOfSeries( + @JsonProperty("allOf") + val conditions: List, + ) : Series { + constructor(vararg args: Series) : this(args.toList()) + } + + @Schema(name = "SearchConditionLibraryId") + data class LibraryId( + @JsonProperty("libraryId") + val operator: SearchOperator.Equality, + ) : Book, + Series + + @Schema(name = "SearchConditionCollectionId") + data class CollectionId( + @JsonProperty("collectionId") + val operator: SearchOperator.Equality, + ) : Series + + @Schema(name = "SearchConditionReadListId") + data class ReadListId( + @JsonProperty("readListId") + val operator: SearchOperator.Equality, + ) : Book + + @Schema(name = "SearchConditionSeriesId") + data class SeriesId( + @JsonProperty("seriesId") + val operator: SearchOperator.Equality, + ) : Book + + @Schema(name = "SearchConditionDeleted") + data class Deleted( + @JsonProperty("deleted") + val operator: SearchOperator.Boolean, + ) : Book, + Series + + @Schema(name = "SearchConditionComplete") + data class Complete( + @JsonProperty("complete") + val operator: SearchOperator.Boolean, + ) : Series + + @Schema(name = "SearchConditionOneShot") + data class OneShot( + @JsonProperty("oneShot") + val operator: SearchOperator.Boolean, + ) : Book, + Series + + @Schema(name = "SearchConditionTitle") + data class Title( + @JsonProperty("title") + val operator: SearchOperator.StringOp, + ) : Book, + Series + + @Schema(name = "SearchConditionTitleSort") + data class TitleSort( + @JsonProperty("titleSort") + val operator: SearchOperator.StringOp, + ) : Series + + @Schema(name = "SearchConditionReleaseDate") + data class ReleaseDate( + @JsonProperty("releaseDate") + val operator: SearchOperator.Date, + ) : Book, + Series + + @Schema(name = "SearchConditionNumberSort") + data class NumberSort( + @JsonProperty("numberSort") + val operator: SearchOperator.Numeric, + ) : Book + + @Schema(name = "SearchConditionTag") + data class Tag( + @JsonProperty("tag") + val operator: SearchOperator.EqualityNullable, + ) : Book, + Series + + @Schema(name = "SearchConditionSharingLabel") + data class SharingLabel( + @JsonProperty("sharingLabel") + val operator: SearchOperator.EqualityNullable, + ) : Series + + @Schema(name = "SearchConditionPublisher") + data class Publisher( + @JsonProperty("publisher") + val operator: SearchOperator.Equality, + ) : Series + + @Schema(name = "SearchConditionLanguage") + data class Language( + @JsonProperty("language") + val operator: SearchOperator.Equality, + ) : Series + + @Schema(name = "SearchConditionGenre") + data class Genre( + @JsonProperty("genre") + val operator: SearchOperator.EqualityNullable, + ) : Series + + @Schema(name = "SearchConditionAgeRating") + data class AgeRating( + @JsonProperty("ageRating") + val operator: SearchOperator.NumericNullable, + ) : Series + + @Schema(name = "SearchConditionReadStatus") + data class ReadStatus( + @JsonProperty("readStatus") + val operator: SearchOperator.Equality, + ) : Book, + Series + + @Schema(name = "SearchConditionMediaStatus") + data class MediaStatus( + @JsonProperty("mediaStatus") + val operator: SearchOperator.Equality, + ) : Book + + @Schema(name = "SearchConditionSeriesStatus") + data class SeriesStatus( + @JsonProperty("seriesStatus") + val operator: SearchOperator.Equality, + ) : Series + + @Schema(name = "SearchConditionMediaProfile") + data class MediaProfile( + @JsonProperty("mediaProfile") + val operator: SearchOperator.Equality, + ) : Book + + @Schema(name = "SearchConditionAuthor") + data class Author( + @JsonProperty("author") + val operator: SearchOperator.Equality, + ) : Book, + Series + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class AuthorMatch( + val name: String? = null, + val role: String? = null, + ) + + @Schema(name = "SearchConditionPoster") + data class Poster( + @JsonProperty("poster") + val operator: SearchOperator.Equality, + ) : Book + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class PosterMatch( + val type: Type? = null, + val selected: Boolean? = null, + ) { + enum class Type { + GENERATED, + SIDECAR, + USER_UPLOADED, + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchContext.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchContext.kt new file mode 100644 index 000000000..3ba01265a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchContext.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.model + +class SearchContext private constructor( + val userId: String?, + val restrictions: ContentRestrictions, + val libraryIds: Collection?, +) { + constructor(user: KomgaUser?) : this(user?.id, user?.restrictions ?: ContentRestrictions(), user?.getAuthorizedLibraryIds(null)) + + companion object { + fun empty() = SearchContext(null) + + fun ofAnonymousUser() = SearchContext("UNUSED", ContentRestrictions(), null) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchField.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchField.kt new file mode 100644 index 000000000..6bbfb5bc7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchField.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +@Deprecated("use SearchOperator.BeginsWith instead") +enum class SearchField { + TITLE, + TITLE_SORT, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchOperator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchOperator.kt new file mode 100644 index 000000000..974fa6bd2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchOperator.kt @@ -0,0 +1,278 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonTypeName +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Duration +import java.time.ZonedDateTime + +class SearchOperator { + @Schema( + name = "SearchOperatorEquality", + discriminatorProperty = "operator", + oneOf = [Is::class, IsNot::class], + discriminatorMapping = [ + DiscriminatorMapping("is", Is::class), + DiscriminatorMapping("isNot", IsNot::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface Equality + + @Schema( + name = "SearchOperatorEqualityNullable", + discriminatorProperty = "operator", + oneOf = [Is::class, IsNot::class, IsNullT::class, IsNotNullT::class], + discriminatorMapping = [ + DiscriminatorMapping("is", Is::class), + DiscriminatorMapping("isNot", IsNot::class), + DiscriminatorMapping("isNull", IsNullT::class), + DiscriminatorMapping("isNotNull", IsNotNullT::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface EqualityNullable + + @Schema( + name = "SearchOperatorString", + discriminatorProperty = "operator", + oneOf = [BeginsWith::class, DoesNotBeginWith::class, Contains::class, DoesNotContain::class, EndsWith::class, DoesNotEndWith::class, Is::class, IsNot::class], + discriminatorMapping = [ + DiscriminatorMapping("beginsWith", BeginsWith::class), + DiscriminatorMapping("doesNotBeginWith", DoesNotBeginWith::class), + DiscriminatorMapping("contains", Contains::class), + DiscriminatorMapping("doesNotContain", DoesNotContain::class), + DiscriminatorMapping("endsWith", EndsWith::class), + DiscriminatorMapping("doesNotEndWith", DoesNotEndWith::class), + DiscriminatorMapping("is", Is::class), + DiscriminatorMapping("isNot", IsNot::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface StringOp + + @Schema( + name = "SearchOperatorNumericT", + discriminatorProperty = "operator", + oneOf = [GreaterThan::class, LessThan::class, Is::class, IsNot::class], + discriminatorMapping = [ + DiscriminatorMapping("greaterThan", GreaterThan::class), + DiscriminatorMapping("lessThan", LessThan::class), + DiscriminatorMapping("is", Is::class), + DiscriminatorMapping("isNot", IsNot::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface Numeric + + @Schema( + name = "SearchOperatorNumericNullable", + discriminatorProperty = "operator", + oneOf = [GreaterThan::class, LessThan::class, IsNullT::class, IsNotNullT::class, Is::class, IsNot::class], + discriminatorMapping = [ + DiscriminatorMapping("greaterThan", GreaterThan::class), + DiscriminatorMapping("lessThan", LessThan::class), + DiscriminatorMapping("isNull", IsNullT::class), + DiscriminatorMapping("isNotNull", IsNotNullT::class), + DiscriminatorMapping("is", Is::class), + DiscriminatorMapping("isNot", IsNot::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface NumericNullable + + @Schema( + name = "SearchOperatorDate", + discriminatorProperty = "operator", + oneOf = [Before::class, After::class, IsInTheLast::class, IsNotInTheLast::class, IsNull::class, IsNotNull::class], + discriminatorMapping = [ + DiscriminatorMapping("before", Before::class), + DiscriminatorMapping("after", After::class), + DiscriminatorMapping("isInTheLast", IsInTheLast::class), + DiscriminatorMapping("isNotInTheLast", IsNotInTheLast::class), + DiscriminatorMapping("isNull", IsNull::class), + DiscriminatorMapping("isNotNull", IsNotNull::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface Date + + @Schema( + name = "SearchOperatorBoolean", + discriminatorProperty = "operator", + oneOf = [IsTrue::class, IsFalse::class], + discriminatorMapping = [ + DiscriminatorMapping("isTrue", IsTrue::class), + DiscriminatorMapping("isFalse", IsFalse::class), + ], + ) + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "operator", + ) + sealed interface Boolean + + @Schema(name = "SearchOperatorIs") + @JsonTypeName("is") + data class Is( + val value: T, + ) : Equality, + EqualityNullable, + StringOp, + Numeric, + NumericNullable + + @Schema(name = "SearchOperatorIsNot") + @JsonTypeName("isNot") + data class IsNot( + val value: T, + ) : Equality, + EqualityNullable, + StringOp, + Numeric, + NumericNullable + + @Schema(name = "SearchOperatorContains") + @JsonTypeName("contains") + data class Contains( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorDoesNotContain") + @JsonTypeName("doesNotContain") + data class DoesNotContain( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorBeginsWith") + @JsonTypeName("beginsWith") + data class BeginsWith( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorDoesNotBeginWith") + @JsonTypeName("doesNotBeginWith") + data class DoesNotBeginWith( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorEndsWith") + @JsonTypeName("endsWith") + data class EndsWith( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorDoesNotEndWith") + @JsonTypeName("doesNotEndWith") + data class DoesNotEndWith( + val value: String, + ) : StringOp + + @Schema(name = "SearchOperatorGreaterThan") + @JsonTypeName("greaterThan") + data class GreaterThan( + val value: T, + ) : Numeric, + NumericNullable + + @Schema(name = "SearchOperatorLessThan") + @JsonTypeName("lessThan") + data class LessThan( + val value: T, + ) : Numeric, + NumericNullable + + @Schema(name = "SearchOperatorBefore") + @JsonTypeName("before") + data class Before( + val dateTime: ZonedDateTime, + ) : Date + + @Schema(name = "SearchOperatorAfter") + @JsonTypeName("after") + data class After( + val dateTime: ZonedDateTime, + ) : Date + + @Schema(name = "SearchOperatorIsInTheLast") + @JsonTypeName("isInTheLast") + data class IsInTheLast( + val duration: Duration, + ) : Date + + @Schema(name = "SearchOperatorIsNotInTheLast") + @JsonTypeName("isNotInTheLast") + data class IsNotInTheLast( + val duration: Duration, + ) : Date + + @Schema(name = "SearchOperatorIsTrue") + @JsonTypeName("isTrue") + data object IsTrue : Boolean + + @Schema(name = "SearchOperatorIsFalse") + @JsonTypeName("isFalse") + data object IsFalse : Boolean + + @Schema(name = "SearchOperatorIsNull") + @JsonTypeName("isNull") + data object IsNull : Date + + @Schema(name = "SearchOperatorIsNotNull") + @JsonTypeName("isNotNull") + data object IsNotNull : Date + + @Schema(name = "SearchOperatorIsNullT") + @JsonTypeName("isNull") + class IsNullT : + NumericNullable, + EqualityNullable { + override fun equals(other: Any?): kotlin.Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int = javaClass.hashCode() + } + + @Schema(name = "SearchOperatorIsNotNullT") + @JsonTypeName("isNotNull") + class IsNotNullT : + NumericNullable, + EqualityNullable { + override fun equals(other: Any?): kotlin.Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int = javaClass.hashCode() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt new file mode 100644 index 000000000..5d794dfd9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.io.path.toPath + +data class Series( + val name: String, + val url: URL, + val fileLastModified: LocalDateTime, + val id: String = TsidCreator.getTsid256().toString(), + val libraryId: String = "", + val bookCount: Int = 0, + val deletedDate: LocalDateTime? = null, + val oneshot: Boolean = false, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + @delegate:Transient + val path: Path by lazy { this.url.toURI().toPath() } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt new file mode 100644 index 000000000..9ea2b0b52 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesCollection.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.time.LocalDateTime + +data class SeriesCollection( + val name: String, + /** + * Indicates whether the collection is ordered manually + */ + val ordered: Boolean = false, + val seriesIds: List = emptyList(), + val id: String = TsidCreator.getTsid256().toString(), + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, + /** + * Indicates that the seriesIds have been filtered and is not exhaustive. + */ + val filtered: Boolean = false, +) : Auditable diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt new file mode 100644 index 000000000..91dbf2996 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt @@ -0,0 +1,129 @@ +package org.gotson.komga.domain.model + +import org.gotson.komga.language.lowerNotBlank +import java.time.LocalDateTime + +class SeriesMetadata( + val status: Status = Status.ONGOING, + title: String, + titleSort: String = title, + summary: String = "", + val readingDirection: ReadingDirection? = null, + publisher: String = "", + val ageRating: Int? = null, + language: String = "", + genres: Set = emptySet(), + tags: Set = emptySet(), + val totalBookCount: Int? = null, + sharingLabels: Set = emptySet(), + val links: List = emptyList(), + val alternateTitles: List = emptyList(), + val statusLock: Boolean = false, + val titleLock: Boolean = false, + val titleSortLock: Boolean = false, + val summaryLock: Boolean = false, + val readingDirectionLock: Boolean = false, + val publisherLock: Boolean = false, + val ageRatingLock: Boolean = false, + val languageLock: Boolean = false, + val genresLock: Boolean = false, + val tagsLock: Boolean = false, + val totalBookCountLock: Boolean = false, + val sharingLabelsLock: Boolean = false, + val linksLock: Boolean = false, + val alternateTitlesLock: Boolean = false, + val seriesId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + val title = title.trim() + val titleSort = titleSort.trim() + val summary = summary.trim() + val publisher = publisher.trim() + val language = BCP47TagValidator.normalize(language.trim()) + val tags = tags.lowerNotBlank().toSet() + val genres = genres.lowerNotBlank().toSet() + val sharingLabels = sharingLabels.lowerNotBlank().toSet() + + fun copy( + status: Status = this.status, + title: String = this.title, + titleSort: String = this.titleSort, + summary: String = this.summary, + readingDirection: ReadingDirection? = this.readingDirection, + publisher: String = this.publisher, + ageRating: Int? = this.ageRating, + language: String = this.language, + genres: Set = this.genres, + tags: Set = this.tags, + totalBookCount: Int? = this.totalBookCount, + sharingLabels: Set = this.sharingLabels, + links: List = this.links, + alternateTitles: List = this.alternateTitles, + statusLock: Boolean = this.statusLock, + titleLock: Boolean = this.titleLock, + titleSortLock: Boolean = this.titleSortLock, + summaryLock: Boolean = this.summaryLock, + readingDirectionLock: Boolean = this.readingDirectionLock, + publisherLock: Boolean = this.publisherLock, + ageRatingLock: Boolean = this.ageRatingLock, + languageLock: Boolean = this.languageLock, + genresLock: Boolean = this.genresLock, + tagsLock: Boolean = this.tagsLock, + totalBookCountLock: Boolean = this.totalBookCountLock, + sharingLabelsLock: Boolean = this.sharingLabelsLock, + linksLock: Boolean = this.linksLock, + alternateTitlesLock: Boolean = this.alternateTitlesLock, + seriesId: String = this.seriesId, + createdDate: LocalDateTime = this.createdDate, + lastModifiedDate: LocalDateTime = this.lastModifiedDate, + ) = SeriesMetadata( + status = status, + title = title, + titleSort = titleSort, + summary = summary, + readingDirection = readingDirection, + publisher = publisher, + ageRating = ageRating, + language = language, + genres = genres, + tags = tags, + totalBookCount = totalBookCount, + sharingLabels = sharingLabels, + links = links, + alternateTitles = alternateTitles, + statusLock = statusLock, + titleLock = titleLock, + titleSortLock = titleSortLock, + summaryLock = summaryLock, + readingDirectionLock = readingDirectionLock, + publisherLock = publisherLock, + ageRatingLock = ageRatingLock, + languageLock = languageLock, + genresLock = genresLock, + tagsLock = tagsLock, + totalBookCountLock = totalBookCountLock, + sharingLabelsLock = sharingLabelsLock, + linksLock = linksLock, + alternateTitlesLock = alternateTitlesLock, + seriesId = seriesId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) + + enum class Status { + ENDED, + ONGOING, + ABANDONED, + HIATUS, + } + + enum class ReadingDirection { + LEFT_TO_RIGHT, + RIGHT_TO_LEFT, + VERTICAL, + WEBTOON, + } + + override fun toString(): String = "SeriesMetadata(status=$status, readingDirection=$readingDirection, ageRating=$ageRating, totalBookCount=$totalBookCount, links=$links, alternateTitles=$alternateTitles, statusLock=$statusLock, titleLock=$titleLock, titleSortLock=$titleSortLock, summaryLock=$summaryLock, readingDirectionLock=$readingDirectionLock, publisherLock=$publisherLock, ageRatingLock=$ageRatingLock, languageLock=$languageLock, genresLock=$genresLock, tagsLock=$tagsLock, totalBookCountLock=$totalBookCountLock, sharingLabelsLock=$sharingLabelsLock, linksLock=$linksLock, alternateTitlesLock=$alternateTitlesLock, seriesId='$seriesId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', titleSort='$titleSort', summary='$summary', publisher='$publisher', language='$language', tags=$tags, genres=$genres, sharingLabels=$sharingLabels)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt new file mode 100644 index 000000000..3fe3ff5d0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.model + +data class SeriesMetadataPatch( + val title: String?, + val titleSort: String?, + val status: SeriesMetadata.Status?, + val summary: String?, + val readingDirection: SeriesMetadata.ReadingDirection?, + val publisher: String?, + val ageRating: Int?, + val language: String?, + val genres: Set?, + val totalBookCount: Int?, + val collections: Set, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt new file mode 100644 index 000000000..fb69c3f0f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class SeriesSearch( + val condition: SearchCondition.Series? = null, + val fullTextSearch: String? = null, + @JsonIgnore + @Deprecated("Used for backward compatibility only, use SearchOperator.BeginsWith instead") + val regexSearch: Pair? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt new file mode 100644 index 000000000..5ffcc6402 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt @@ -0,0 +1,29 @@ +package org.gotson.komga.domain.model + +import java.net.URL +import java.time.LocalDateTime + +data class Sidecar( + val url: URL, + val parentUrl: URL, + val lastModifiedTime: LocalDateTime, + val type: Type, + val source: Source, +) { + enum class Type { + ARTWORK, + METADATA, + } + + enum class Source { + SERIES, + BOOK, + } +} + +data class SidecarStored( + val url: URL, + val parentUrl: URL, + val lastModifiedTime: LocalDateTime, + val libraryId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt new file mode 100644 index 000000000..9569eedba --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.domain.model + +import java.time.ZonedDateTime + +data class SyncPoint( + val id: String, + val userId: String, + val apiKeyId: String?, + val createdDate: ZonedDateTime, +) { + data class Book( + val syncPointId: String, + val bookId: String, + val createdDate: ZonedDateTime, + val lastModifiedDate: ZonedDateTime, + val fileLastModified: ZonedDateTime, + val fileSize: Long, + val fileHash: String, + val metadataLastModifiedDate: ZonedDateTime, + val thumbnailId: String?, + val synced: Boolean, + ) + + data class ReadList( + val syncPointId: String, + val readListId: String, + val readListName: String, + val createdDate: ZonedDateTime, + val lastModifiedDate: ZonedDateTime, + val synced: Boolean, + ) { + companion object { + const val ON_DECK_ID = "KOMGA-ONDECK" + } + + data class Book( + val syncPointId: String, + val readListId: String, + val bookId: String, + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt new file mode 100644 index 000000000..70ac7d807 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt @@ -0,0 +1,72 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.nio.file.Files +import java.nio.file.Paths +import java.time.LocalDateTime + +data class ThumbnailBook( + val thumbnail: ByteArray? = null, + val url: URL? = null, + val selected: Boolean = false, + val type: Type, + val mediaType: String, + val fileSize: Long, + val dimension: Dimension, + val id: String = TsidCreator.getTsid256().toString(), + val bookId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + enum class Type { + GENERATED, + SIDECAR, + USER_UPLOADED, + } + + fun exists(): Boolean { + if (url != null) return Files.exists(Paths.get(url.toURI())) + return thumbnail != null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ThumbnailBook + + if (thumbnail != null) { + if (other.thumbnail == null) return false + if (!thumbnail.contentEquals(other.thumbnail)) return false + } else if (other.thumbnail != null) + return false + if (url != other.url) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (mediaType != other.mediaType) return false + if (fileSize != other.fileSize) return false + if (dimension != other.dimension) return false + if (id != other.id) return false + if (bookId != other.bookId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail?.contentHashCode() ?: 0 + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + mediaType.hashCode() + result = 31 * result + fileSize.hashCode() + result = 31 * result + dimension.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + bookId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt new file mode 100644 index 000000000..f50136970 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt @@ -0,0 +1,55 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.time.LocalDateTime + +data class ThumbnailReadList( + val thumbnail: ByteArray, + val selected: Boolean = false, + val type: Type, + val mediaType: String, + val fileSize: Long, + val dimension: Dimension, + val id: String = TsidCreator.getTsid256().toString(), + val readListId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + enum class Type { + USER_UPLOADED, + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ThumbnailReadList + + if (!thumbnail.contentEquals(other.thumbnail)) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (mediaType != other.mediaType) return false + if (fileSize != other.fileSize) return false + if (dimension != other.dimension) return false + if (id != other.id) return false + if (readListId != other.readListId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail.contentHashCode() + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + mediaType.hashCode() + result = 31 * result + fileSize.hashCode() + result = 31 * result + dimension.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + readListId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt new file mode 100644 index 000000000..71916ee76 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt @@ -0,0 +1,71 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.nio.file.Files +import java.nio.file.Paths +import java.time.LocalDateTime + +data class ThumbnailSeries( + val thumbnail: ByteArray? = null, + val url: URL? = null, + val selected: Boolean = false, + val type: Type, + val mediaType: String, + val fileSize: Long, + val dimension: Dimension, + val id: String = TsidCreator.getTsid256().toString(), + val seriesId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + enum class Type { + SIDECAR, + USER_UPLOADED, + } + + fun exists(): Boolean { + if (url != null) return Files.exists(Paths.get(url.toURI())) + return thumbnail != null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ThumbnailSeries + + if (thumbnail != null) { + if (other.thumbnail == null) return false + if (!thumbnail.contentEquals(other.thumbnail)) return false + } else if (other.thumbnail != null) + return false + if (url != other.url) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (mediaType != other.mediaType) return false + if (fileSize != other.fileSize) return false + if (dimension != other.dimension) return false + if (id != other.id) return false + if (seriesId != other.seriesId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail?.contentHashCode() ?: 0 + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + mediaType.hashCode() + result = 31 * result + fileSize.hashCode() + result = 31 * result + dimension.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + seriesId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt new file mode 100644 index 000000000..8048a9c4d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt @@ -0,0 +1,55 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.time.LocalDateTime + +data class ThumbnailSeriesCollection( + val thumbnail: ByteArray, + val selected: Boolean = false, + val type: Type, + val mediaType: String, + val fileSize: Long, + val dimension: Dimension, + val id: String = TsidCreator.getTsid256().toString(), + val collectionId: String = "", + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable { + enum class Type { + USER_UPLOADED, + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ThumbnailSeriesCollection + + if (!thumbnail.contentEquals(other.thumbnail)) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (mediaType != other.mediaType) return false + if (fileSize != other.fileSize) return false + if (dimension != other.dimension) return false + if (id != other.id) return false + if (collectionId != other.collectionId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail.contentHashCode() + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + mediaType.hashCode() + result = 31 * result + fileSize.hashCode() + result = 31 * result + dimension.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + collectionId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt new file mode 100644 index 000000000..e65c72de2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.domain.model + +enum class ThumbnailSize( + val maxEdge: Int, +) { + DEFAULT(300), + MEDIUM(600), + LARGE(900), + XLARGE(1200), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TransientBook.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TransientBook.kt new file mode 100644 index 000000000..b38587570 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TransientBook.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +data class TransientBook( + val book: Book, + val media: Media, + val metadata: Metadata = Metadata(), +) { + data class Metadata( + val number: Float? = null, + val seriesId: String? = null, + ) +} + +fun TransientBook.toBookWithMedia() = BookWithMedia(book, media) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TypedBytes.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TypedBytes.kt new file mode 100644 index 000000000..60518dec7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/TypedBytes.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +class TypedBytes( + val bytes: ByteArray, + val mediaType: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/UserRoles.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/UserRoles.kt new file mode 100644 index 000000000..8d883bc8e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/UserRoles.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.domain.model + +enum class UserRoles { + ADMIN, + FILE_DOWNLOAD, + PAGE_STREAMING, + KOBO_SYNC, + KOREADER_SYNC, + ; + + companion object { + /** + * Returns a Set composed of the enum constant of this type with the specified name. + * The string must match exactly an identifier used to declare an enum constant in this type. + * (Extraneous whitespace characters are not permitted.) + */ + fun valuesOf(roles: Iterable): Set = + roles + .mapNotNull { + try { + valueOf(it) + } catch (_: Exception) { + null + } + }.toSet() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/WebLink.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/WebLink.kt new file mode 100644 index 000000000..ea87b828b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/model/WebLink.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +import java.net.URI + +data class WebLink( + val label: String, + val url: URI, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt new file mode 100644 index 000000000..ca9068cd9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.AuthenticationActivity +import org.gotson.komga.domain.model.KomgaUser +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime + +interface AuthenticationActivityRepository { + fun findAll(pageable: Pageable): Page + + fun findAllByUser( + user: KomgaUser, + pageable: Pageable, + ): Page + + fun findMostRecentByUser( + user: KomgaUser, + apiKeyId: String?, + ): AuthenticationActivity? + + fun insert(activity: AuthenticationActivity) + + fun deleteByUser(user: KomgaUser) + + fun deleteOlderThan(dateTime: LocalDateTime) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataAggregationRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataAggregationRepository.kt new file mode 100644 index 000000000..bab6c8fa3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataAggregationRepository.kt @@ -0,0 +1,19 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookMetadataAggregation + +interface BookMetadataAggregationRepository { + fun findById(seriesId: String): BookMetadataAggregation + + fun findByIdOrNull(seriesId: String): BookMetadataAggregation? + + fun insert(metadata: BookMetadataAggregation) + + fun update(metadata: BookMetadataAggregation) + + fun delete(seriesId: String) + + fun delete(seriesIds: Collection) + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt new file mode 100644 index 000000000..ca13ddac6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookMetadata + +interface BookMetadataRepository { + fun findById(bookId: String): BookMetadata + + fun findByIdOrNull(bookId: String): BookMetadata? + + fun findAllByIds(bookIds: Collection): Collection + + fun insert(metadata: BookMetadata) + + fun insert(metadatas: Collection) + + fun update(metadata: BookMetadata) + + fun update(metadatas: Collection) + + fun delete(bookId: String) + + fun delete(bookIds: Collection) + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt new file mode 100644 index 000000000..fa73e80ce --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -0,0 +1,93 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.math.BigDecimal +import java.net.URL + +interface BookRepository { + fun findByIdOrNull(bookId: String): Book? + + fun findNotDeletedByLibraryIdAndUrlOrNull( + libraryId: String, + url: URL, + ): Book? + + fun findAll(): Collection + + fun findAllBySeriesId(seriesId: String): Collection + + fun findAllBySeriesIds(seriesIds: Collection): Collection + + fun findAllNotDeletedByLibraryIdAndUrlNotIn( + libraryId: String, + urls: Collection, + ): Collection + + fun findAll( + searchCondition: SearchCondition.Book?, + searchContext: SearchContext, + pageable: Pageable, + ): Page + + fun findAllDeletedByFileSize(fileSize: Long): Collection + + fun findAllByLibraryIdAndWithEmptyHash(libraryId: String): Collection + + fun findAllByLibraryIdAndWithEmptyHashKoreader(libraryId: String): Collection + + fun findAllByHashKoreader(hashKoreader: String): Collection + + fun findAllByLibraryIdAndMediaTypes( + libraryId: String, + mediaTypes: Collection, + ): Collection + + fun findAllByLibraryIdAndMismatchedExtension( + libraryId: String, + mediaType: String, + extension: String, + ): Collection + + fun getLibraryIdOrNull(bookId: String): String? + + fun getSeriesIdOrNull(bookId: String): String? + + fun findFirstIdInSeriesOrNull(seriesId: String): String? + + fun findLastIdInSeriesOrNull(seriesId: String): String? + + fun findFirstUnreadIdInSeriesOrNull( + seriesId: String, + userId: String, + ): String? + + fun findAllIdsBySeriesId(seriesId: String): Collection + + fun findAllIdsByLibraryId(libraryId: String): Collection + + fun existsById(bookId: String): Boolean + + fun insert(book: Book) + + fun insert(books: Collection) + + fun update(book: Book) + + fun update(books: Collection) + + fun delete(bookId: String) + + fun delete(bookIds: Collection) + + fun deleteAll() + + fun count(): Long + + fun countGroupedByLibraryId(): Map + + fun getFilesizeGroupedByLibraryId(): Map +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt new file mode 100644 index 000000000..113b6a867 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/HistoricalEventRepository.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.HistoricalEvent + +interface HistoricalEventRepository { + fun insert(event: HistoricalEvent) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt new file mode 100644 index 000000000..1c2a48be6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ApiKey +import org.gotson.komga.domain.model.KomgaUser + +interface KomgaUserRepository { + fun count(): Long + + fun findByIdOrNull(id: String): KomgaUser? + + fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? + + fun findByApiKeyOrNull(apiKey: String): Pair? + + fun findAll(): Collection + + fun findApiKeyByUserId(userId: String): Collection + + fun existsByEmailIgnoreCase(email: String): Boolean + + fun existsApiKeyByIdAndUserId( + apiKeyId: String, + userId: String, + ): Boolean + + fun existsApiKeyByCommentAndUserId( + comment: String, + userId: String, + ): Boolean + + fun insert(user: KomgaUser) + + fun insert(apiKey: ApiKey) + + fun update(user: KomgaUser) + + fun delete(userId: String) + + fun deleteAll() + + fun deleteApiKeyByIdAndUserId( + apiKeyId: String, + userId: String, + ) + + fun deleteApiKeyByUserId(userId: String) + + fun findAnnouncementIdsReadByUserId(userId: String): Set + + fun saveAnnouncementIdsRead( + user: KomgaUser, + announcementIds: Set, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt new file mode 100644 index 000000000..d6d8d2135 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Library + +interface LibraryRepository { + fun findById(libraryId: String): Library + + fun findByIdOrNull(libraryId: String): Library? + + fun findAll(): Collection + + fun findAllByIds(libraryIds: Collection): Collection + + fun delete(libraryId: String) + + fun deleteAll() + + fun insert(library: Library) + + fun update(library: Library) + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt new file mode 100644 index 000000000..5511e4eb3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt @@ -0,0 +1,40 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtension + +interface MediaRepository { + fun findById(bookId: String): Media + + fun findByIdOrNull(bookId: String): Media? + + fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash( + libraryId: String, + mediaTypes: Collection, + pageHashing: Int, + ): Collection + + fun getPagesSizes(bookIds: Collection): Collection> + + fun findExtensionByIdOrNull(bookId: String): MediaExtension? + + fun insert(media: Media) + + fun insert(medias: Collection) + + fun update(media: Media) + + /** + * Performs a full copy of a Media, including MediaExtension, pages, and files. + */ + fun copy( + fromBookId: String, + toBookId: String, + ) + + fun delete(bookId: String) + + fun delete(bookIds: Collection) + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/PageHashRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/PageHashRepository.kt new file mode 100644 index 000000000..429c3b631 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/PageHashRepository.kt @@ -0,0 +1,38 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.domain.model.PageHashMatch +import org.gotson.komga.domain.model.PageHashUnknown +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface PageHashRepository { + fun findKnown(pageHash: String): PageHashKnown? + + fun findAllKnown( + actions: List?, + pageable: Pageable, + ): Page + + fun findAllUnknown(pageable: Pageable): Page + + fun findMatchesByHash( + pageHash: String, + pageable: Pageable, + ): Page + + fun findMatchesByKnownHashAction( + actions: List?, + libraryId: String?, + ): Map> + + fun getKnownThumbnail(pageHash: String): ByteArray? + + fun insert( + pageHash: PageHashKnown, + thumbnail: ByteArray?, + ) + + fun update(pageHash: PageHashKnown) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt new file mode 100644 index 000000000..ce687fd13 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt @@ -0,0 +1,63 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.ReadList +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface ReadListRepository { + /** + * Find one ReadList by [readListId], + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] it not null. + */ + fun findByIdOrNull( + readListId: String, + filterOnLibraryIds: Collection? = null, + restrictions: ContentRestrictions = ContentRestrictions(), + ): ReadList? + + /** + * Find all ReadList + * optionally with at least one Book belonging to the provided [belongsToLibraryIds] if not null, + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findAll( + belongsToLibraryIds: Collection? = null, + filterOnLibraryIds: Collection? = null, + search: String? = null, + pageable: Pageable, + restrictions: ContentRestrictions = ContentRestrictions(), + ): Page + + /** + * Find all ReadList that contains the provided [containsBookId], + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findAllContainingBookId( + containsBookId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): Collection + + fun findAllEmpty(): Collection + + fun findByNameOrNull(name: String): ReadList? + + fun insert(readList: ReadList) + + fun update(readList: ReadList) + + fun removeBookFromAll(bookId: String) + + fun removeBooksFromAll(bookIds: Collection) + + fun delete(readListId: String) + + fun delete(readListIds: Collection) + + fun deleteAll() + + fun existsByName(name: String): Boolean + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRequestRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRequestRepository.kt new file mode 100644 index 000000000..f5de789ca --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRequestRepository.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ReadListRequestBook +import org.gotson.komga.domain.model.ReadListRequestBookMatches + +interface ReadListRequestRepository { + fun matchBookRequests(requests: Collection): Collection +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt new file mode 100644 index 000000000..9456a3a86 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ReadProgress + +interface ReadProgressRepository { + fun findByBookIdAndUserIdOrNull( + bookId: String, + userId: String, + ): ReadProgress? + + fun findAll(): Collection + + fun findAllByUserId(userId: String): Collection + + fun findAllByBookId(bookId: String): Collection + + fun findAllByBookIdsAndUserId( + bookIds: Collection, + userId: String, + ): Collection + + fun save(readProgress: ReadProgress) + + fun save(readProgresses: Collection) + + fun delete( + bookId: String, + userId: String, + ) + + fun deleteByUserId(userId: String) + + fun deleteByBookId(bookId: String) + + fun deleteByBookIds(bookIds: Collection) + + fun deleteByBookIdsAndUserId( + bookIds: Collection, + userId: String, + ) + + fun deleteBySeriesIds(seriesIds: Collection) + + fun deleteAll() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt new file mode 100644 index 000000000..a5e977b2c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt @@ -0,0 +1,258 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.FilterBy +import org.gotson.komga.domain.model.FilterTags +import org.gotson.komga.domain.model.SearchContext +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.time.LocalDate + +interface ReferentialRepository { + @Deprecated("Use findAuthors instead") + fun findAllAuthorsByName( + search: String, + filterOnLibraryIds: Collection?, + ): List + + @Deprecated("Use findAuthors instead") + fun findAllAuthorsByNameAndLibrary( + search: String, + libraryId: String, + filterOnLibraryIds: Collection?, + ): List + + @Deprecated("Use findAuthors instead") + fun findAllAuthorsByNameAndCollection( + search: String, + collectionId: String, + filterOnLibraryIds: Collection?, + ): List + + @Deprecated("Use findAuthors instead") + fun findAllAuthorsByNameAndSeries( + search: String, + seriesId: String, + filterOnLibraryIds: Collection?, + ): List + + @Deprecated("Use findAuthorsNames instead") + fun findAllAuthorsNamesByName( + search: String, + filterOnLibraryIds: Collection?, + ): List + + @Deprecated("Use findAuthorsRoles instead") + fun findAllAuthorsRoles(filterOnLibraryIds: Collection?): List + + fun findAuthors( + context: SearchContext, + search: String?, + role: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + fun findAuthorsRoles( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + fun findAuthorsNames( + context: SearchContext, + search: String?, + role: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findGenres instead") + fun findAllGenres(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findGenres instead") + fun findAllGenresByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findGenres instead") + fun findAllGenresByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findGenres( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findTags instead") + fun findAllSeriesAndBookTags(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findTags instead") + fun findAllSeriesAndBookTagsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findTags instead") + fun findAllSeriesAndBookTagsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findTags instead") + fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findTags instead") + fun findAllSeriesTagsByLibrary( + libraryId: String, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findTags instead") + fun findAllSeriesTagsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findTags instead") + fun findAllBookTags(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findTags instead") + fun findAllBookTagsBySeries( + seriesId: String, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findTags instead") + fun findAllBookTagsByReadList( + readListId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findTags( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + filterTags: FilterTags, + pageable: Pageable, + ): Page + + @Deprecated("Use findLanguages instead") + fun findAllLanguages(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findLanguages instead") + fun findAllLanguagesByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findLanguages instead") + fun findAllLanguagesByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findLanguages( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findPublishers instead") + fun findAllPublishers(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findPublishers instead") + fun findAllPublishers( + filterOnLibraryIds: Collection?, + pageable: Pageable, + ): Page + + @Deprecated("Use findPublishers instead") + fun findAllPublishersByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findPublishers instead") + fun findAllPublishersByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findPublishers( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findAgeRatings instead") + fun findAllAgeRatings(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findAgeRatings instead") + fun findAllAgeRatingsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findAgeRatings instead") + fun findAllAgeRatingsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findAgeRatings( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findSeriesReleaseDates instead") + fun findAllSeriesReleaseDates(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findSeriesReleaseDates instead") + fun findAllSeriesReleaseDatesByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findSeriesReleaseDates instead") + fun findAllSeriesReleaseDatesByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findSeriesReleaseYears( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page + + @Deprecated("Use findSharingLabels instead") + fun findAllSharingLabels(filterOnLibraryIds: Collection?): Set + + @Deprecated("Use findSharingLabels instead") + fun findAllSharingLabelsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + @Deprecated("Use findSharingLabels instead") + fun findAllSharingLabelsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + + fun findSharingLabels( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt new file mode 100644 index 000000000..2d9b4d5e6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt @@ -0,0 +1,63 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.SeriesCollection +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface SeriesCollectionRepository { + /** + * Find one SeriesCollection by [collectionId], + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findByIdOrNull( + collectionId: String, + filterOnLibraryIds: Collection? = null, + restrictions: ContentRestrictions = ContentRestrictions(), + ): SeriesCollection? + + /** + * Find all SeriesCollection + * optionally with at least one Series belonging to the provided [belongsToLibraryIds] if not null, + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findAll( + belongsToLibraryIds: Collection? = null, + filterOnLibraryIds: Collection? = null, + search: String? = null, + pageable: Pageable, + restrictions: ContentRestrictions = ContentRestrictions(), + ): Page + + /** + * Find all SeriesCollection that contains the provided [containsSeriesId], + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findAllContainingSeriesId( + containsSeriesId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): Collection + + fun findAllEmpty(): Collection + + fun findByNameOrNull(name: String): SeriesCollection? + + fun insert(collection: SeriesCollection) + + fun update(collection: SeriesCollection) + + fun removeSeriesFromAll(seriesId: String) + + fun removeSeriesFromAll(seriesIds: Collection) + + fun delete(collectionId: String) + + fun delete(collectionIds: Collection) + + fun deleteAll() + + fun existsByName(name: String): Boolean + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt new file mode 100644 index 000000000..a7895ead0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt @@ -0,0 +1,19 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.SeriesMetadata + +interface SeriesMetadataRepository { + fun findById(seriesId: String): SeriesMetadata + + fun findByIdOrNull(seriesId: String): SeriesMetadata? + + fun insert(metadata: SeriesMetadata) + + fun update(metadata: SeriesMetadata) + + fun delete(seriesId: String) + + fun delete(seriesIds: Collection) + + fun count(): Long +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt new file mode 100644 index 000000000..45a8d1490 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt @@ -0,0 +1,55 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.Series +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.net.URL + +interface SeriesRepository { + fun findByIdOrNull(seriesId: String): Series? + + fun findNotDeletedByLibraryIdAndUrlOrNull( + libraryId: String, + url: URL, + ): Series? + + fun findAll(): Collection + + fun findAllByLibraryId(libraryId: String): Collection + + fun findAllNotDeletedByLibraryIdAndUrlNotIn( + libraryId: String, + urls: Collection, + ): Collection + + fun findAllByTitleContaining(title: String): Collection + + fun findAll( + searchCondition: SearchCondition.Series?, + searchContext: SearchContext, + pageable: Pageable, + ): Page + + fun getLibraryId(seriesId: String): String? + + fun findAllIdsByLibraryId(libraryId: String): Collection + + fun insert(series: Series) + + fun update( + series: Series, + updateModifiedTime: Boolean = true, + ) + + fun delete(seriesId: String) + + fun delete(seriesIds: Collection) + + fun deleteAll() + + fun count(): Long + + fun countGroupedByLibraryId(): Map +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt new file mode 100644 index 000000000..4f5a48362 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.SidecarStored +import java.net.URL + +interface SidecarRepository { + fun findAll(): Collection + + fun save( + libraryId: String, + sidecar: Sidecar, + ) + + fun deleteByLibraryIdAndUrls( + libraryId: String, + urls: Collection, + ) + + fun deleteByLibraryId(libraryId: String) + + fun countGroupedByLibraryId(): Map +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt new file mode 100644 index 000000000..e820ee13c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt @@ -0,0 +1,112 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SyncPoint +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface SyncPointRepository { + fun create( + apiKeyId: String?, + search: BookSearch, + context: SearchContext, + ): SyncPoint + + fun addOnDeck( + syncPointId: String, + context: SearchContext, + filterOnLibraryIds: List?, + ) + + fun findByIdOrNull(syncPointId: String): SyncPoint? + + fun findBooksById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findReadListsById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findReadListsAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findReadListsChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findReadListsRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBookIdsByReadListIds( + syncPointId: String, + readListIds: Collection, + ): List + + fun markBooksSynced( + syncPointId: String, + forRemovedBooks: Boolean, + bookIds: Collection, + ) + + fun markReadListsSynced( + syncPointId: String, + forRemovedReadLists: Boolean, + readListIds: Collection, + ) + + fun deleteByUserId(userId: String) + + fun deleteByUserIdAndApiKeyIds( + userId: String, + apiKeyIds: Collection, + ) + + fun deleteOne(syncPointId: String) + + fun deleteAll() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt new file mode 100644 index 000000000..5e3129fd1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailBook + +interface ThumbnailBookRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailBook? + + fun findSelectedByBookIdOrNull(bookId: String): ThumbnailBook? + + fun findAllByBookId(bookId: String): Collection + + fun findAllByBookIdAndType( + bookId: String, + type: Set, + ): Collection + + fun findAllBookIdsByThumbnailTypeAndDimensionSmallerThan( + type: ThumbnailBook.Type, + size: Int, + ): Collection + + fun existsById(thumbnailId: String): Boolean + + fun getLibraryIdOrNull(thumbnailId: String): String? + + fun getSeriesIdOrNull(thumbnailId: String): String? + + fun insert(thumbnail: ThumbnailBook) + + fun update(thumbnail: ThumbnailBook) + + fun markSelected(thumbnail: ThumbnailBook) + + fun delete(thumbnailBookId: String) + + fun deleteByBookId(bookId: String) + + fun deleteByBookIdAndType( + bookId: String, + type: ThumbnailBook.Type, + ) + + fun deleteByBookIds(bookIds: Collection) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt new file mode 100644 index 000000000..f01157f99 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailReadList + +interface ThumbnailReadListRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailReadList? + + fun findSelectedByReadListIdOrNull(readListId: String): ThumbnailReadList? + + fun findAllByReadListId(readListId: String): Collection + + fun insert(thumbnail: ThumbnailReadList) + + fun update(thumbnail: ThumbnailReadList) + + fun markSelected(thumbnail: ThumbnailReadList) + + fun delete(thumbnailReadListId: String) + + fun deleteByReadListId(readListId: String) + + fun deleteByReadListIds(readListIds: Collection) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt new file mode 100644 index 000000000..31d56eabb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailSeriesCollection + +interface ThumbnailSeriesCollectionRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailSeriesCollection? + + fun findSelectedByCollectionIdOrNull(collectionId: String): ThumbnailSeriesCollection? + + fun findAllByCollectionId(collectionId: String): Collection + + fun insert(thumbnail: ThumbnailSeriesCollection) + + fun update(thumbnail: ThumbnailSeriesCollection) + + fun markSelected(thumbnail: ThumbnailSeriesCollection) + + fun delete(thumbnailCollectionId: String) + + fun deleteByCollectionId(collectionId: String) + + fun deleteByCollectionIds(collectionIds: Collection) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt new file mode 100644 index 000000000..81b874173 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt @@ -0,0 +1,32 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailSeries + +interface ThumbnailSeriesRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailSeries? + + fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries? + + fun findAllBySeriesId(seriesId: String): Collection + + fun findAllBySeriesIdIdAndType( + seriesId: String, + type: ThumbnailSeries.Type, + ): Collection + + fun getLibraryIdOrNull(thumbnailId: String): String? + + fun getSeriesIdOrNull(thumbnailId: String): String? + + fun insert(thumbnail: ThumbnailSeries) + + fun update(thumbnail: ThumbnailSeries) + + fun markSelected(thumbnail: ThumbnailSeries) + + fun delete(thumbnailSeriesId: String) + + fun deleteBySeriesId(seriesId: String) + + fun deleteBySeriesIds(seriesIds: Collection) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt new file mode 100644 index 000000000..55cf1faec --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.TransientBook + +interface TransientBookRepository { + fun findByIdOrNull(transientBookId: String): TransientBook? + + fun save(transientBook: TransientBook) + + fun save(transientBooks: Collection) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt new file mode 100644 index 000000000..bfffb679c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -0,0 +1,423 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.model.NoThumbnailFoundException +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.mediacontainer.divina.DivinaExtractor +import org.gotson.komga.infrastructure.mediacontainer.epub.EpubExtractor +import org.gotson.komga.infrastructure.mediacontainer.epub.epub +import org.gotson.komga.infrastructure.mediacontainer.pdf.PdfExtractor +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.io.ByteArrayOutputStream +import java.nio.file.AccessDeniedException +import java.nio.file.NoSuchFileException +import javax.imageio.ImageIO +import kotlin.io.path.extension + +private val logger = KotlinLogging.logger {} + +@Service +class BookAnalyzer( + private val contentDetector: ContentDetector, + extractors: List, + private val pdfExtractor: PdfExtractor, + private val epubExtractor: EpubExtractor, + private val imageConverter: ImageConverter, + private val imageAnalyzer: ImageAnalyzer, + private val hasher: Hasher, + @param:Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int, + private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("thumbnailType") + private val thumbnailType: ImageType, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, +) { + val divinaExtractors = + extractors + .flatMap { e -> e.mediaTypes().map { it to e } } + .toMap() + + fun analyze( + book: Book, + analyzeDimensions: Boolean, + ): Media { + logger.info { "Trying to analyze book: $book" } + return try { + var mediaType = + contentDetector.detectMediaType(book.path).let { + logger.info { "Detected media type: $it" } + MediaType.fromMediaType(it) ?: return Media(mediaType = it, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id) + } + + if (book.path.extension.lowercase() == "epub" && mediaType != MediaType.EPUB) { + if (epubExtractor.isEpub(book.path)) { + mediaType = MediaType.EPUB + } else { + logger.warn { "Epub file is malformed, file is probably broken: ${book.path}" } + return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1032", bookId = book.id) + } + } + + when (mediaType.profile) { + MediaProfile.DIVINA -> analyzeDivina(book, mediaType, analyzeDimensions) + MediaProfile.PDF -> analyzePdf(book, analyzeDimensions) + MediaProfile.EPUB -> analyzeEpub(book, analyzeDimensions) + }.copy(mediaType = mediaType.type) + } catch (ade: AccessDeniedException) { + logger.error(ade) { "Error while analyzing book: $book" } + Media(status = Media.Status.ERROR, comment = "ERR_1000") + } catch (ex: NoSuchFileException) { + logger.error(ex) { "Error while analyzing book: $book" } + Media(status = Media.Status.ERROR, comment = "ERR_1018") + } catch (ex: Exception) { + logger.error(ex) { "Error while analyzing book: $book" } + Media(status = Media.Status.ERROR, comment = "ERR_1005") + }.copy(bookId = book.id) + } + + private fun analyzeDivina( + book: Book, + mediaType: MediaType, + analyzeDimensions: Boolean, + ): Media { + val entries = + try { + divinaExtractors[mediaType.type]?.getEntries(book.path, analyzeDimensions) + ?: return Media(status = Media.Status.UNSUPPORTED) + } catch (ex: MediaUnsupportedException) { + return Media(status = Media.Status.UNSUPPORTED, comment = ex.code) + } catch (ex: Exception) { + logger.error(ex) { "Error while analyzing book: $book" } + return Media(status = Media.Status.ERROR, comment = "ERR_1008") + } + + val (pages, others) = + entries + .partition { entry -> + entry.mediaType?.let { contentDetector.isImage(it) } ?: false + }.let { (images, others) -> + Pair( + images.map { BookPage(fileName = it.name, mediaType = it.mediaType!!, dimension = it.dimension, fileSize = it.fileSize) }, + others, + ) + } + + val entriesErrorSummary = + others + .filter { it.mediaType.isNullOrBlank() } + .map { it.name } + .ifEmpty { null } + ?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it } + + if (pages.isEmpty()) { + logger.warn { "Book $book does not contain any pages" } + return Media(status = Media.Status.ERROR, comment = "ERR_1006") + } + logger.info { "Book has ${pages.size} pages" } + + val files = others.map { MediaFile(fileName = it.name, mediaType = it.mediaType, fileSize = it.fileSize) } + + return Media(status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary) + } + + private fun analyzeEpub( + book: Book, + analyzeDimensions: Boolean, + ): Media { + book.path.epub { epub -> + val (resources, missingResources) = epubExtractor.getResources(epub).partition { it.fileSize != null } + val isKepub = epubExtractor.isKepub(epub, resources) + + val errors = mutableListOf() + + val toc = + try { + epubExtractor.getToc(epub) + } catch (e: Exception) { + logger.error(e) { "Error while getting EPUB TOC" } + errors.add("ERR_1035") + emptyList() + } + + val landmarks = + try { + epubExtractor.getLandmarks(epub) + } catch (e: Exception) { + logger.error(e) { "Error while getting EPUB Landmarks" } + errors.add("ERR_1036") + emptyList() + } + + val pageList = + try { + epubExtractor.getPageList(epub) + } catch (e: Exception) { + logger.error(e) { "Error while getting EPUB page list" } + errors.add("ERR_1037") + emptyList() + } + + val divinaPages = + try { + epubExtractor.getDivinaPages(epub, analyzeDimensions) + } catch (e: Exception) { + logger.error(e) { "Error while getting EPUB Divina pages" } + errors.add("ERR_1038") + emptyList() + } + + val isFixedLayout = divinaPages.isNotEmpty() || epubExtractor.isFixedLayout(epub) + + val positions = + try { + epubExtractor.computePositions(epub, book.path, resources, isFixedLayout, isKepub) + } catch (e: Exception) { + logger.error(e) { "Error while getting EPUB positions" } + errors.add("ERR_1039") + emptyList() + } + + val entriesErrorSummary = + missingResources + .map { it.fileName } + .ifEmpty { null } + ?.joinToString(prefix = "ERR_1033 [", postfix = "]") { it } + + val allErrors = + (errors + entriesErrorSummary) + .filterNotNull() + .joinToString(" ") + .ifBlank { null } + + return Media( + status = Media.Status.READY, + pages = divinaPages, + files = resources, + pageCount = if (divinaPages.isNotEmpty()) divinaPages.size else epubExtractor.computePageCount(epub), + epubDivinaCompatible = divinaPages.isNotEmpty(), + epubIsKepub = isKepub, + extension = + MediaExtensionEpub( + toc = toc, + landmarks = landmarks, + pageList = pageList, + isFixedLayout = isFixedLayout, + positions = positions, + ), + comment = allErrors, + ) + } + } + + private fun analyzePdf( + book: Book, + analyzeDimensions: Boolean, + ): Media { + val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) } + return Media(status = Media.Status.READY, pages = pages) + } + + @Throws( + MediaNotReadyException::class, + NoThumbnailFoundException::class, + ) + fun generateThumbnail(book: BookWithMedia): ThumbnailBook { + logger.info { "Generate thumbnail for book: $book" } + + if (book.media.status != Media.Status.READY) { + logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" } + throw MediaNotReadyException() + } + + val thumbnail = + getPoster(book)?.let { cover -> + imageConverter.resizeImageToByteArray(cover.bytes, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge) + } ?: throw NoThumbnailFoundException() + + return ThumbnailBook( + thumbnail = thumbnail, + type = ThumbnailBook.Type.GENERATED, + bookId = book.book.id, + mediaType = thumbnailType.mediaType, + dimension = imageAnalyzer.getDimension(thumbnail.inputStream()) ?: Dimension(0, 0), + fileSize = thumbnail.size.toLong(), + ) + } + + fun getPoster(book: BookWithMedia): TypedBytes? = + when (book.media.profile) { + MediaProfile.DIVINA -> divinaExtractors[book.media.mediaType]?.getPoster(book) + MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, 1) + MediaProfile.EPUB -> epubExtractor.getCover(book.book.path) ?: if (book.media.epubDivinaCompatible) divinaExtractors[MediaType.ZIP.type]?.getPoster(book) else null + null -> null + } + + private fun DivinaExtractor.getPoster(book: BookWithMedia): TypedBytes = + this + .getEntryStream( + book.book.path, + book.media.pages + .first() + .fileName, + ).let { + TypedBytes( + it, + book.media.pages + .first() + .mediaType, + ) + } + + @Throws( + MediaNotReadyException::class, + IndexOutOfBoundsException::class, + ) + fun getPageContent( + book: BookWithMedia, + number: Int, + ): ByteArray { + logger.debug { "Get page #$number for book: $book" } + + if (book.media.status != Media.Status.READY) { + logger.warn { "Book media is not ready, cannot get pages" } + throw MediaNotReadyException() + } + + if (number > book.media.pageCount || number <= 0) { + logger.error { "Page number #$number is out of bounds. Book has ${book.media.pageCount} pages" } + throw IndexOutOfBoundsException("Page $number does not exist") + } + + return when (book.media.profile) { + MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName) + MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, number).bytes + MediaProfile.EPUB -> + if (book.media.epubDivinaCompatible) + epubExtractor.getEntryStream(book.book.path, book.media.pages[number - 1].fileName) + else + throw MediaUnsupportedException("Epub profile does not support getting page content") + + null -> throw MediaNotReadyException() + } + } + + @Throws( + MediaNotReadyException::class, + IndexOutOfBoundsException::class, + ) + fun getPageContentRaw( + book: BookWithMedia, + number: Int, + ): TypedBytes { + logger.debug { "Get raw page #$number for book: $book" } + if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages") + + if (book.media.status != Media.Status.READY) { + logger.warn { "Book media is not ready, cannot get pages" } + throw MediaNotReadyException() + } + + if (number > book.media.pageCount || number <= 0) { + logger.error { "Page number #$number is out of bounds. Book has ${book.media.pageCount} pages" } + throw IndexOutOfBoundsException("Page $number does not exist") + } + + return pdfExtractor.getPageContentAsPdf(book.book.path, number) + } + + @Throws( + MediaNotReadyException::class, + ) + fun getFileContent( + book: BookWithMedia, + fileName: String, + ): ByteArray { + logger.debug { "Get file $fileName for book: $book" } + + if (book.media.status != Media.Status.READY) { + logger.warn { "Book media is not ready, cannot get files" } + throw MediaNotReadyException() + } + + return when (book.media.profile) { + MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName) + MediaProfile.EPUB -> epubExtractor.getEntryStream(book.book.path, fileName) + MediaProfile.PDF, null -> throw MediaUnsupportedException("Extractor does not support extraction of files") + } + } + + /** + * Will hash the first and last pages of the given book. + * The number of pages hashed from start/end is configurable. + * + * See [org.gotson.komga.infrastructure.configuration.KomgaProperties.pageHashing] + */ + fun hashPages(book: BookWithMedia): Media { + val hashedPages = + book.media.pages.mapIndexed { index, bookPage -> + if (bookPage.fileHash.isBlank() && (index < pageHashing || index >= (book.media.pageCount - pageHashing))) { + val content = getPageContent(book, index + 1) + val hash = hashPage(bookPage, content) + bookPage.copy(fileHash = hash) + } else { + bookPage + } + } + + return book.media.copy(pages = hashedPages) + } + + /** + * Hash a single page, using the file content for hashing. + * + * For JPEG, the image is read/written to remove the metadata. + */ + fun hashPage( + page: BookPage, + content: ByteArray, + ): String { + val bytes = + if (page.mediaType == ImageType.JPEG.mediaType) { + // JPEG could contain different EXIF data, reading and writing back the image will get rid of it + ByteArrayOutputStream().use { buffer -> + ImageIO.write(ImageIO.read(content.inputStream()), ImageType.JPEG.imageIOFormat, buffer) + buffer.toByteArray() + } + } else { + content + } + + return hasher.computeHash(bytes.inputStream()) + } + + fun getPdfPagesDynamic(media: Media): List { + if (media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Cannot get synthetic pages for non-PDF media") + + return media.pages.map { page -> + page.copy( + mediaType = pdfImageType.mediaType, + dimension = page.dimension?.let { pdfExtractor.scaleDimension(it) }, + ) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt new file mode 100644 index 000000000..6e6155cce --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookConverter.kt @@ -0,0 +1,220 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookConversionException +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.model.restoreHashFrom +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.language.notEquals +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.io.FileNotFoundException +import java.nio.file.FileAlreadyExistsException +import java.util.zip.Deflater +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.moveTo +import kotlin.io.path.nameWithoutExtension +import kotlin.io.path.notExists +import kotlin.io.path.outputStream + +private val logger = KotlinLogging.logger {} + +const val CBZ_EXTENSION = "cbz" + +@Service +class BookConverter( + private val bookAnalyzer: BookAnalyzer, + private val fileSystemScanner: FileSystemScanner, + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val libraryRepository: LibraryRepository, + private val transactionTemplate: TransactionTemplate, + private val eventPublisher: ApplicationEventPublisher, + private val historicalEventRepository: HistoricalEventRepository, +) { + private val convertibleTypes = listOf(MediaType.RAR_4.type, MediaType.RAR_5.type) + + private val mediaTypeToExtension = + listOf(MediaType.RAR_4, MediaType.RAR_5, MediaType.ZIP, MediaType.PDF, MediaType.EPUB) + .associate { it.type to it.fileExtension } + + private val failedConversions = mutableListOf() + private val skippedRepairs = mutableListOf() + + fun getConvertibleBooks(library: Library): Collection = + if (library.convertToCbz) { + bookRepository + .findAllByLibraryIdAndMediaTypes(library.id, convertibleTypes) + .also { logger.info { "Found ${it.size} books to convert" } } + } else { + logger.info { "CBZ conversion is not enabled, skipping" } + emptyList() + } + + fun convertToCbz(book: Book) { + // perform various checks + if (!libraryRepository.findById(book.libraryId).convertToCbz) + return logger.info { "Book conversion is disabled for the library, it may have changed since the task was submitted, skipping" } + + if (failedConversions.contains(book.id)) + return logger.info { "Book conversion already failed before, skipping" } + + fileSystemScanner.scanFile(book.path)?.let { scannedBook -> + if (scannedBook.fileLastModified.notEquals(book.fileLastModified)) + return logger.info { "Book has changed on disk, skipping" } + } ?: throw FileNotFoundException("File not found: ${book.path}") + + val media = mediaRepository.findById(book.id) + + if (!convertibleTypes.contains(media.mediaType)) + throw MediaUnsupportedException("${media.mediaType} cannot be converted. Must be one of $convertibleTypes") + + if (media.status != Media.Status.READY) + throw MediaNotReadyException() + + // perform conversion + val destinationFilename = "${book.path.nameWithoutExtension}.$CBZ_EXTENSION" + val destinationPath = book.path.parent.resolve(destinationFilename) + if (destinationPath.exists()) + throw FileAlreadyExistsException("Destination file already exists: $destinationPath") + + logger.info { "Copying archive content to $destinationPath" } + ZipArchiveOutputStream(destinationPath.outputStream()).use { zipStream -> + zipStream.setMethod(ZipArchiveOutputStream.DEFLATED) + zipStream.setLevel(Deflater.NO_COMPRESSION) + + media + .pages + .map { it.fileName } + .union(media.files.map { it.fileName }) + .forEach { entry -> + zipStream.putArchiveEntry(ZipArchiveEntry(entry)) + zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry)) + zipStream.closeArchiveEntry() + } + } + + // perform checks on new file + val convertedBook = + fileSystemScanner + .scanFile(destinationPath) + ?.copy( + id = book.id, + seriesId = book.seriesId, + libraryId = book.libraryId, + ) + ?: throw IllegalStateException("Newly converted book could not be scanned: $destinationFilename") + + val convertedMedia = bookAnalyzer.analyze(convertedBook, libraryRepository.findById(book.libraryId).analyzeDimensions) + + try { + when { + convertedMedia.status != Media.Status.READY + -> throw BookConversionException("Converted file could not be analyzed, aborting conversion") + + convertedMedia.mediaType != MediaType.ZIP.type + -> throw BookConversionException("Converted file is not a zip file, aborting conversion") + + !convertedMedia.pages + .map { FilenameUtils.getName(it.fileName) to it.mediaType } + .containsAll(media.pages.map { FilenameUtils.getName(it.fileName) to it.mediaType }) + -> throw BookConversionException("Converted file does not contain all pages from existing file, aborting conversion") + + !convertedMedia.files + .map { FilenameUtils.getName(it.fileName) } + .containsAll(media.files.map { FilenameUtils.getName(it.fileName) }) + -> throw BookConversionException("Converted file does not contain all files from existing file, aborting conversion") + } + } catch (e: BookConversionException) { + destinationPath.deleteIfExists() + failedConversions += book.id + throw e + } + + if (book.path.deleteIfExists()) { + logger.info { "Deleted old file: ${book.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted after conversion to CBZ")) + } + + val mediaWithHashes = convertedMedia.copy(pages = convertedMedia.pages.restoreHashFrom(media.pages)) + + transactionTemplate.executeWithoutResult { + bookRepository.update(convertedBook) + mediaRepository.update(mediaWithHashes) + } + + historicalEventRepository.insert(HistoricalEvent.BookConverted(convertedBook, book)) + eventPublisher.publishEvent(DomainEvent.BookUpdated(convertedBook)) + } + + fun getMismatchedExtensionBooks(library: Library): Collection = + mediaTypeToExtension.flatMap { (mediaType, extension) -> + bookRepository.findAllByLibraryIdAndMismatchedExtension(library.id, mediaType, extension) + } + + fun repairExtension(book: Book) { + if (!libraryRepository.findById(book.libraryId).repairExtensions) + return logger.info { "Repair extensions is disabled for the library, it may have changed since the task was submitted, skipping" } + + if (skippedRepairs.contains(book.id)) + return logger.info { "Extension repair has already been skipped before, skipping" } + + if (book.path.notExists()) throw FileNotFoundException("File not found: ${book.path}") + + val media = mediaRepository.findById(book.id) + + if (!mediaTypeToExtension.keys.contains(media.mediaType)) + throw MediaUnsupportedException("${media.mediaType} cannot be repaired. Must be one of ${mediaTypeToExtension.keys}") + + if (book.path.extension.lowercase() == "epub" && media.mediaType == MediaType.ZIP.type) { + skippedRepairs += book.id + logger.info { "EPUB file detected as zip should not be repaired, skipping: ${book.path}" } + return + } + + val actualExtension = book.path.extension + val correctExtension = mediaTypeToExtension[media.mediaType] + + if (correctExtension == actualExtension) { + logger.info { "MediaType (${media.mediaType}) and extension ($actualExtension) already match, skipping" } + skippedRepairs += book.id + } + + val destinationFilename = "${book.path.nameWithoutExtension}.$correctExtension" + val destinationPath = book.path.parent.resolve(destinationFilename) + if (destinationPath.exists()) + throw FileAlreadyExistsException("Destination file already exists: $destinationPath") + + logger.info { "Renaming ${book.path} to $destinationPath" } + book.path.moveTo(destinationPath) + + val repairedBook = + fileSystemScanner + .scanFile(destinationPath) + ?.copy( + id = book.id, + seriesId = book.seriesId, + libraryId = book.libraryId, + ) + ?: throw IllegalStateException("Repaired book could not be scanned: $destinationFilename") + + bookRepository.update(repairedBook) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt new file mode 100644 index 000000000..4b031a245 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt @@ -0,0 +1,282 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.CodedException +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.withCode +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SidecarRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.language.toIndexedMap +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import java.io.FileNotFoundException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.copyTo +import kotlin.io.path.deleteExisting +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.moveTo +import kotlin.io.path.name +import kotlin.io.path.nameWithoutExtension +import kotlin.io.path.notExists +import kotlin.io.path.readAttributes +import kotlin.io.path.toPath + +private val logger = KotlinLogging.logger {} + +@Service +class BookImporter( + private val bookLifecycle: BookLifecycle, + private val fileSystemScanner: FileSystemScanner, + private val seriesLifecycle: SeriesLifecycle, + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val metadataRepository: BookMetadataRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val readProgressRepository: ReadProgressRepository, + private val readListRepository: ReadListRepository, + private val libraryRepository: LibraryRepository, + private val sidecarRepository: SidecarRepository, + private val eventPublisher: ApplicationEventPublisher, + private val taskEmitter: TaskEmitter, + private val historicalEventRepository: HistoricalEventRepository, + private val seriesRepository: SeriesRepository, +) { + fun importBook( + sourceFile: Path, + series: Series, + copyMode: CopyMode, + destinationName: String? = null, + upgradeBookId: String? = null, + ): Book { + try { + if (sourceFile.notExists()) throw FileNotFoundException("File not found: $sourceFile").withCode("ERR_1018") + if (series.oneshot && upgradeBookId.isNullOrEmpty()) throw IllegalArgumentException("Destination series is oneshot but upgradeBookId is missing") + + libraryRepository.findAll().forEach { library -> + if (sourceFile.startsWith(library.path)) throw PathContainedInPath("Cannot import file that is part of an existing library", "ERR_1019") + } + + val bookToUpgrade = + if (upgradeBookId != null) { + bookRepository.findByIdOrNull(upgradeBookId)?.also { + if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series").withCode("ERR_1020") + } + } else { + null + } + + val destDir = + if (series.oneshot) + series.path.parent + else + series.path + + val destFile = + destDir.resolve( + if (destinationName != null) + Paths.get("$destinationName.${sourceFile.extension}").name + else + sourceFile.name, + ) + val sidecars = + fileSystemScanner.scanBookSidecars(sourceFile).associateWith { + destDir.resolve( + if (destinationName != null) + it.url + .toURI() + .toPath() + .name + .replace(sourceFile.nameWithoutExtension, destinationName, true) + else + it.url + .toURI() + .toPath() + .name, + ) + } + + var deletedUpgradedFile = false + when { + bookToUpgrade?.path != null && destFile == bookToUpgrade.path -> { + logger.info { "Deleting existing file: ${bookToUpgrade.path}" } + try { + bookToUpgrade.path.deleteExisting() + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade")) + deletedUpgradedFile = true + } catch (e: NoSuchFileException) { + logger.warn { "Could not delete upgraded book: ${bookToUpgrade.path}" } + } + } + + destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile").withCode("ERR_1021") + } + // delete existing sidecars + if (bookToUpgrade?.path != null) { + fileSystemScanner.scanBookSidecars(bookToUpgrade.path).forEach { sidecar -> + val sidecarPath = sidecar.url.toURI().toPath() + logger.info { "Deleting existing file: $sidecarPath" } + sidecarPath.deleteIfExists() + } + } + + when (copyMode) { + CopyMode.MOVE -> { + logger.info { "Moving file $sourceFile to $destFile" } + sourceFile.moveTo(destFile) + sidecars.forEach { + it.key.url.toURI().toPath().let { sourcePath -> + logger.info { "Moving file $sourcePath to ${it.value}" } + sourcePath.moveTo(it.value, true) + } + } + } + + CopyMode.COPY -> { + logger.info { "Copying file $sourceFile to $destFile" } + sourceFile.copyTo(destFile) + sidecars.forEach { + it.key.url.toURI().toPath().let { sourcePath -> + logger.info { "Copying file $sourcePath to ${it.value}" } + sourcePath.copyTo(it.value, true) + } + } + } + + CopyMode.HARDLINK -> + try { + logger.info { "Hardlink file $sourceFile to $destFile" } + Files.createLink(destFile, sourceFile) + sidecars.forEach { + it.key.url.toURI().toPath().let { sourcePath -> + logger.info { "Hardlink file $sourcePath to ${it.value}" } + it.value.deleteIfExists() + Files.createLink(it.value, sourcePath) + } + } + } catch (e: Exception) { + logger.warn(e) { "Filesystem does not support hardlinks, copying instead" } + sourceFile.copyTo(destFile) + sidecars.forEach { + it.key.url + .toURI() + .toPath() + .copyTo(it.value, true) + } + } + } + + val importedBook = + fileSystemScanner + .scanFile(destFile) + ?.copy(libraryId = series.libraryId, oneshot = series.oneshot) + ?: throw IllegalStateException("Newly imported book could not be scanned: $destFile").withCode("ERR_1022") + + seriesLifecycle.addBooks(series, listOf(importedBook)) + + if (bookToUpgrade != null) { + // copy media and mark it as outdated + mediaRepository.findById(bookToUpgrade.id).let { + mediaRepository.update( + it.copy( + bookId = importedBook.id, + status = Media.Status.OUTDATED, + ), + ) + } + + // copy metadata + metadataRepository.findById(bookToUpgrade.id).let { + metadataRepository.update(it.copy(bookId = importedBook.id)) + } + + // copy user uploaded thumbnails + thumbnailBookRepository.findAllByBookIdAndType(bookToUpgrade.id, setOf(ThumbnailBook.Type.USER_UPLOADED)).forEach { deleted -> + thumbnailBookRepository.update(deleted.copy(bookId = importedBook.id)) + } + + // copy read progress + readProgressRepository + .findAllByBookId(bookToUpgrade.id) + .map { it.copy(bookId = importedBook.id) } + .forEach { readProgressRepository.save(it) } + + // replace upgraded book by imported book in read lists + readListRepository + .findAllContainingBookId(bookToUpgrade.id, filterOnLibraryIds = null) + .forEach { rl -> + readListRepository.update( + rl.copy( + bookIds = + rl.bookIds.values + .map { if (it == bookToUpgrade.id) importedBook.id else it } + .toIndexedMap(), + ), + ) + } + + // delete upgraded book file on disk if it has not been replaced earlier + if (!deletedUpgradedFile && bookToUpgrade.path.deleteIfExists()) { + logger.info { "Deleted existing file: ${bookToUpgrade.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade")) + } + + // delete upgraded book + bookLifecycle.deleteOne(bookToUpgrade) + + // update series if one-shot, so it's not marked as not found during the next scan + if (series.oneshot) { + seriesRepository.update(series.copy(url = importedBook.url, fileLastModified = importedBook.fileLastModified)) + } + } + + seriesLifecycle.sortBooks(series) + + sidecars.forEach { (sourceSidecar, destPath) -> + when (sourceSidecar.type) { + Sidecar.Type.ARTWORK -> taskEmitter.refreshBookLocalArtwork(importedBook) + Sidecar.Type.METADATA -> taskEmitter.refreshBookMetadata(importedBook) + } + val destSidecar = + sourceSidecar.copy( + url = destPath.toUri().toURL(), + parentUrl = destPath.parent.toUri().toURL(), + lastModifiedTime = destPath.readAttributes().getUpdatedTime(), + ) + sidecarRepository.save(importedBook.libraryId, destSidecar) + } + + historicalEventRepository.insert(HistoricalEvent.BookImported(importedBook, series, sourceFile, upgradeBookId != null)) + eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true)) + + return importedBook + } catch (e: Exception) { + val msg = if (e is CodedException) e.code else e.message + eventPublisher.publishEvent(DomainEvent.BookImported(null, sourceFile.toUri().toURL(), success = false, msg)) + throw e + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt new file mode 100644 index 000000000..980648118 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -0,0 +1,571 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookAction +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.NoThumbnailFoundException +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.hash.KoreaderHasher +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.io.File +import java.net.URLDecoder +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isWritable +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.notExists +import kotlin.io.path.toPath +import kotlin.math.roundToInt + +private val logger = KotlinLogging.logger {} + +@Service +class BookLifecycle( + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val readProgressRepository: ReadProgressRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val readListRepository: ReadListRepository, + private val libraryRepository: LibraryRepository, + private val bookAnalyzer: BookAnalyzer, + private val imageConverter: ImageConverter, + private val eventPublisher: ApplicationEventPublisher, + private val transactionTemplate: TransactionTemplate, + private val hasher: Hasher, + private val hasherKoreader: KoreaderHasher, + private val historicalEventRepository: HistoricalEventRepository, + private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, +) { + private val resizeTargetFormat = ImageType.JPEG + + fun analyzeAndPersist(book: Book): Set { + logger.info { "Analyze and persist book: $book" } + val media = bookAnalyzer.analyze(book, libraryRepository.findById(book.libraryId).analyzeDimensions) + + transactionTemplate.executeWithoutResult { + // if the number of pages has changed, delete all read progress for that book + mediaRepository.findById(book.id).let { previous -> + if (previous.status == Media.Status.OUTDATED && previous.pageCount != media.pageCount) { + val adjustedProgress = + readProgressRepository + .findAllByBookId(book.id) + .map { it.copy(page = if (it.completed) media.pageCount else 1) } + if (adjustedProgress.isNotEmpty()) { + logger.info { "Number of pages differ, adjust read progress for book" } + readProgressRepository.save(adjustedProgress) + } + } + } + + mediaRepository.update(media) + } + + eventPublisher.publishEvent(DomainEvent.BookUpdated(book)) + + return if (media.status == Media.Status.READY) setOf(BookAction.GENERATE_THUMBNAIL, BookAction.REFRESH_METADATA) else emptySet() + } + + fun hashAndPersist(book: Book) { + if (!libraryRepository.findById(book.libraryId).hashFiles) + return logger.info { "File hashing is disabled for the library, it may have changed since the task was submitted, skipping" } + + logger.info { "Hash and persist book: $book" } + if (book.fileHash.isBlank()) { + val hash = hasher.computeHash(book.path) + bookRepository.update(book.copy(fileHash = hash)) + } else { + logger.info { "Book already has a hash, skipping" } + } + } + + fun hashKoreaderAndPersist(book: Book) { + if (!libraryRepository.findById(book.libraryId).hashKoreader) + return logger.info { "File hashing for Koreader is disabled for the library, it may have changed since the task was submitted, skipping" } + + logger.info { "Hash Koreader and persist book: $book" } + if (book.fileHashKoreader.isBlank()) { + val hash = hasherKoreader.computeHash(book.path) + bookRepository.update(book.copy(fileHashKoreader = hash)) + } else { + logger.info { "Book already has a Koreader hash, skipping" } + } + } + + fun hashPagesAndPersist(book: Book) { + if (!libraryRepository.findById(book.libraryId).hashPages) + return logger.info { "Page hashing is disabled for the library, it may have changed since the task was submitted, skipping" } + + logger.info { "Hash and persist pages for book: $book" } + + mediaRepository.update(bookAnalyzer.hashPages(BookWithMedia(book, mediaRepository.findById(book.id)))) + } + + fun generateThumbnailAndPersist(book: Book) { + logger.info { "Generate thumbnail and persist for book: $book" } + try { + addThumbnailForBook(bookAnalyzer.generateThumbnail(BookWithMedia(book, mediaRepository.findById(book.id))), MarkSelectedPreference.IF_NONE_OR_GENERATED) + } catch (ex: NoThumbnailFoundException) { + logger.error { "Error while creating thumbnail" } + } catch (ex: Exception) { + logger.error(ex) { "Error while creating thumbnail" } + } + } + + fun addThumbnailForBook( + thumbnail: ThumbnailBook, + markSelected: MarkSelectedPreference, + ): ThumbnailBook { + when (thumbnail.type) { + ThumbnailBook.Type.GENERATED -> { + // only one generated thumbnail is allowed + thumbnailBookRepository.deleteByBookIdAndType(thumbnail.bookId, ThumbnailBook.Type.GENERATED) + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) + } + + ThumbnailBook.Type.SIDECAR -> { + // delete existing thumbnail with the same url + thumbnailBookRepository + .findAllByBookIdAndType(thumbnail.bookId, setOf(ThumbnailBook.Type.SIDECAR)) + .filter { it.url == thumbnail.url } + .forEach { + thumbnailBookRepository.delete(it.id) + } + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) + } + + ThumbnailBook.Type.USER_UPLOADED -> { + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) + } + } + + val selected = + when (markSelected) { + MarkSelectedPreference.YES -> true + MarkSelectedPreference.IF_NONE_OR_GENERATED -> { + val selectedThumbnail = thumbnailBookRepository.findSelectedByBookIdOrNull(thumbnail.bookId) + selectedThumbnail == null || selectedThumbnail.type == ThumbnailBook.Type.GENERATED + } + + MarkSelectedPreference.NO -> false + } + + if (selected) + thumbnailBookRepository.markSelected(thumbnail) + else + thumbnailsHouseKeeping(thumbnail.bookId) + + val newThumbnail = thumbnail.copy(selected = selected) + eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(newThumbnail)) + return newThumbnail + } + + fun deleteThumbnailForBook(thumbnail: ThumbnailBook) { + require(thumbnail.type == ThumbnailBook.Type.USER_UPLOADED) { "Only uploaded thumbnails can be deleted" } + thumbnailBookRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.bookId) + eventPublisher.publishEvent(DomainEvent.ThumbnailBookDeleted(thumbnail)) + } + + fun getThumbnail(bookId: String): ThumbnailBook? { + val selected = thumbnailBookRepository.findSelectedByBookIdOrNull(bookId) + + if (selected == null || !selected.exists()) { + thumbnailsHouseKeeping(bookId) + return thumbnailBookRepository.findSelectedByBookIdOrNull(bookId) + } + + return selected + } + + fun getThumbnailBytes( + bookId: String, + resizeTo: Int? = null, + ): TypedBytes? { + getThumbnail(bookId)?.let { + val thumbnailBytes = + when { + it.thumbnail != null -> it.thumbnail + it.url != null -> File(it.url.toURI()).readBytes() + else -> return null + } + + if (resizeTo != null) { + try { + return TypedBytes( + imageConverter.resizeImageToByteArray(thumbnailBytes, resizeTargetFormat, resizeTo), + resizeTargetFormat.mediaType, + ) + } catch (e: Exception) { + logger.error(e) { "Resize thumbnail of book $bookId to $resizeTo: failed" } + } + } + + return TypedBytes(thumbnailBytes, it.mediaType) + } + return null + } + + fun getThumbnailBytesOriginal(bookId: String): TypedBytes? { + val thumbnail = getThumbnail(bookId) ?: return null + return if (thumbnail.type == ThumbnailBook.Type.GENERATED) { + val book = bookRepository.findByIdOrNull(bookId) ?: return null + val media = mediaRepository.findById(book.id) + bookAnalyzer.getPoster(BookWithMedia(book, media)) + } else { + getThumbnailBytes(bookId) + } + } + + fun getThumbnailBytesByThumbnailId(thumbnailId: String): TypedBytes? = + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { thumbnail -> + getBytesFromThumbnailBook(thumbnail)?.let { bytes -> + TypedBytes(bytes, thumbnail.mediaType) + } + } + + private fun getBytesFromThumbnailBook(thumbnail: ThumbnailBook): ByteArray? = + when { + thumbnail.thumbnail != null -> thumbnail.thumbnail + thumbnail.url != null -> File(thumbnail.url.toURI()).readBytes() + else -> null + } + + private fun thumbnailsHouseKeeping(bookId: String) { + logger.info { "House keeping thumbnails for book: $bookId" } + val all = + thumbnailBookRepository + .findAllByBookId(bookId) + .mapNotNull { + if (!it.exists()) { + logger.warn { "Thumbnail doesn't exist, removing entry" } + thumbnailBookRepository.delete(it.id) + null + } else { + it + } + } + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailBookRepository.markSelected(selected[0]) + } + + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Book has no selected thumbnail, choosing one automatically" } + thumbnailBookRepository.markSelected(all.first()) + } + } + } + + fun findBookThumbnailsToRegenerate(forBiggerResultOnly: Boolean): Collection = + if (forBiggerResultOnly) { + thumbnailBookRepository.findAllBookIdsByThumbnailTypeAndDimensionSmallerThan(ThumbnailBook.Type.GENERATED, komgaSettingsProvider.thumbnailSize.maxEdge) + } else { + bookRepository.findAll(SearchCondition.Deleted(SearchOperator.IsFalse), SearchContext.empty(), Pageable.unpaged()).content.map { it.id } + } + + @Throws( + ImageConversionException::class, + MediaNotReadyException::class, + IndexOutOfBoundsException::class, + ) + fun getBookPage( + book: Book, + number: Int, + convertTo: ImageType? = null, + resizeTo: Int? = null, + ): TypedBytes { + val media = mediaRepository.findById(book.id) + val pageContent = bookAnalyzer.getPageContent(BookWithMedia(book, media), number) + val pageMediaType = + if (media.profile == MediaProfile.PDF) + pdfImageType.mediaType + else + media.pages[number - 1].mediaType + + if (resizeTo != null) { + val convertedPage = + try { + imageConverter.resizeImageToByteArray(pageContent, resizeTargetFormat, resizeTo) + } catch (e: Exception) { + logger.error(e) { "Resize page #$number of book $book to $resizeTo: failed" } + throw e + } + return TypedBytes(convertedPage, resizeTargetFormat.mediaType) + } else { + convertTo?.let { + val msg = "Convert page #$number of book $book from $pageMediaType to ${it.mediaType}" + if (!imageConverter.supportedReadMediaTypes.contains(pageMediaType)) { + throw ImageConversionException("$msg: unsupported read format $pageMediaType") + } + if (!imageConverter.supportedWriteMediaTypes.contains(it.mediaType)) { + throw ImageConversionException("$msg: unsupported write format ${it.mediaType}") + } + if (pageMediaType == it.mediaType) { + logger.warn { "$msg: same format, no need for conversion" } + return@let + } + + logger.info { msg } + val convertedPage = + try { + imageConverter.convertImage(pageContent, it.imageIOFormat) + } catch (e: Exception) { + logger.error(e) { "$msg: conversion failed" } + throw e + } + return TypedBytes(convertedPage, it.mediaType) + } + + return TypedBytes(pageContent, pageMediaType) + } + } + + fun deleteOne(book: Book) { + logger.info { "Delete book id: ${book.id}" } + + transactionTemplate.executeWithoutResult { + readProgressRepository.deleteByBookId(book.id) + readListRepository.removeBookFromAll(book.id) + + mediaRepository.delete(book.id) + thumbnailBookRepository.deleteByBookId(book.id) + bookMetadataRepository.delete(book.id) + + bookRepository.delete(book.id) + } + + eventPublisher.publishEvent(DomainEvent.BookDeleted(book)) + } + + fun softDeleteMany(books: Collection) { + logger.info { "Soft delete books: $books" } + val deletedDate = LocalDateTime.now() + bookRepository.update(books.map { it.copy(deletedDate = deletedDate) }) + + books.forEach { eventPublisher.publishEvent(DomainEvent.BookUpdated(it)) } + } + + fun deleteMany(books: Collection) { + val bookIds = books.map { it.id } + logger.info { "Delete book ids: $bookIds" } + + transactionTemplate.executeWithoutResult { + readProgressRepository.deleteByBookIds(bookIds) + readListRepository.removeBooksFromAll(bookIds) + + mediaRepository.delete(bookIds) + thumbnailBookRepository.deleteByBookIds(bookIds) + bookMetadataRepository.delete(bookIds) + + bookRepository.delete(bookIds) + } + + books.forEach { eventPublisher.publishEvent(DomainEvent.BookDeleted(it)) } + } + + fun markReadProgress( + book: Book, + user: KomgaUser, + page: Int, + ) { + val media = mediaRepository.findById(book.id) + require(page in 1..media.pageCount) { "Page argument ($page) must be within 1 and book page count (${media.pageCount})" } + + val locator = + if (media.profile == MediaProfile.EPUB) { + require(media.epubDivinaCompatible) { "epub book is not Divina compatible" } + + val extension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw IllegalArgumentException("Epub extension not found") + .also { logger.error { "Epub extension not found for book ${book.id}. Book should be re-analyzed." } } + extension.positions[page - 1] + } else { + null + } + + val progress = ReadProgress(book.id, user.id, page, page == media.pageCount, locator = locator) + + readProgressRepository.save(progress) + eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress)) + } + + fun markReadProgressCompleted( + bookId: String, + user: KomgaUser, + ) { + val media = mediaRepository.findById(bookId) + + val progress = ReadProgress(bookId, user.id, media.pageCount, true) + readProgressRepository.save(progress) + eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress)) + } + + fun deleteReadProgress( + book: Book, + user: KomgaUser, + ) { + readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user.id)?.let { progress -> + readProgressRepository.delete(book.id, user.id) + eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(progress)) + } + } + + fun markProgression( + book: Book, + user: KomgaUser, + newProgression: R2Progression, + ) { + readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user.id)?.let { savedProgress -> + check( + newProgression.modified + .withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime() + .isAfter(savedProgress.readDate), + ) { "Progression is older than existing" } + } + + val media = mediaRepository.findById(book.id) + requireNotNull(media.profile) { "Media has no profile" } + val progress = + when (media.profile!!) { + MediaProfile.DIVINA, + MediaProfile.PDF, + -> { + require(newProgression.locator.locations?.position in 1..media.pageCount) { "Page argument (${newProgression.locator.locations?.position}) must be within 1 and book page count (${media.pageCount})" } + ReadProgress( + book.id, + user.id, + newProgression.locator.locations!!.position!!, + newProgression.locator.locations.position == media.pageCount, + newProgression.modified.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime(), + newProgression.device.id, + newProgression.device.name, + newProgression.locator, + ) + } + + MediaProfile.EPUB -> { + val href = + newProgression.locator.href + .replaceAfter("#", "") + .removeSuffix("#") + .let { URLDecoder.decode(it, Charsets.UTF_8) } + require(href in media.files.map { it.fileName }) { "Resource does not exist in book: $href" } + requireNotNull(newProgression.locator.locations?.progression) { "location.progression is required" } + + val extension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw IllegalArgumentException("Epub extension not found") + .also { logger.error { "Epub extension not found for book ${book.id}. Book should be re-analyzed." } } + // match progression with positions + val matchingPositions = extension.positions.filter { it.href == href } + val matchedPosition = + if (extension.isFixedLayout && matchingPositions.size == 1) + matchingPositions.first() + else + matchingPositions.firstOrNull { it.locations!!.progression == newProgression.locator.locations!!.progression } + ?: run { + // no exact match + val before = matchingPositions.filter { it.locations!!.progression!! < newProgression.locator.locations!!.progression!! }.maxByOrNull { it.locations!!.position!! } + val after = matchingPositions.filter { it.locations!!.progression!! > newProgression.locator.locations!!.progression!! }.minByOrNull { it.locations!!.position!! } + if (before == null || after == null || before.locations!!.position!! > after.locations!!.position!!) + throw IllegalArgumentException("Invalid progression") + before + } + + val totalProgression = matchedPosition.locations?.totalProgression + ReadProgress( + book.id, + user.id, + totalProgression?.let { (media.pageCount * it).roundToInt() } ?: 0, + totalProgression?.let { it >= 0.99F } ?: false, + newProgression.modified.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime(), + newProgression.device.id, + newProgression.device.name, + newProgression.locator.copy( + // use the type we have instead of the one provided + type = matchedPosition.type, + // if no koboSpan is provided, use the one we matched + koboSpan = newProgression.locator.koboSpan ?: matchedPosition.koboSpan, + // don't trust the provided total progression, the one from Kobo can be wrong + locations = newProgression.locator.locations?.copy(totalProgression = totalProgression), + ), + ) + } + } + + readProgressRepository.save(progress) + eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress)) + } + + fun deleteBookFiles(book: Book) { + if (book.path.notExists()) return logger.info { "Cannot delete book file, path does not exist: ${book.path}" } + if (!book.path.isWritable()) return logger.info { "Cannot delete book file, path is not writable: ${book.path}" } + + val thumbnails = + thumbnailBookRepository + .findAllByBookIdAndType(book.id, setOf(ThumbnailBook.Type.SIDECAR)) + .mapNotNull { it.url?.toURI()?.toPath() } + .filter { it.exists() && it.isWritable() } + + if (book.path.deleteIfExists()) { + logger.info { "Deleted file: ${book.path}" } + historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted by user request")) + } + thumbnails.forEach { + if (it.deleteIfExists()) logger.info { "Deleted file: $it" } + } + + if (book.path.parent + .listDirectoryEntries() + .isEmpty() + ) + if (book.path.parent.deleteIfExists()) { + logger.info { "Deleted directory: ${book.path.parent}" } + historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(book.seriesId, book.path.parent, "Folder was deleted because it was empty")) + } + + softDeleteMany(listOf(book)) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookMetadataLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookMetadataLifecycle.kt new file mode 100644 index 000000000..8b1576fb8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookMetadataLifecycle.kt @@ -0,0 +1,91 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class BookMetadataLifecycle( + private val bookMetadataProviders: List, + private val metadataApplier: MetadataApplier, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val libraryRepository: LibraryRepository, + private val readListLifecycle: ReadListLifecycle, + private val eventPublisher: ApplicationEventPublisher, +) { + fun refreshMetadata( + book: Book, + capabilities: Set, + ) { + logger.info { "Refresh metadata for book: $book with capabilities: $capabilities" } + val media = mediaRepository.findById(book.id) + + val library = libraryRepository.findById(book.libraryId) + var changed = false + + bookMetadataProviders.forEach { provider -> + when { + capabilities.intersect(provider.capabilities).isEmpty() -> + logger.info { "Provider does not support requested capabilities, skipping: ${provider.javaClass.simpleName}" } + + !(provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.BOOK) || provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.READLIST)) -> + logger.info { "Library is not set to import book or read lists metadata for this provider, skipping: ${provider.javaClass.simpleName}" } + + else -> { + logger.debug { "Provider: ${provider.javaClass.simpleName}" } + val patch = + try { + provider.getBookMetadataFromBook(BookWithMedia(book, media)) + } catch (e: Exception) { + logger.error(e) { "Error while getting metadata from ${provider.javaClass.simpleName} for book: $book" } + null + } + + if (provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.BOOK)) { + handlePatchForBookMetadata(patch, book) + changed = true + } + + if (provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.READLIST)) { + patch?.readLists?.forEach { readList -> + readListLifecycle.addBookToReadList(readList.name, book, readList.number) + } + } + } + } + } + + if (changed) eventPublisher.publishEvent(DomainEvent.BookUpdated(book)) + } + + private fun handlePatchForBookMetadata( + patch: BookMetadataPatch?, + book: Book, + ) { + patch?.let { bPatch -> + bookMetadataRepository.findById(book.id).let { + logger.debug { "Apply metadata for book: $book" } + + logger.debug { "Original metadata: $it" } + logger.debug { "Patch: $bPatch" } + val patched = metadataApplier.apply(bPatch, it) + logger.debug { "Patched metadata: $patched" } + + bookMetadataRepository.update(patched) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt new file mode 100644 index 000000000..a29c1edf1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/BookPageEditor.kt @@ -0,0 +1,179 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookAction +import org.gotson.komga.domain.model.BookConversionException +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.model.restoreHashFrom +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.PageHashRepository +import org.gotson.komga.language.notEquals +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.io.File +import java.io.FileNotFoundException +import java.util.zip.Deflater +import kotlin.io.path.deleteIfExists +import kotlin.io.path.moveTo +import kotlin.io.path.outputStream + +private val logger = KotlinLogging.logger {} +private const val TEMP_PREFIX = "komga_page_removal_" +private const val TEMP_SUFFIX = ".tmp" + +@Service +class BookPageEditor( + private val bookAnalyzer: BookAnalyzer, + private val fileSystemScanner: FileSystemScanner, + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val libraryRepository: LibraryRepository, + private val pageHashRepository: PageHashRepository, + private val transactionTemplate: TransactionTemplate, + private val eventPublisher: ApplicationEventPublisher, + private val historicalEventRepository: HistoricalEventRepository, +) { + private val convertibleTypes = listOf(MediaType.ZIP.type) + + private val failedPageRemoval = mutableListOf() + + fun removeHashedPages( + book: Book, + pagesToDelete: Collection, + ): BookAction? { + // perform various checks + if (failedPageRemoval.contains(book.id)) { + logger.info { "Book page removal already failed before, skipping" } + return null + } + + fileSystemScanner.scanFile(book.path)?.let { scannedBook -> + if (scannedBook.fileLastModified.notEquals(book.fileLastModified)) { + logger.info { "Book has changed on disk, skipping. Db: ${book.fileLastModified}. Scanned: ${scannedBook.fileLastModified}" } + return null + } + } ?: throw FileNotFoundException("File not found: ${book.path}") + + val media = mediaRepository.findById(book.id) + + if (!convertibleTypes.contains(media.mediaType)) + throw MediaUnsupportedException("${media.mediaType} cannot be converted. Must be one of $convertibleTypes") + + if (media.status != Media.Status.READY) + throw MediaNotReadyException() + + // create a temp file with the pages removed + val pagesToKeep = + media.pages.filterIndexed { index, page -> + pagesToDelete.find { candidate -> + candidate.fileHash == page.fileHash && + candidate.mediaType == page.mediaType && + candidate.fileName == page.fileName && + candidate.pageNumber == index + 1 + } == null + } + if (media.pages.size != (pagesToKeep.size + pagesToDelete.size)) { + logger.info { "Should be removing ${pagesToDelete.size} pages from book, but count doesn't add up, skipping" } + return null + } + + logger.info { "Start removal of ${pagesToDelete.size} pages for book: $book" } + logger.debug { "Pages: ${media.pages}" } + logger.debug { "Pages to delete: $pagesToDelete" } + logger.debug { "Pages to keep: $pagesToKeep" } + + val tempFile = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX, book.path.parent.toFile()).toPath() + logger.info { "Creating new file: $tempFile" } + ZipArchiveOutputStream(tempFile.outputStream()).use { zipStream -> + zipStream.setMethod(ZipArchiveOutputStream.DEFLATED) + zipStream.setLevel(Deflater.NO_COMPRESSION) + + pagesToKeep + .map { it.fileName } + .union(media.files.map { it.fileName }) + .forEach { entry -> + zipStream.putArchiveEntry(ZipArchiveEntry(entry)) + zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry)) + zipStream.closeArchiveEntry() + } + } + + // perform checks on new file + val createdBook = + fileSystemScanner + .scanFile(tempFile) + ?.copy( + id = book.id, + seriesId = book.seriesId, + libraryId = book.libraryId, + ) + ?: throw IllegalStateException("Newly created book could not be scanned: $tempFile") + + val createdMedia = bookAnalyzer.analyze(createdBook, libraryRepository.findById(book.libraryId).analyzeDimensions) + + try { + when { + createdMedia.status != Media.Status.READY + -> throw BookConversionException("Created file could not be analyzed, aborting page removal") + + createdMedia.mediaType != MediaType.ZIP.type + -> throw BookConversionException("Created file is not a zip file, aborting page removal") + + !createdMedia.pages + .map { FilenameUtils.getName(it.fileName) to it.mediaType } + .containsAll(pagesToKeep.map { FilenameUtils.getName(it.fileName) to it.mediaType }) + -> throw BookConversionException("Created file does not contain all pages to keep from existing file, aborting conversion") + + !createdMedia.files + .map { FilenameUtils.getName(it.fileName) } + .containsAll(media.files.map { FilenameUtils.getName(it.fileName) }) + -> throw BookConversionException("Created file does not contain all files from existing file, aborting page removal") + } + } catch (e: BookConversionException) { + tempFile.deleteIfExists() + failedPageRemoval += book.id + throw e + } + + tempFile.moveTo(book.path, true) + val newBook = + fileSystemScanner + .scanFile(book.path) + ?.copy( + id = book.id, + seriesId = book.seriesId, + libraryId = book.libraryId, + ) + ?: throw IllegalStateException("Newly created book could not be scanned after replacing existing one: ${book.path}") + + val mediaWithHashes = createdMedia.copy(pages = createdMedia.pages.restoreHashFrom(media.pages)) + + transactionTemplate.executeWithoutResult { + bookRepository.update(newBook) + mediaRepository.update(mediaWithHashes) + pagesToDelete + .mapNotNull { pageHashRepository.findKnown(it.fileHash) } + .forEach { pageHashRepository.update(it.copy(deleteCount = it.deleteCount + 1)) } + } + + pagesToDelete.forEach { historicalEventRepository.insert(HistoricalEvent.DuplicatePageDeleted(book, it)) } + eventPublisher.publishEvent(DomainEvent.BookUpdated(newBook)) + + return if (pagesToDelete.any { it.pageNumber == 1 }) BookAction.GENERATE_THUMBNAIL else null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt new file mode 100644 index 000000000..b91a4a996 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt @@ -0,0 +1,245 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.ScanResult +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.infrastructure.sidecar.SidecarBookConsumer +import org.gotson.komga.infrastructure.sidecar.SidecarSeriesConsumer +import org.springframework.stereotype.Service +import java.io.IOException +import java.net.URL +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.nameWithoutExtension +import kotlin.io.path.pathString +import kotlin.io.path.readAttributes +import kotlin.time.measureTime + +private val logger = KotlinLogging.logger {} + +@Service +class FileSystemScanner( + private val sidecarBookConsumers: List, + private val sidecarSeriesConsumers: List, +) { + private data class TempSidecar( + val name: String, + val url: URL, + val lastModifiedTime: LocalDateTime, + val type: Sidecar.Type? = null, + ) + + private val sidecarBookPrefilter = sidecarBookConsumers.flatMap { it.getSidecarBookPrefilter() } + + fun scanRootFolder( + root: Path, + forceDirectoryModifiedTime: Boolean = false, + oneshotsDir: String? = null, + scanCbx: Boolean = true, + scanPdf: Boolean = true, + scanEpub: Boolean = true, + directoryExclusions: Set = emptySet(), + ): ScanResult { + val scanForExtensions = + buildList { + if (scanCbx) addAll(listOf("cbz", "zip", "cbr", "rar")) + if (scanPdf) add("pdf") + if (scanEpub) add("epub") + } + logger.info { "Scanning folder: $root" } + logger.info { "Scan for extensions: $scanForExtensions" } + logger.info { "Excluded directory patterns: $directoryExclusions" } + logger.info { "Force directory modified time: $forceDirectoryModifiedTime" } + + if (!(Files.isDirectory(root) && Files.isReadable(root))) + throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016") + + val scannedSeries = mutableMapOf>() + val scannedSidecars = mutableListOf() + + measureTime { + // path is the series directory + val pathToSeries = mutableMapOf() + val pathToSeriesSidecars = mutableMapOf>() + // path is the book's parent directory, ie the series directory + val pathToBooks = mutableMapOf>() + val pathToBookSidecars = mutableMapOf>() + + Files.walkFileTree( + root, + setOf(FileVisitOption.FOLLOW_LINKS), + Integer.MAX_VALUE, + object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + logger.trace { "preVisit: $dir (regularFile:${attrs.isRegularFile}, directory:${attrs.isDirectory}, symbolicLink:${attrs.isSymbolicLink}, other:${attrs.isOther})" } + if (dir.name.startsWith(".") || + directoryExclusions.any { exclude -> + dir.pathString.contains(exclude, true) + } + ) + return FileVisitResult.SKIP_SUBTREE + + pathToSeries[dir] = + Series( + name = dir.name.ifBlank { dir.pathString }, + url = dir.toUri().toURL(), + fileLastModified = attrs.getUpdatedTime(), + ) + + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + logger.trace { "visitFile: $file (regularFile:${attrs.isRegularFile}, directory:${attrs.isDirectory}, symbolicLink:${attrs.isSymbolicLink}, other:${attrs.isOther})" } + if (!attrs.isSymbolicLink && !attrs.isDirectory) { + if (scanForExtensions.contains(file.extension.lowercase()) && + !file.name.startsWith(".") + ) { + val book = pathToBook(file, attrs) + file.parent.let { key -> + pathToBooks.merge(key, mutableListOf(book)) { prev, one -> prev.union(one).toMutableList() } + } + } + + sidecarSeriesConsumers + .firstOrNull { consumer -> + consumer.getSidecarSeriesFilenames().any { file.name.equals(it, ignoreCase = true) } + }?.let { + val sidecar = Sidecar(file.toUri().toURL(), file.parent.toUri().toURL(), attrs.getUpdatedTime(), it.getSidecarSeriesType(), Sidecar.Source.SERIES) + pathToSeriesSidecars.merge(file.parent, mutableListOf(sidecar)) { prev, one -> prev.union(one).toMutableList() } + } + + // book sidecars can't be exactly matched during a file visit + // this prefilters files to reduce the candidates + if (sidecarBookPrefilter.any { it.matches(file.name) }) { + val sidecar = TempSidecar(file.name, file.toUri().toURL(), attrs.getUpdatedTime()) + pathToBookSidecars.merge(file.parent, mutableListOf(sidecar)) { prev, one -> prev.union(one).toMutableList() } + } + } + + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path?, + exc: IOException?, + ): FileVisitResult { + logger.warn { "Could not access: $file" } + return FileVisitResult.SKIP_SUBTREE + } + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + logger.trace { "postVisit: $dir" } + val books = pathToBooks[dir] + val tempSeries = pathToSeries[dir] + if (!books.isNullOrEmpty() && tempSeries !== null) { + if (!oneshotsDir.isNullOrBlank() && dir.pathString.contains(oneshotsDir, true)) { + books.forEach { book -> + val series = + Series( + name = book.name, + url = book.url, + fileLastModified = book.fileLastModified, + oneshot = true, + ) + scannedSeries[series] = listOf(book.copy(oneshot = true)) + } + } else { + val series = + if (forceDirectoryModifiedTime) + tempSeries.copy(fileLastModified = maxOf(tempSeries.fileLastModified, books.maxOf { it.fileLastModified })) + else + tempSeries + + scannedSeries[series] = books + + // only add series sidecars if series has books + pathToSeriesSidecars[dir]?.let { scannedSidecars.addAll(it) } + } + + // book sidecars are matched here, with the actual list of books + books.forEach { book -> + val sidecars = + pathToBookSidecars[dir] + ?.mapNotNull { sidecar -> + sidecarBookConsumers.firstOrNull { it.isSidecarBookMatch(book.name, sidecar.name) }?.let { + sidecar to it.getSidecarBookType() + } + }?.toMap() ?: emptyMap() + pathToBookSidecars[dir]?.minusAssign(sidecars.keys) + + sidecars.mapTo(scannedSidecars) { (sidecar, type) -> + Sidecar(sidecar.url, book.url, sidecar.lastModifiedTime, type, Sidecar.Source.BOOK) + } + } + } + + return FileVisitResult.CONTINUE + } + }, + ) + }.also { + val countOfBooks = scannedSeries.values.sumOf { it.size } + logger.info { "Scanned ${scannedSeries.size} series, $countOfBooks books, and ${scannedSidecars.size} sidecars in $it" } + } + + return ScanResult(scannedSeries, scannedSidecars) + } + + fun scanFile(path: Path): Book? { + if (!path.exists()) return null + + return pathToBook(path, path.readAttributes()) + } + + fun scanBookSidecars(path: Path): List { + val bookBaseName = path.nameWithoutExtension + val parent = path.parent + return parent + .listDirectoryEntries() + .filter { candidate -> sidecarBookPrefilter.any { it.matches(candidate.name) } } + .mapNotNull { candidate -> + sidecarBookConsumers.firstOrNull { it.isSidecarBookMatch(bookBaseName, candidate.name) }?.let { + Sidecar(candidate.toUri().toURL(), parent.toUri().toURL(), candidate.readAttributes().getUpdatedTime(), it.getSidecarBookType(), Sidecar.Source.BOOK) + } + } + } + + private fun pathToBook( + path: Path, + attrs: BasicFileAttributes, + ): Book = + Book( + name = path.nameWithoutExtension, + url = path.toUri().toURL(), + fileLastModified = attrs.getUpdatedTime(), + fileSize = attrs.size(), + ) +} + +fun BasicFileAttributes.getUpdatedTime(): LocalDateTime = maxOf(creationTime(), lastModifiedTime()).toLocalDateTime() + +fun FileTime.toLocalDateTime(): LocalDateTime = LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt new file mode 100644 index 000000000..797b7485a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt @@ -0,0 +1,138 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.ApiKey +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.infrastructure.jooq.main.ClientSettingsDtoDao +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.security.TokenEncoder +import org.gotson.komga.infrastructure.security.apikey.ApiKeyGenerator +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate + +private val logger = KotlinLogging.logger {} + +@Service +class KomgaUserLifecycle( + private val userRepository: KomgaUserRepository, + private val readProgressRepository: ReadProgressRepository, + private val authenticationActivityRepository: AuthenticationActivityRepository, + private val syncPointRepository: SyncPointRepository, + private val passwordEncoder: PasswordEncoder, + private val tokenEncoder: TokenEncoder, + private val sessionRegistry: SessionRegistry, + private val transactionTemplate: TransactionTemplate, + private val eventPublisher: ApplicationEventPublisher, + private val apiKeyGenerator: ApiKeyGenerator, + private val clientSettingsDtoDao: ClientSettingsDtoDao, +) { + fun updatePassword( + user: KomgaUser, + newPassword: String, + expireSessions: Boolean, + ) { + logger.info { "Changing password for user ${user.email}" } + val updatedUser = user.copy(password = passwordEncoder.encode(newPassword)) + userRepository.update(updatedUser) + + if (expireSessions) expireSessions(updatedUser) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(updatedUser, expireSessions)) + } + + fun updateUser(user: KomgaUser) { + val existing = userRepository.findByIdOrNull(user.id) + requireNotNull(existing) { "User doesn't exist, cannot update: $user" } + + val toUpdate = user.copy(password = existing.password) + logger.info { "Update user: $toUpdate" } + userRepository.update(toUpdate) + + val expireSessions = + existing.roles != user.roles || + existing.restrictions != user.restrictions || + existing.sharedAllLibraries != user.sharedAllLibraries || + existing.sharedLibrariesIds != user.sharedLibrariesIds + + if (expireSessions) expireSessions(toUpdate) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(toUpdate, expireSessions)) + } + + fun countUsers() = userRepository.count() + + @Throws(UserEmailAlreadyExistsException::class) + fun createUser(komgaUser: KomgaUser): KomgaUser { + if (userRepository.existsByEmailIgnoreCase(komgaUser.email)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${komgaUser.email}") + + userRepository.insert(komgaUser.copy(password = passwordEncoder.encode(komgaUser.password))) + + val createdUser = userRepository.findByIdOrNull(komgaUser.id)!! + logger.info { "User created: $createdUser" } + return createdUser + } + + fun deleteUser(user: KomgaUser) { + logger.info { "Deleting user: $user" } + + transactionTemplate.executeWithoutResult { + clientSettingsDtoDao.deleteByUserId(user.id) + readProgressRepository.deleteByUserId(user.id) + authenticationActivityRepository.deleteByUser(user) + syncPointRepository.deleteByUserId(user.id) + userRepository.delete(user.id) + } + + expireSessions(user) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(user, true)) + } + + fun expireSessions(user: KomgaUser) { + logger.info { "Expiring all sessions for user: ${user.email}" } + sessionRegistry + .getAllSessions(KomgaPrincipal(user), false) + .forEach { + logger.info { "Expiring session: ${it.sessionId}" } + it.expireNow() + } + } + + /** + * Create and persist an API key for the user. + * @return the ApiKey, or null if a unique API key could not be generated + */ + fun createApiKey( + user: KomgaUser, + comment: String, + ): ApiKey? { + val commentTrimmed = comment.trim() + if (userRepository.existsApiKeyByCommentAndUserId(commentTrimmed, user.id)) + throw DuplicateNameException("api key comment already exists for this user", "ERR_1034") + for (attempt in 1..10) { + try { + val plainTextKey = + ApiKey( + userId = user.id, + key = apiKeyGenerator.generate(), + comment = commentTrimmed, + ) + userRepository.insert(plainTextKey.copy(key = tokenEncoder.encode(plainTextKey.key))) + return plainTextKey + } catch (e: Exception) { + logger.debug { "Failed to generate unique api key, attempt #$attempt" } + } + } + return null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt new file mode 100644 index 000000000..3bab7cf77 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt @@ -0,0 +1,473 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SidecarRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.language.notEquals +import org.gotson.komga.language.toIndexedMap +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.nio.file.Paths +import java.time.LocalDateTime +import kotlin.time.measureTime + +private val logger = KotlinLogging.logger {} + +@Service +class LibraryContentLifecycle( + private val fileSystemScanner: FileSystemScanner, + private val seriesRepository: SeriesRepository, + private val bookRepository: BookRepository, + private val libraryRepository: LibraryRepository, + private val bookLifecycle: BookLifecycle, + private val mediaRepository: MediaRepository, + private val seriesLifecycle: SeriesLifecycle, + private val collectionLifecycle: SeriesCollectionLifecycle, + private val readListLifecycle: ReadListLifecycle, + private val sidecarRepository: SidecarRepository, + private val komgaSettingsProvider: KomgaSettingsProvider, + private val taskEmitter: TaskEmitter, + private val transactionTemplate: TransactionTemplate, + private val hasher: Hasher, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val readListRepository: ReadListRepository, + private val readProgressRepository: ReadProgressRepository, + private val collectionRepository: SeriesCollectionRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val eventPublisher: ApplicationEventPublisher, + private val thumbnailSeriesRepository: ThumbnailSeriesRepository, +) { + fun scanRootFolder( + library: Library, + scanDeep: Boolean = false, + ) { + logger.info { "Scan root folder for library: $library" } + measureTime { + val scanResult = + try { + fileSystemScanner.scanRootFolder( + Paths.get(library.root.toURI()), + library.scanForceModifiedTime, + library.oneshotsDirectory, + library.scanCbx, + library.scanPdf, + library.scanEpub, + library.scanDirectoryExclusions, + ) + } catch (e: DirectoryNotFoundException) { + library.copy(unavailableDate = LocalDateTime.now()).let { + libraryRepository.update(it) + eventPublisher.publishEvent(DomainEvent.LibraryUpdated(it)) + } + throw e + } + + if (library.unavailableDate != null) { + library.copy(unavailableDate = null).let { + libraryRepository.update(it) + eventPublisher.publishEvent(DomainEvent.LibraryUpdated(it)) + } + } + + val scannedSeries = + scanResult + .series + .map { (series, books) -> + series.copy(libraryId = library.id) to books.map { it.copy(libraryId = library.id) } + }.toMap() + + // delete series that don't exist anymore + if (scannedSeries.isEmpty()) { + logger.info { "Scan returned no series, soft deleting all existing series" } + val series = seriesRepository.findAllByLibraryId(library.id) + seriesLifecycle.softDeleteMany(series) + } else { + scannedSeries.keys.map { it.url }.let { urls -> + val series = seriesRepository.findAllNotDeletedByLibraryIdAndUrlNotIn(library.id, urls) + if (series.isNotEmpty()) { + logger.info { "Soft deleting series not on disk anymore: $series" } + seriesLifecycle.softDeleteMany(series) + } + } + } + + // delete books that don't exist anymore. We need to do this now, so trash bin can work + val seriesToSortAndRefresh = + scannedSeries.values.flatten().map { it.url }.let { urls -> + val books = bookRepository.findAllNotDeletedByLibraryIdAndUrlNotIn(library.id, urls) + if (books.isNotEmpty()) { + logger.info { "Soft deleting books not on disk anymore: $books" } + bookLifecycle.softDeleteMany(books) + books + .map { it.seriesId } + .distinct() + .mapNotNull { seriesRepository.findByIdOrNull(it) } + .toMutableList() + } else { + mutableListOf() + } + } + // we store the url of all the series that had deleted books + // this can be used to detect changed series even if their file modified date did not change, for example because of NFS/SMB cache + val seriesUrlWithDeletedBooks = seriesToSortAndRefresh.map { it.url } + + scannedSeries.forEach { (newSeries, newBooks) -> + val existingSeries = seriesRepository.findNotDeletedByLibraryIdAndUrlOrNull(library.id, newSeries.url) + + // if series does not exist, save it + if (existingSeries == null) { + logger.info { "Adding new series: $newSeries" } + val createdSeries = seriesLifecycle.createSeries(newSeries) + seriesLifecycle.addBooks(createdSeries, newBooks) + tryRestoreSeries(createdSeries, newBooks) + tryRestoreBooks(newBooks) + seriesToSortAndRefresh.add(createdSeries) + } else { + // if series already exists, update it + logger.debug { "Scanned series already exists. Scanned: $newSeries, Existing: $existingSeries" } + val seriesChanged = newSeries.fileLastModified.notEquals(existingSeries.fileLastModified) || existingSeries.deletedDate != null || seriesUrlWithDeletedBooks.contains(newSeries.url) + if (seriesChanged) { + logger.info { "Series changed on disk, updating: $existingSeries" } + seriesRepository.update(existingSeries.copy(fileLastModified = newSeries.fileLastModified, deletedDate = null)) + } + if (scanDeep || seriesChanged) { + // update list of books with existing entities if they exist + val existingBooks = bookRepository.findAllBySeriesId(existingSeries.id) + logger.debug { "Existing books: $existingBooks" } + + // update existing books + newBooks.forEach { newBook -> + logger.debug { "Trying to match scanned book by url: $newBook" } + existingBooks.find { it.url == newBook.url && it.deletedDate == null }?.let { existingBook -> + logger.debug { "Matched existing book: $existingBook" } + if (newBook.fileLastModified.notEquals(existingBook.fileLastModified)) { + val hash = + if (existingBook.fileSize == newBook.fileSize && existingBook.fileHash.isNotBlank()) { + hasher.computeHash(newBook.path) + } else { + null + } + if (hash == existingBook.fileHash) { + logger.info { "Book changed on disk, but still has the same hash, no need to reset media status: $existingBook" } + val updatedBook = + existingBook.copy( + fileLastModified = newBook.fileLastModified, + fileSize = newBook.fileSize, + fileHash = hash, + ) + bookRepository.update(updatedBook) + } else { + logger.info { "Book changed on disk, update and reset media status: $existingBook" } + val updatedBook = + existingBook.copy( + fileLastModified = newBook.fileLastModified, + fileSize = newBook.fileSize, + fileHash = hash ?: "", + ) + transactionTemplate.executeWithoutResult { + mediaRepository.findById(existingBook.id).let { + mediaRepository.update(it.copy(status = Media.Status.OUTDATED)) + } + bookRepository.update(updatedBook) + } + } + } + } + } + + // add new books + val existingBooksUrls = existingBooks.filterNot { it.deletedDate != null }.map { it.url } + val booksToAdd = newBooks.filterNot { newBook -> existingBooksUrls.contains(newBook.url) } + logger.info { "Adding new books: $booksToAdd" } + seriesLifecycle.addBooks(existingSeries, booksToAdd) + tryRestoreBooks(booksToAdd) + seriesToSortAndRefresh.add(existingSeries) + } + } + } + + // for all series where books have been removed or added, trigger a sort and refresh metadata + seriesToSortAndRefresh.distinctBy { it.id }.forEach { + seriesLifecycle.sortBooks(it) + taskEmitter.refreshSeriesMetadata(it.id) + } + + val existingSidecars = sidecarRepository.findAll() + scanResult.sidecars.forEach { newSidecar -> + val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url } + if (existingSidecar == null || existingSidecar.lastModifiedTime.notEquals(newSidecar.lastModifiedTime)) { + when (newSidecar.source) { + Sidecar.Source.SERIES -> + seriesRepository.findNotDeletedByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { series -> + logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Series for ${newSidecar.type}: $series" } + when (newSidecar.type) { + Sidecar.Type.ARTWORK -> taskEmitter.refreshSeriesLocalArtwork(series.id) + Sidecar.Type.METADATA -> taskEmitter.refreshSeriesMetadata(series.id) + } + } + + Sidecar.Source.BOOK -> + bookRepository.findNotDeletedByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book -> + logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Book for ${newSidecar.type}: $book" } + when (newSidecar.type) { + Sidecar.Type.ARTWORK -> taskEmitter.refreshBookLocalArtwork(book) + Sidecar.Type.METADATA -> taskEmitter.refreshBookMetadata(book) + } + } + } + sidecarRepository.save(library.id, newSidecar) + } + } + + // cleanup sidecars that don't exist anymore + scanResult.sidecars.map { it.url }.let { newSidecarsUrls -> + existingSidecars + .filterNot { existing -> newSidecarsUrls.contains(existing.url) } + .let { sidecars -> + sidecarRepository.deleteByLibraryIdAndUrls(library.id, sidecars.map { it.url }) + } + } + + if (library.emptyTrashAfterScan) + emptyTrash(library) + else + cleanupEmptySets() + }.also { logger.info { "Library updated in $it" } } + + eventPublisher.publishEvent(DomainEvent.LibraryScanned(library)) + } + + /** + * This will try to match newSeries with a deleted series. + * Series are matched if: + * - they have the same number of books + * - all the books are matched by file size and file hash + * + * If a series is matched, the following will be restored from the deleted series to the new series: + * - Collections + * - Metadata. The metadata title will only be copied if locked. If not locked, the folder name is used. + * - all books, via #tryRestoreBooks + */ + private fun tryRestoreSeries( + newSeries: Series, + newBooks: List, + ) { + logger.info { "Try to restore series: $newSeries" } + val bookSizes = newBooks.map { it.fileSize } + + val deletedCandidates = + seriesRepository + .findAll(SearchCondition.Deleted(SearchOperator.IsTrue), SearchContext.empty(), Pageable.unpaged()) + .content + .mapNotNull { deletedCandidate -> + val deletedBooks = bookRepository.findAllBySeriesId(deletedCandidate.id) + val deletedBooksSizes = deletedBooks.map { it.fileSize } + if (newBooks.size == deletedBooks.size && bookSizes.containsAll(deletedBooksSizes) && deletedBooksSizes.containsAll(bookSizes) && deletedBooks.all { it.fileHash.isNotBlank() }) { + deletedCandidate to deletedBooks + } else { + null + } + } + logger.debug { "Deleted series candidates: $deletedCandidates" } + + if (deletedCandidates.isNotEmpty()) { + val newBooksWithHash = newBooks.map { book -> bookRepository.findByIdOrNull(book.id)!!.copy(fileHash = hasher.computeHash(book.path)) } + bookRepository.update(newBooksWithHash) + + val match = + deletedCandidates.find { (_, books) -> + books.map { it.fileHash }.containsAll(newBooksWithHash.map { it.fileHash }) && newBooksWithHash.map { it.fileHash }.containsAll(books.map { it.fileHash }) + } + + if (match != null) { + // restore series + logger.info { "Match found, restore $match into $newSeries" } + transactionTemplate.executeWithoutResult { + // copy metadata + seriesMetadataRepository.findById(match.first.id).let { deleted -> + val newlyAdded = seriesMetadataRepository.findById(newSeries.id) + seriesMetadataRepository.update( + deleted.copy( + seriesId = newSeries.id, + title = if (deleted.titleLock) deleted.title else newlyAdded.title, + titleSort = if (deleted.titleSortLock) deleted.titleSort else newlyAdded.titleSort, + ), + ) + } + + // copy user uploaded thumbnails + thumbnailSeriesRepository.findAllBySeriesIdIdAndType(match.first.id, ThumbnailSeries.Type.USER_UPLOADED).forEach { deleted -> + thumbnailSeriesRepository.update(deleted.copy(seriesId = newSeries.id)) + } + + // replace deleted series by new series in collections + collectionRepository + .findAllContainingSeriesId(match.first.id, filterOnLibraryIds = null) + .forEach { col -> + collectionRepository.update( + col.copy( + seriesIds = col.seriesIds.map { if (it == match.first.id) newSeries.id else it }, + ), + ) + } + + tryRestoreBooks(newBooksWithHash) + + // delete upgraded series + seriesLifecycle.deleteMany(listOf(match.first)) + } + } + } + } + + /** + * This will try to match each book in newBooks with a deleted book. + * Books are matched by file size, then by file hash. + * + * If a book is matched, the following will be restored from the deleted book to the new book: + * - Media + * - Read Progress + * - Read Lists + * - Metadata. The metadata title will only be copied if locked. If not locked, the filename is used, but a refresh for Title will be requested. + */ + private fun tryRestoreBooks(newBooks: List) { + logger.info { "Try to restore books: $newBooks" } + newBooks.forEach { bookToAdd -> + // try to find a deleted book that matches the file size + val deletedCandidates = bookRepository.findAllDeletedByFileSize(bookToAdd.fileSize).filter { it.fileHash.isNotBlank() } + logger.debug { "Deleted candidates: $deletedCandidates" } + + if (deletedCandidates.isNotEmpty()) { + // if the book has no hash, compute the hash and store it + val bookWithHash = + if (bookToAdd.fileHash.isNotBlank()) + bookToAdd + else + bookRepository.findByIdOrNull(bookToAdd.id)!!.copy(fileHash = hasher.computeHash(bookToAdd.path)).also { bookRepository.update(it) } + + val match = deletedCandidates.find { it.fileHash == bookWithHash.fileHash } + + if (match != null) { + // restore book + logger.info { "Match found, restore $match into $bookToAdd" } + transactionTemplate.executeWithoutResult { + // copy media + mediaRepository.copy(match.id, bookToAdd.id) + + // copy generated and user uploaded thumbnails + thumbnailBookRepository.findAllByBookIdAndType(match.id, setOf(ThumbnailBook.Type.GENERATED, ThumbnailBook.Type.USER_UPLOADED)).forEach { deleted -> + thumbnailBookRepository.update(deleted.copy(bookId = bookToAdd.id)) + } + + // copy metadata + bookMetadataRepository.findById(match.id).let { deleted -> + val newlyAdded = bookMetadataRepository.findById(bookToAdd.id) + bookMetadataRepository.update( + deleted.copy( + bookId = bookToAdd.id, + title = if (deleted.titleLock) deleted.title else newlyAdded.title, + ), + ) + if (!deleted.titleLock) taskEmitter.refreshBookMetadata(bookToAdd, setOf(BookMetadataPatchCapability.TITLE)) + } + + // copy read progress + readProgressRepository + .findAllByBookId(match.id) + .map { it.copy(bookId = bookToAdd.id) } + .forEach { readProgressRepository.save(it) } + + // replace deleted book by new book in read lists + readListRepository + .findAllContainingBookId(match.id, filterOnLibraryIds = null) + .forEach { rl -> + readListRepository.update( + rl.copy( + bookIds = + rl.bookIds.values + .map { if (it == match.id) bookToAdd.id else it } + .toIndexedMap(), + ), + ) + } + + // delete soft-deleted book + bookLifecycle.deleteOne(match) + } + } + } + } + } + + fun emptyTrash(library: Library) { + logger.info { "Empty trash for library: $library" } + + val seriesToDelete = + seriesRepository + .findAll( + SearchCondition.AllOfSeries( + SearchCondition.LibraryId(SearchOperator.Is(library.id)), + SearchCondition.Deleted(SearchOperator.IsTrue), + ), + SearchContext.empty(), + Pageable.unpaged(), + ).content + seriesLifecycle.deleteMany(seriesToDelete) + + val booksToDelete = + bookRepository + .findAll( + SearchCondition.AllOfBook( + SearchCondition.LibraryId(SearchOperator.Is(library.id)), + SearchCondition.Deleted(SearchOperator.IsTrue), + ), + SearchContext.empty(), + Pageable.unpaged(), + ).content + bookLifecycle.deleteMany(booksToDelete) + booksToDelete.map { it.seriesId }.distinct().forEach { seriesId -> + seriesRepository.findByIdOrNull(seriesId)?.let { seriesLifecycle.sortBooks(it) } + } + + cleanupEmptySets() + } + + private fun cleanupEmptySets() { + if (komgaSettingsProvider.deleteEmptyCollections) { + collectionLifecycle.deleteEmptyCollections() + } + + if (komgaSettingsProvider.deleteEmptyReadLists) { + readListLifecycle.deleteEmptyReadLists() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt new file mode 100644 index 000000000..0f0b1d121 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt @@ -0,0 +1,132 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.scheduler.LibraryScanScheduler +import org.gotson.komga.application.tasks.LOWEST_PRIORITY +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SidecarRepository +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.io.FileNotFoundException +import java.nio.file.Files + +private val logger = KotlinLogging.logger {} + +@Service +class LibraryLifecycle( + private val libraryRepository: LibraryRepository, + private val seriesLifecycle: SeriesLifecycle, + private val seriesRepository: SeriesRepository, + private val sidecarRepository: SidecarRepository, + private val taskEmitter: TaskEmitter, + private val eventPublisher: ApplicationEventPublisher, + private val transactionTemplate: TransactionTemplate, + private val libraryScanScheduler: LibraryScanScheduler, +) { + @Throws( + FileNotFoundException::class, + DirectoryNotFoundException::class, + DuplicateNameException::class, + PathContainedInPath::class, + ) + fun addLibrary(library: Library): Library { + logger.info { "Adding new library: ${library.name} with root folder: ${library.root}" } + + val existing = libraryRepository.findAll() + checkLibraryValidity(library, existing) + + libraryRepository.insert(library) + taskEmitter.scanLibrary(library.id) + + eventPublisher.publishEvent(DomainEvent.LibraryAdded(library)) + + return libraryRepository.findById(library.id) + } + + fun updateLibrary(toUpdate: Library) { + logger.info { "Updating library: ${toUpdate.id}" } + + val libraries = libraryRepository.findAll() + val otherLibraries = libraries.filter { it.id != toUpdate.id } + val current = libraries.first { it.id == toUpdate.id } + checkLibraryValidity(toUpdate, otherLibraries) + + libraryRepository.update(toUpdate) + + if (current.scanInterval != toUpdate.scanInterval) + libraryScanScheduler.scheduleScan(toUpdate) + + if (checkLibraryShouldRescan(current, toUpdate)) + taskEmitter.scanLibrary(toUpdate.id) + + if (toUpdate.hashFiles && !current.hashFiles) + taskEmitter.hashBooksWithoutHash(toUpdate) + if (toUpdate.hashKoreader && !current.hashKoreader) + taskEmitter.hashBooksWithoutHashKoreader(toUpdate) + if (toUpdate.hashPages && !current.hashPages) + taskEmitter.findBooksWithMissingPageHash(toUpdate, LOWEST_PRIORITY) + if (toUpdate.repairExtensions && !current.repairExtensions) + taskEmitter.repairExtensions(toUpdate, LOWEST_PRIORITY) + if (toUpdate.convertToCbz && !current.convertToCbz) + taskEmitter.findBooksToConvert(toUpdate, LOWEST_PRIORITY) + + eventPublisher.publishEvent(DomainEvent.LibraryUpdated(toUpdate)) + } + + private fun checkLibraryShouldRescan( + existing: Library, + updated: Library, + ): Boolean { + if (existing.root != updated.root) return true + if (existing.oneshotsDirectory != updated.oneshotsDirectory) return true + if (existing.scanCbx != updated.scanCbx) return true + if (existing.scanPdf != updated.scanPdf) return true + if (existing.scanEpub != updated.scanEpub) return true + if (existing.scanForceModifiedTime != updated.scanForceModifiedTime) return true + if (existing.scanDirectoryExclusions != updated.scanDirectoryExclusions) return true + return false + } + + private fun checkLibraryValidity( + library: Library, + existing: Collection, + ) { + if (!Files.exists(library.path)) + throw FileNotFoundException("Library root folder does not exist: ${library.root}") + + if (!Files.isDirectory(library.path)) + throw DirectoryNotFoundException("Library root folder is not a folder: ${library.root}") + + if (existing.map { it.name }.contains(library.name)) + throw DuplicateNameException("Library name already exists") + + existing.forEach { + if (library.path.startsWith(it.path)) + throw PathContainedInPath("Library path ${library.path} is a child of existing library ${it.name}: ${it.path}") + if (it.path.startsWith(library.path)) + throw PathContainedInPath("Library path ${library.path} is a parent of existing library ${it.name}: ${it.path}") + } + } + + fun deleteLibrary(library: Library) { + logger.info { "Deleting library: $library" } + + val series = seriesRepository.findAllByLibraryId(library.id) + transactionTemplate.executeWithoutResult { + seriesLifecycle.deleteMany(series) + sidecarRepository.deleteByLibraryId(library.id) + + libraryRepository.delete(library.id) + } + + eventPublisher.publishEvent(DomainEvent.LibraryDeleted(library)) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt new file mode 100644 index 000000000..8e612aa1e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class LocalArtworkLifecycle( + private val libraryRepository: LibraryRepository, + private val bookLifecycle: BookLifecycle, + private val seriesLifecycle: SeriesLifecycle, + private val localArtworkProvider: LocalArtworkProvider, +) { + fun refreshLocalArtwork(book: Book) { + logger.info { "Refresh local artwork for book: $book" } + val library = libraryRepository.findById(book.libraryId) + + if (library.importLocalArtwork) + localArtworkProvider.getBookThumbnails(book).forEach { + bookLifecycle.addThumbnailForBook(it, if (it.selected) MarkSelectedPreference.IF_NONE_OR_GENERATED else MarkSelectedPreference.NO) + } + else + logger.info { "Library is not set to import local artwork, skipping" } + } + + fun refreshLocalArtwork(series: Series) { + logger.info { "Refresh local artwork for series: $series" } + val library = libraryRepository.findById(series.libraryId) + + if (library.importLocalArtwork) + localArtworkProvider.getSeriesThumbnails(series).forEach { + seriesLifecycle.addThumbnailForSeries(it, if (it.selected) MarkSelectedPreference.IF_NONE_OR_GENERATED else MarkSelectedPreference.NO) + } + else + logger.info { "Library is not set to import local artwork, skipping" } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt new file mode 100644 index 000000000..eb970a7a7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookMetadataAggregation +import org.springframework.stereotype.Service + +@Service +class MetadataAggregator { + fun aggregate(metadatas: Collection): BookMetadataAggregation { + val authors = metadatas.flatMap { it.authors }.distinctBy { "${it.role}__${it.name}" } + val tags = metadatas.flatMap { it.tags }.toSet() + val (summary, summaryNumber) = + metadatas + .sortedBy { it.numberSort } + .find { it.summary.isNotBlank() } + ?.let { + it.summary to it.number + } ?: ("" to "") + val releaseDate = metadatas.mapNotNull { it.releaseDate }.minOrNull() + + return BookMetadataAggregation(authors = authors, tags = tags, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt new file mode 100644 index 000000000..9168a607e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt @@ -0,0 +1,57 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.springframework.stereotype.Service + +@Service +class MetadataApplier { + private fun getIfNotLocked( + original: T, + patched: T?, + lock: Boolean, + ): T = + if (patched != null && !lock) + patched + else + original + + fun apply( + patch: BookMetadataPatch, + metadata: BookMetadata, + ): BookMetadata = + with(metadata) { + copy( + title = getIfNotLocked(title, patch.title, titleLock), + summary = getIfNotLocked(summary, patch.summary, summaryLock), + number = getIfNotLocked(number, patch.number, numberLock), + numberSort = getIfNotLocked(numberSort, patch.numberSort, numberSortLock), + releaseDate = getIfNotLocked(releaseDate, patch.releaseDate, releaseDateLock), + authors = getIfNotLocked(authors, patch.authors, authorsLock), + isbn = getIfNotLocked(isbn, patch.isbn, isbnLock), + links = getIfNotLocked(links, patch.links, linksLock), + tags = getIfNotLocked(tags, patch.tags, tagsLock), + ) + } + + fun apply( + patch: SeriesMetadataPatch, + metadata: SeriesMetadata, + ): SeriesMetadata = + with(metadata) { + copy( + status = getIfNotLocked(status, patch.status, statusLock), + title = getIfNotLocked(title, patch.title, titleLock), + titleSort = getIfNotLocked(titleSort, patch.titleSort, titleSortLock), + summary = getIfNotLocked(summary, patch.summary, summaryLock), + readingDirection = getIfNotLocked(readingDirection, patch.readingDirection, readingDirectionLock), + ageRating = getIfNotLocked(ageRating, patch.ageRating, ageRatingLock), + publisher = getIfNotLocked(publisher, patch.publisher, publisherLock), + language = getIfNotLocked(language, patch.language, languageLock), + genres = getIfNotLocked(genres, patch.genres, genresLock), + totalBookCount = getIfNotLocked(totalBookCount, patch.totalBookCount, totalBookCountLock), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/PageHashLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/PageHashLifecycle.kt new file mode 100644 index 000000000..dfd0f9701 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/PageHashLifecycle.kt @@ -0,0 +1,58 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.PageHashRepository +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class PageHashLifecycle( + private val pageHashRepository: PageHashRepository, + private val mediaRepository: MediaRepository, + private val bookLifecycle: BookLifecycle, + private val bookRepository: BookRepository, + private val komgaProperties: KomgaProperties, +) { + private val hashableMediaTypes = listOf(MediaType.ZIP.type) + + fun getBookIdsWithMissingPageHash(library: Library): Collection = + if (library.hashPages) { + mediaRepository + .findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(library.id, hashableMediaTypes, komgaProperties.pageHashing) + .also { logger.info { "Found ${it.size} books with missing page hash" } } + } else { + logger.info { "Page hashing is not enabled, skipping" } + emptyList() + } + + fun getPage( + pageHash: String, + resizeTo: Int? = null, + ): TypedBytes? { + val match = pageHashRepository.findMatchesByHash(pageHash, Pageable.ofSize(1)).firstOrNull() ?: return null + val book = bookRepository.findByIdOrNull(match.bookId) ?: return null + + return bookLifecycle.getBookPage(book, match.pageNumber, resizeTo = resizeTo) + } + + fun getBookPagesToDeleteAutomatically(library: Library): Map> = pageHashRepository.findMatchesByKnownHashAction(listOf(PageHashKnown.Action.DELETE_AUTO), library.id) + + fun createOrUpdate(pageHash: PageHashKnown) { + val existing = pageHashRepository.findKnown(pageHash.hash) + if (existing == null) { + pageHashRepository.insert(pageHash, getPage(pageHash.hash, 500)?.bytes) + } else { + pageHashRepository.update(existing.copy(action = pageHash.action)) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt new file mode 100644 index 000000000..95554c13d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt @@ -0,0 +1,190 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadListRequestMatch +import org.gotson.komga.domain.model.ThumbnailReadList +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository +import org.gotson.komga.infrastructure.image.MosaicGenerator +import org.gotson.komga.infrastructure.metadata.comicrack.ReadListProvider +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate + +private val logger = KotlinLogging.logger {} + +@Service +class ReadListLifecycle( + private val readListRepository: ReadListRepository, + private val thumbnailReadListRepository: ThumbnailReadListRepository, + private val bookLifecycle: BookLifecycle, + private val mosaicGenerator: MosaicGenerator, + private val readListMatcher: ReadListMatcher, + private val readListProvider: ReadListProvider, + private val eventPublisher: ApplicationEventPublisher, + private val transactionTemplate: TransactionTemplate, +) { + @Throws( + DuplicateNameException::class, + ) + @Transactional + fun addReadList(readList: ReadList): ReadList { + logger.info { "Adding new read list: $readList" } + + if (readListRepository.existsByName(readList.name)) + throw DuplicateNameException("Read list name already exists") + + readListRepository.insert(readList) + + eventPublisher.publishEvent(DomainEvent.ReadListAdded(readList)) + + return readListRepository.findByIdOrNull(readList.id)!! + } + + @Transactional + fun updateReadList(toUpdate: ReadList) { + logger.info { "Update read list: $toUpdate" } + val existing = + readListRepository.findByIdOrNull(toUpdate.id) + ?: throw IllegalArgumentException("Cannot update read list that does not exist") + + if (!existing.name.equals(toUpdate.name, true) && readListRepository.existsByName(toUpdate.name)) + throw DuplicateNameException("Read list name already exists") + + readListRepository.update(toUpdate) + + eventPublisher.publishEvent(DomainEvent.ReadListUpdated(toUpdate)) + } + + fun deleteReadList(readList: ReadList) { + transactionTemplate.executeWithoutResult { + thumbnailReadListRepository.deleteByReadListId(readList.id) + readListRepository.delete(readList.id) + } + + eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList)) + } + + /** + * Add book to read list by name. + * Read list will be created if it doesn't exist. + */ + @Transactional + fun addBookToReadList( + readListName: String, + book: Book, + numberInList: Int?, + ) { + readListRepository.findByNameOrNull(readListName).let { existing -> + if (existing != null) { + if (existing.bookIds.containsValue(book.id)) { + logger.debug { "Book is already in existing read list '${existing.name}'" } + } else { + val map = existing.bookIds.toSortedMap() + val key = + if (numberInList != null && existing.bookIds.containsKey(numberInList)) { + logger.debug { "Existing read list '${existing.name}' already contains a book at position $numberInList, adding book '${book.name}' at the end" } + existing.bookIds.lastKey() + 1 + } else { + logger.debug { "Adding book '${book.name}' to existing read list '${existing.name}'" } + numberInList ?: (existing.bookIds.lastKey() + 1) + } + map[key] = book.id + updateReadList( + existing.copy(bookIds = map), + ) + } + } else { + logger.debug { "Adding book '${book.name}' to new read list '$readListName'" } + addReadList( + ReadList( + name = readListName, + bookIds = mapOf((numberInList ?: 0) to book.id).toSortedMap(), + ), + ) + } + } + } + + fun deleteEmptyReadLists() { + logger.info { "Deleting empty read lists" } + val toDelete = readListRepository.findAllEmpty() + transactionTemplate.executeWithoutResult { + thumbnailReadListRepository.deleteByReadListIds(toDelete.map { it.id }) + readListRepository.delete(toDelete.map { it.id }) + } + + toDelete.forEach { eventPublisher.publishEvent(DomainEvent.ReadListDeleted(it)) } + } + + fun addThumbnail(thumbnail: ThumbnailReadList): ThumbnailReadList { + when (thumbnail.type) { + ThumbnailReadList.Type.USER_UPLOADED -> { + thumbnailReadListRepository.insert(thumbnail) + if (thumbnail.selected) { + thumbnailReadListRepository.markSelected(thumbnail) + } + } + } + + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail)) + return thumbnail + } + + fun markSelectedThumbnail(thumbnail: ThumbnailReadList) { + thumbnailReadListRepository.markSelected(thumbnail) + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail.copy(selected = true))) + } + + fun deleteThumbnail(thumbnail: ThumbnailReadList) { + thumbnailReadListRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.readListId) + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListDeleted(thumbnail)) + } + + fun getThumbnailBytes(readList: ReadList): ByteArray { + thumbnailReadListRepository.findSelectedByReadListIdOrNull(readList.id)?.let { + return it.thumbnail + } + + val ids = + with(mutableListOf()) { + while (size < 4) { + this += readList.bookIds.values.take(4) + } + this.take(4) + } + + val images = ids.mapNotNull { bookLifecycle.getThumbnailBytes(it)?.bytes } + return mosaicGenerator.createMosaic(images) + } + + fun matchComicRackList(fileContent: ByteArray): ReadListRequestMatch { + val request = readListProvider.importFromCbl(fileContent) + + return readListMatcher.matchReadListRequest(request) + } + + private fun thumbnailsHouseKeeping(readListId: String) { + logger.info { "House keeping thumbnails for read list: $readListId" } + val all = thumbnailReadListRepository.findAllByReadListId(readListId) + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailReadListRepository.markSelected(selected[0]) + } + + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Read list has no selected thumbnail, choosing one automatically" } + thumbnailReadListRepository.markSelected(all.first()) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt new file mode 100644 index 000000000..f737593ea --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.ReadListMatch +import org.gotson.komga.domain.model.ReadListRequest +import org.gotson.komga.domain.model.ReadListRequestMatch +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadListRequestRepository +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class ReadListMatcher( + private val readListRepository: ReadListRepository, + private val readListRequestRepository: ReadListRequestRepository, +) { + fun matchReadListRequest(request: ReadListRequest): ReadListRequestMatch { + logger.info { "Trying to match $request" } + + val readListMatch = + if (readListRepository.existsByName(request.name)) + ReadListMatch(request.name, "ERR_1009") + else + ReadListMatch(request.name) + + val matches = readListRequestRepository.matchBookRequests(request.books) + + return ReadListRequestMatch(readListMatch, matches) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt new file mode 100644 index 000000000..e34a46627 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt @@ -0,0 +1,172 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.ThumbnailSeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository +import org.gotson.komga.infrastructure.image.MosaicGenerator +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate + +private val logger = KotlinLogging.logger {} + +@Service +class SeriesCollectionLifecycle( + private val collectionRepository: SeriesCollectionRepository, + private val thumbnailSeriesCollectionRepository: ThumbnailSeriesCollectionRepository, + private val seriesLifecycle: SeriesLifecycle, + private val mosaicGenerator: MosaicGenerator, + private val eventPublisher: ApplicationEventPublisher, + private val transactionTemplate: TransactionTemplate, +) { + @Throws( + DuplicateNameException::class, + ) + @Transactional + fun addCollection(collection: SeriesCollection): SeriesCollection { + logger.info { "Adding new collection: $collection" } + + if (collectionRepository.existsByName(collection.name)) + throw DuplicateNameException("Collection name already exists") + + collectionRepository.insert(collection) + + eventPublisher.publishEvent(DomainEvent.CollectionAdded(collection)) + + return collectionRepository.findByIdOrNull(collection.id)!! + } + + @Transactional + fun updateCollection(toUpdate: SeriesCollection) { + logger.info { "Update collection: $toUpdate" } + + val existing = + collectionRepository.findByIdOrNull(toUpdate.id) + ?: throw IllegalArgumentException("Cannot update collection that does not exist") + + if (!existing.name.equals(toUpdate.name, true) && collectionRepository.existsByName(toUpdate.name)) + throw DuplicateNameException("Collection name already exists") + + collectionRepository.update(toUpdate) + + eventPublisher.publishEvent(DomainEvent.CollectionUpdated(toUpdate)) + } + + fun deleteCollection(collection: SeriesCollection) { + transactionTemplate.executeWithoutResult { + thumbnailSeriesCollectionRepository.deleteByCollectionId(collection.id) + collectionRepository.delete(collection.id) + } + eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection)) + } + + /** + * Add series to collection by name. + * Collection will be created if it doesn't exist. + */ + @Transactional + fun addSeriesToCollection( + collectionName: String, + series: Series, + ) { + collectionRepository.findByNameOrNull(collectionName).let { existing -> + if (existing != null) { + if (existing.seriesIds.contains(series.id)) { + logger.debug { "Series is already in existing collection '${existing.name}'" } + } else { + logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" } + updateCollection( + existing.copy(seriesIds = existing.seriesIds + series.id), + ) + } + } else { + logger.debug { "Adding series '${series.name}' to new collection '$collectionName'" } + addCollection( + SeriesCollection( + name = collectionName, + seriesIds = listOf(series.id), + ), + ) + } + } + } + + fun deleteEmptyCollections() { + logger.info { "Deleting empty collections" } + val toDelete = collectionRepository.findAllEmpty() + transactionTemplate.executeWithoutResult { + thumbnailSeriesCollectionRepository.deleteByCollectionIds(toDelete.map { it.id }) + collectionRepository.delete(toDelete.map { it.id }) + } + + toDelete.forEach { eventPublisher.publishEvent(DomainEvent.CollectionDeleted(it)) } + } + + fun addThumbnail(thumbnail: ThumbnailSeriesCollection): ThumbnailSeriesCollection { + when (thumbnail.type) { + ThumbnailSeriesCollection.Type.USER_UPLOADED -> { + thumbnailSeriesCollectionRepository.insert(thumbnail) + if (thumbnail.selected) { + thumbnailSeriesCollectionRepository.markSelected(thumbnail) + } + } + } + + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail)) + return thumbnail + } + + fun markSelectedThumbnail(thumbnail: ThumbnailSeriesCollection) { + thumbnailSeriesCollectionRepository.markSelected(thumbnail) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail.copy(selected = true))) + } + + fun deleteThumbnail(thumbnail: ThumbnailSeriesCollection) { + thumbnailSeriesCollectionRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.collectionId) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionDeleted(thumbnail)) + } + + fun getThumbnailBytes( + collection: SeriesCollection, + userId: String, + ): ByteArray { + thumbnailSeriesCollectionRepository.findSelectedByCollectionIdOrNull(collection.id)?.let { + return it.thumbnail + } + + val ids = + with(mutableListOf()) { + while (size < 4) { + this += collection.seriesIds.take(4) + } + this.take(4) + } + + val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it, userId) } + return mosaicGenerator.createMosaic(images) + } + + private fun thumbnailsHouseKeeping(collectionId: String) { + logger.info { "House keeping thumbnails for collection: $collectionId" } + val all = thumbnailSeriesCollectionRepository.findAllByCollectionId(collectionId) + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailSeriesCollectionRepository.markSelected(selected[0]) + } + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Collection has no selected thumbnail, choosing one automatically" } + thumbnailSeriesCollectionRepository.markSelected(all.first()) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt new file mode 100644 index 000000000..b014fba1a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt @@ -0,0 +1,381 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookMetadataAggregation +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.gotson.komga.language.stripAccents +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.io.File +import java.time.LocalDateTime +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isWritable +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.notExists +import kotlin.io.path.toPath + +private val logger = KotlinLogging.logger {} +private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + +@Service +class SeriesLifecycle( + private val libraryRepository: LibraryRepository, + private val bookRepository: BookRepository, + private val bookLifecycle: BookLifecycle, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesRepository: SeriesRepository, + private val thumbnailsSeriesRepository: ThumbnailSeriesRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookMetadataAggregationRepository: BookMetadataAggregationRepository, + private val collectionRepository: SeriesCollectionRepository, + private val readProgressRepository: ReadProgressRepository, + private val taskEmitter: TaskEmitter, + private val eventPublisher: ApplicationEventPublisher, + private val transactionTemplate: TransactionTemplate, + private val historicalEventRepository: HistoricalEventRepository, +) { + private val whitespacePattern = """\s+""".toRegex() + + fun sortBooks(series: Series) { + logger.info { "Sorting books for $series" } + + val books = bookRepository.findAllBySeriesId(series.id) + val metadatas = bookMetadataRepository.findAllByIds(books.map { it.id }) + logger.debug { "Existing books: $books" } + logger.debug { "Existing metadata: $metadatas" } + + val sorted = + books + .sortedWith( + compareBy(natSortComparator) { + it.name + .trim() + .stripAccents() + .replace(whitespacePattern, " ") + }, + ).map { book -> book to metadatas.first { it.bookId == book.id } } + logger.debug { "Sorted books: $sorted" } + + bookRepository.update( + sorted.mapIndexed { index, (book, _) -> book.copy(number = index + 1) }, + ) + + val oldToNew = + sorted.mapIndexedNotNull { index, (book, metadata) -> + if (metadata.numberLock && metadata.numberSortLock) + null + else + Triple( + book, + metadata, + metadata.copy( + number = if (!metadata.numberLock) (index + 1).toString() else metadata.number, + numberSort = if (!metadata.numberSortLock) (index + 1).toFloat() else metadata.numberSort, + ), + ) + } + bookMetadataRepository.update(oldToNew.map { it.third }) + + // refresh metadata to reimport book number, else the series resorting would overwrite it + oldToNew.forEach { (book, old, new) -> + if (old.number != new.number || old.numberSort != new.numberSort) { + logger.debug { "Metadata numbering has changed, refreshing metadata for book ${new.bookId} " } + taskEmitter.refreshBookMetadata(book, setOf(BookMetadataPatchCapability.NUMBER, BookMetadataPatchCapability.NUMBER_SORT)) + } + } + + // update book count for series + seriesRepository.findByIdOrNull(series.id)?.let { + seriesRepository.update(it.copy(bookCount = books.size), false) + } + } + + fun addBooks( + series: Series, + booksToAdd: Collection, + ) { + booksToAdd.forEach { + check(it.libraryId == series.libraryId) { "Cannot add book to series if they don't share the same libraryId" } + } + val toAdd = booksToAdd.map { it.copy(seriesId = series.id) } + + transactionTemplate.executeWithoutResult { + bookRepository.insert(toAdd) + + // create associated media + mediaRepository.insert(toAdd.map { Media(bookId = it.id) }) + + // create associated metadata + toAdd + .map { + BookMetadata( + title = it.name, + number = it.number.toString(), + numberSort = it.number.toFloat(), + bookId = it.id, + ) + }.let { bookMetadataRepository.insert(it) } + } + + toAdd.forEach { eventPublisher.publishEvent(DomainEvent.BookAdded(it)) } + } + + fun createSeries(series: Series): Series { + transactionTemplate.executeWithoutResult { + seriesRepository.insert(series) + + seriesMetadataRepository.insert( + SeriesMetadata( + title = series.name, + titleSort = series.name, + seriesId = series.id, + ), + ) + + bookMetadataAggregationRepository.insert( + BookMetadataAggregation(seriesId = series.id), + ) + } + + eventPublisher.publishEvent(DomainEvent.SeriesAdded(series)) + + return seriesRepository.findByIdOrNull(series.id)!! + } + + fun softDeleteMany(series: Collection) { + logger.info { "Soft delete series: $series" } + val deletedDate = LocalDateTime.now() + + transactionTemplate.executeWithoutResult { + bookLifecycle.softDeleteMany(bookRepository.findAllBySeriesIds(series.map { it.id })) + series.forEach { + seriesRepository.update(it.copy(deletedDate = deletedDate)) + } + } + + series.forEach { eventPublisher.publishEvent(DomainEvent.SeriesUpdated(it)) } + } + + fun deleteMany(series: Collection) { + val seriesIds = series.map { it.id } + logger.info { "Delete series ids: $seriesIds" } + + transactionTemplate.executeWithoutResult { + bookLifecycle.deleteMany(bookRepository.findAllBySeriesIds(seriesIds)) + + readProgressRepository.deleteBySeriesIds(seriesIds) + collectionRepository.removeSeriesFromAll(seriesIds) + thumbnailsSeriesRepository.deleteBySeriesIds(seriesIds) + seriesMetadataRepository.delete(seriesIds) + bookMetadataAggregationRepository.delete(seriesIds) + + seriesRepository.delete(seriesIds) + } + + series.forEach { eventPublisher.publishEvent(DomainEvent.SeriesDeleted(it)) } + } + + fun markReadProgressCompleted( + seriesId: String, + user: KomgaUser, + ) { + val bookIds = + bookRepository + .findAllIdsBySeriesId(seriesId) + .filter { bookId -> + val readProgress = readProgressRepository.findByBookIdAndUserIdOrNull(bookId, user.id) + readProgress == null || !readProgress.completed + } + val progresses = + mediaRepository + .getPagesSizes(bookIds) + .map { (bookId, pageSize) -> ReadProgress(bookId, user.id, pageSize, true) } + + readProgressRepository.save(progresses) + progresses.forEach { eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(it)) } + eventPublisher.publishEvent(DomainEvent.ReadProgressSeriesChanged(seriesId, user.id)) + } + + fun deleteReadProgress( + seriesId: String, + user: KomgaUser, + ) { + val bookIds = bookRepository.findAllIdsBySeriesId(seriesId) + val progresses = readProgressRepository.findAllByBookIdsAndUserId(bookIds, user.id) + readProgressRepository.deleteByBookIdsAndUserId(bookIds, user.id) + + progresses.forEach { eventPublisher.publishEvent(DomainEvent.ReadProgressDeleted(it)) } + eventPublisher.publishEvent(DomainEvent.ReadProgressSeriesDeleted(seriesId, user.id)) + } + + fun getSelectedThumbnail(seriesId: String): ThumbnailSeries? { + val selected = thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(seriesId) + + if (selected == null || (selected.type == ThumbnailSeries.Type.SIDECAR && !selected.exists())) { + thumbnailsHouseKeeping(seriesId) + return thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(seriesId) + } + + return selected + } + + private fun getBytesFromThumbnailSeries(thumbnail: ThumbnailSeries): ByteArray? = + when { + thumbnail.thumbnail != null -> thumbnail.thumbnail + thumbnail.url != null -> File(thumbnail.url.toURI()).readBytes() + else -> null + } + + fun getThumbnailBytesByThumbnailId(thumbnailId: String): ByteArray? = + thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let { + getBytesFromThumbnailSeries(it) + } + + fun getThumbnailBytes( + seriesId: String, + userId: String, + ): ByteArray? { + getSelectedThumbnail(seriesId)?.let { + return getBytesFromThumbnailSeries(it) + } + + seriesRepository.findByIdOrNull(seriesId)?.let { series -> + val bookId = + when (libraryRepository.findById(series.libraryId).seriesCover) { + Library.SeriesCover.FIRST -> bookRepository.findFirstIdInSeriesOrNull(seriesId) + Library.SeriesCover.FIRST_UNREAD_OR_FIRST -> + bookRepository.findFirstUnreadIdInSeriesOrNull(seriesId, userId) + ?: bookRepository.findFirstIdInSeriesOrNull(seriesId) + + Library.SeriesCover.FIRST_UNREAD_OR_LAST -> + bookRepository.findFirstUnreadIdInSeriesOrNull(seriesId, userId) + ?: bookRepository.findLastIdInSeriesOrNull(seriesId) + + Library.SeriesCover.LAST -> bookRepository.findLastIdInSeriesOrNull(seriesId) + } + if (bookId != null) return bookLifecycle.getThumbnailBytes(bookId)?.bytes + } + + return null + } + + fun addThumbnailForSeries( + thumbnail: ThumbnailSeries, + markSelected: MarkSelectedPreference, + ): ThumbnailSeries { + // delete existing thumbnail with the same url + if (thumbnail.url != null) { + thumbnailsSeriesRepository + .findAllBySeriesId(thumbnail.seriesId) + .filter { it.url == thumbnail.url } + .forEach { + thumbnailsSeriesRepository.delete(it.id) + } + } + thumbnailsSeriesRepository.insert(thumbnail.copy(selected = false)) + + val selected = + when (markSelected) { + MarkSelectedPreference.YES -> true + MarkSelectedPreference.IF_NONE_OR_GENERATED -> { + thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(thumbnail.seriesId) == null + } + + MarkSelectedPreference.NO -> false + } + + if (selected) thumbnailsSeriesRepository.markSelected(thumbnail) + + val newThumbnail = thumbnail.copy(selected = selected) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(newThumbnail)) + return newThumbnail + } + + fun deleteThumbnailForSeries(thumbnail: ThumbnailSeries) { + require(thumbnail.type == ThumbnailSeries.Type.USER_UPLOADED) { "Only uploaded thumbnails can be deleted" } + thumbnailsSeriesRepository.delete(thumbnail.id) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesDeleted(thumbnail)) + } + + fun deleteSeriesFiles(series: Series) { + if (series.path.notExists()) return logger.info { "Cannot delete series folder, path does not exist: ${series.path}" } + if (!series.path.isWritable()) return logger.info { "Cannot delete series folder, path is not writable: ${series.path}" } + + val thumbnails = + thumbnailsSeriesRepository + .findAllBySeriesIdIdAndType(series.id, ThumbnailSeries.Type.SIDECAR) + .mapNotNull { it.url?.toURI()?.toPath() } + .filter { it.exists() && it.isWritable() } + + bookRepository + .findAllBySeriesId(series.id) + .forEach { bookLifecycle.deleteBookFiles(it) } + thumbnails.forEach { + if (it.deleteIfExists()) logger.info { "Deleted file: $it" } + } + + if (series.path.exists() && series.path.listDirectoryEntries().isEmpty()) + if (series.path.deleteIfExists()) { + logger.info { "Deleted directory: ${series.path}" } + historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(series, "Folder was deleted because it was empty")) + } + + softDeleteMany(listOf(series)) + } + + private fun thumbnailsHouseKeeping(seriesId: String) { + logger.info { "House keeping thumbnails for series: $seriesId" } + val all = + thumbnailsSeriesRepository + .findAllBySeriesId(seriesId) + .mapNotNull { + if (!it.exists()) { + logger.warn { "Thumbnail doesn't exist, removing entry" } + thumbnailsSeriesRepository.delete(it.id) + null + } else { + it + } + } + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailsSeriesRepository.markSelected(selected[0]) + } + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Series has no selected thumbnail, choosing one automatically" } + thumbnailsSeriesRepository.markSelected(all.first()) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesMetadataLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesMetadataLifecycle.kt new file mode 100644 index 000000000..4917658b4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesMetadataLifecycle.kt @@ -0,0 +1,157 @@ +package org.gotson.komga.domain.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider +import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider +import org.gotson.komga.language.mostFrequent +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class SeriesMetadataLifecycle( + private val seriesMetadataFromBookProviders: List, + private val seriesMetadataProviders: List, + private val metadataApplier: MetadataApplier, + private val metadataAggregator: MetadataAggregator, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookMetadataAggregationRepository: BookMetadataAggregationRepository, + private val libraryRepository: LibraryRepository, + private val bookRepository: BookRepository, + private val collectionLifecycle: SeriesCollectionLifecycle, + private val eventPublisher: ApplicationEventPublisher, +) { + fun refreshMetadata(series: Series) { + logger.info { "Refresh metadata for series: $series" } + + val library = libraryRepository.findById(series.libraryId) + var changed = false + + seriesMetadataFromBookProviders.forEach { provider -> + when { + !(provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.SERIES) || provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.COLLECTION)) -> + logger.info { "Library is not set to import series or collection metadata for this provider, skipping: ${provider.javaClass.simpleName}" } + + else -> { + logger.debug { "Provider: ${provider.javaClass.simpleName}" } + val patches = + bookRepository + .findAllBySeriesId(series.id) + .mapNotNull { book -> + try { + provider.getSeriesMetadataFromBook(BookWithMedia(book, mediaRepository.findById(book.id)), library.importComicInfoSeriesAppendVolume) + } catch (e: Exception) { + logger.error(e) { "Error while getting metadata from ${provider.javaClass.simpleName} for book: $book" } + null + } + } + + if (provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.SERIES)) { + handlePatchForSeriesMetadata(patches, series) + changed = true + } + + if (provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.COLLECTION)) { + patches.flatMap { it.collections }.distinct().forEach { collection -> + collectionLifecycle.addSeriesToCollection(collection, series) + } + } + } + } + } + + seriesMetadataProviders.forEach { provider -> + when { + !provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.SERIES) -> + logger.info { "Library is not set to import series metadata for this provider, skipping: ${provider.javaClass.simpleName}" } + else -> { + logger.debug { "Provider: ${provider.javaClass.simpleName}" } + val patch = + try { + provider.getSeriesMetadata(series) + } catch (e: Exception) { + logger.error(e) { "Error while getting metadata from ${provider::class.simpleName} for series: $series" } + null + } + + if (provider.shouldLibraryHandlePatch(library, MetadataPatchTarget.SERIES)) { + handlePatchForSeriesMetadata(patch, series) + changed = true + } + } + } + } + + if (changed) eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series)) + } + + private fun handlePatchForSeriesMetadata( + patches: List, + series: Series, + ) { + val aggregatedPatch = + SeriesMetadataPatch( + title = patches.mostFrequent { it.title }, + titleSort = patches.mostFrequent { it.titleSort }, + status = patches.mostFrequent { it.status }, + genres = + patches + .mapNotNull { it.genres } + .flatten() + .toSet() + .ifEmpty { null }, + language = patches.mostFrequent { it.language }, + summary = null, + readingDirection = patches.mostFrequent { it.readingDirection }, + ageRating = patches.mapNotNull { it.ageRating }.maxOrNull(), + publisher = patches.mostFrequent { it.publisher }, + totalBookCount = patches.mapNotNull { it.totalBookCount }.maxOrNull(), + collections = emptySet(), + ) + + handlePatchForSeriesMetadata(aggregatedPatch, series) + } + + private fun handlePatchForSeriesMetadata( + patch: SeriesMetadataPatch?, + series: Series, + ) { + patch?.let { sPatch -> + seriesMetadataRepository.findById(series.id).let { + logger.debug { "Apply metadata for series: $series" } + + logger.debug { "Original metadata: $it" } + logger.debug { "Patch: $sPatch" } + val patched = metadataApplier.apply(sPatch, it) + logger.debug { "Patched metadata: $patched" } + + seriesMetadataRepository.update(patched) + } + } + } + + fun aggregateMetadata(series: Series) { + logger.info { "Aggregate book metadata for series: $series" } + + val metadatas = bookMetadataRepository.findAllByIds(bookRepository.findAllIdsBySeriesId(series.id)) + val aggregation = metadataAggregator.aggregate(metadatas).copy(seriesId = series.id) + + bookMetadataAggregationRepository.update(aggregation) + + eventPublisher.publishEvent(DomainEvent.SeriesUpdated(series)) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt new file mode 100644 index 000000000..4a87c8b49 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt @@ -0,0 +1,148 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component + +@Component +class SyncPointLifecycle( + private val syncPointRepository: SyncPointRepository, +) { + fun createSyncPoint( + user: KomgaUser, + apiKeyId: String?, + libraryIds: List?, + ): SyncPoint { + val context = SearchContext(user) + + val syncPoint = + syncPointRepository + .create( + apiKeyId, + BookSearch( + SearchCondition.AllOfBook( + buildList { + libraryIds?.let { + add( + SearchCondition.AnyOfBook( + it.map { libraryId -> SearchCondition.LibraryId(SearchOperator.Is(libraryId)) }, + ), + ) + } + add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY))) + add(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + }, + ), + ), + context, + ) + + syncPointRepository.addOnDeck(syncPoint.id, context, libraryIds) + + return syncPoint + } + + /** + * Retrieve a page of un-synced books and mark them as synced. + */ + fun takeBooks( + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findBooksById(toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced added books and mark them as synced. + */ + fun takeBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findBooksAdded(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced changed books and mark them as synced. + */ + fun takeBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findBooksChanged(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced removed books and mark them as synced. + */ + fun takeBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findBooksRemoved(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, true, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced unchanged books with changed read progress and mark them as synced. + */ + fun takeBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findBooksReadProgressChanged(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + fun takeReadLists( + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findReadListsById(toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) } + + fun takeReadListsAdded( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findReadListsAdded(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) } + + fun takeReadListsChanged( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findReadListsChanged(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) } + + fun takeReadListsRemoved( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository + .findReadListsRemoved(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, true, page.content.map { it.readListId }) } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt new file mode 100644 index 000000000..101f72f0f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt @@ -0,0 +1,114 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.TransientBook +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.domain.model.toBookWithMedia +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import java.nio.file.Paths + +@Service +class TransientBookLifecycle( + private val transientBookRepository: TransientBookRepository, + private val bookAnalyzer: BookAnalyzer, + private val fileSystemScanner: FileSystemScanner, + private val libraryRepository: LibraryRepository, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, + private val seriesRepository: SeriesRepository, + private val seriesMetadataProviders: List, + bookMetadataProviders: List, +) { + val bookMetadataProviders = bookMetadataProviders.filter { it.capabilities.contains(BookMetadataPatchCapability.NUMBER_SORT) } + + fun scanAndPersist(filePath: String): List { + val folderToScan = Paths.get(filePath) + + libraryRepository.findAll().forEach { library -> + if (folderToScan.startsWith(library.path)) throw PathContainedInPath("Cannot scan folder that is part of an existing library", "ERR_1017") + } + + val books = + fileSystemScanner + .scanRootFolder(folderToScan) + .series.values + .flatten() + .map { TransientBook(it, Media()) } + + transientBookRepository.save(books) + + return books + } + + fun analyzeAndPersist(transientBook: TransientBook): TransientBook { + val media = bookAnalyzer.analyze(transientBook.book, true) + val (seriesId, number) = getMetadata(transientBook.copy(media = media)) + + val updated = transientBook.copy(media = media, metadata = TransientBook.Metadata(number, seriesId)) + transientBookRepository.save(updated) + + return updated + } + + fun getMetadata(transientBook: TransientBook): Pair { + val bookWithMedia = transientBook.toBookWithMedia() + val number = bookMetadataProviders.firstNotNullOfOrNull { it.getBookMetadataFromBook(bookWithMedia)?.numberSort } + val seriesNamesFromMetadata = + seriesMetadataProviders + .flatMap { + buildList { + if (it.supportsAppendVolume) add(it.getSeriesMetadataFromBook(bookWithMedia, true)?.title?.ifBlank { null }) + add(it.getSeriesMetadataFromBook(bookWithMedia, false)?.title?.ifBlank { null }) + } + }.filterNotNull() + + val series = + if (seriesNamesFromMetadata.isNotEmpty()) { + val exactSearch = SearchCondition.AnyOfSeries(seriesNamesFromMetadata.map { SearchCondition.Title(SearchOperator.Is(it)) }) + val exactMatches = seriesRepository.findAll(exactSearch, SearchContext.ofAnonymousUser(), Pageable.unpaged()) + if (!exactMatches.isEmpty) { + exactMatches.content.first() + } else { + val containsSearch = SearchCondition.AnyOfSeries(seriesNamesFromMetadata.map { SearchCondition.Title(SearchOperator.Contains(it)) }) + seriesRepository.findAll(containsSearch, SearchContext.ofAnonymousUser(), Pageable.unpaged()).content.firstOrNull() + } + } else { + null + } + + return series?.id to number + } + + @Throws( + MediaNotReadyException::class, + IndexOutOfBoundsException::class, + ) + fun getBookPage( + transientBook: TransientBook, + number: Int, + ): TypedBytes { + val pageContent = bookAnalyzer.getPageContent(transientBook.toBookWithMedia(), number) + val pageMediaType = + if (transientBook.media.profile == MediaProfile.PDF) + pdfImageType.mediaType + else + transientBook.media.pages[number - 1].mediaType + + return TypedBytes(pageContent, pageMediaType) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt new file mode 100644 index 000000000..1e6bf12b7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.infrastructure.cache + +import com.github.benmanes.caffeine.cache.Caffeine +import org.gotson.komga.domain.model.TransientBook +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +@Service +class TransientBookCache : TransientBookRepository { + private val cache = + Caffeine + .newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build() + + override fun findByIdOrNull(transientBookId: String): TransientBook? = cache.getIfPresent(transientBookId) + + override fun save(transientBook: TransientBook) { + cache.put(transientBook.book.id, transientBook) + } + + override fun save(transientBooks: Collection) { + cache.putAll(transientBooks.associateBy { it.book.id }) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/ConfigurationChecker.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/ConfigurationChecker.kt new file mode 100644 index 000000000..625b50bb1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/ConfigurationChecker.kt @@ -0,0 +1,66 @@ +package org.gotson.komga.infrastructure.configuration + +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PostConstruct +import org.gotson.komga.domain.model.ConfigurationException +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.exists + +private val logger = KotlinLogging.logger {} + +@Component +class ConfigurationChecker( + private val komgaProperties: KomgaProperties, +) { + private val blockTypes = listOf("cifs", "nfs") + + @PostConstruct + fun checkDatabasesPath() { + checkDatabaseIsLocal(komgaProperties.database, "komga.database.check-local-filesystem: false") + checkDatabaseIsLocal(komgaProperties.tasksDb, "komga.tasks-db.check-local-filesystem: false") + } + + private fun checkDatabaseIsLocal( + database: KomgaProperties.Database, + ignoreProp: String, + ) { + if (database.checkLocalFilesystem) { + val path = + try { + Path(database.file) + } catch (_: Exception) { + return + } + checkIfRemote(path, ignoreProp) + if (!path.exists()) checkIfRemote(path.parent, ignoreProp) + } + } + + private fun checkIfRemote( + path: Path?, + ignoreProp: String, + ) { + if (path == null) return + if (path.exists()) { + val storeType = + try { + Files.getFileStore(path).type().lowercase() + } catch (e: Exception) { + logger.warn(e) { "Could not get FileStore type for path: $path" } + "unknown" + } + + if (blockTypes.any { storeType.startsWith(it, ignoreCase = true) }) { + val errorMessage = "The path '$path' should be on a local filesystem, but was detected to be on a remote filesystem ($storeType). If this is inaccurate you can set '$ignoreProp' to ignore this check." + + logger.error { errorMessage } + throw ConfigurationException(errorMessage) + } else { + logger.debug { "FileStore type: $storeType, path: $path" } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt new file mode 100644 index 000000000..40af5fbcd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt @@ -0,0 +1,111 @@ +package org.gotson.komga.infrastructure.configuration + +import jakarta.annotation.PostConstruct +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.convert.DurationUnit +import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated +import org.sqlite.SQLiteConfig.JournalMode +import java.time.Duration +import java.time.temporal.ChronoUnit +import kotlin.io.path.Path +import kotlin.io.path.createDirectories + +@Component +@ConfigurationProperties(prefix = "komga") +@Validated +class KomgaProperties { + @PostConstruct + private fun makeDirs() { + try { + Path(database.file).parent.createDirectories() + Path(tasksDb.file).parent.createDirectories() + } catch (_: Exception) { + } + } + + @Positive + var pageHashing: Int = 3 + + @Positive + var epubDivinaLetterCountThreshold: Int = 15 + + var oauth2AccountCreation: Boolean = false + + var oidcEmailVerification: Boolean = true + + var database = Database() + + var tasksDb = Database() + + var cors = Cors() + + var lucene = Lucene() + + var configDir: String? = null + + var kobo = Kobo() + + val fonts = Fonts() + + class Cors { + var allowedOrigins: List = emptyList() + } + + class Database { + @get:NotBlank + var file: String = "" + + @get:Positive + var batchChunkSize: Int = 1000 + + @get:Positive + var poolSize: Int? = null + + @get:Positive + var maxPoolSize: Int = 1 + + var journalMode: JournalMode? = JournalMode.WAL + + @DurationUnit(ChronoUnit.SECONDS) + var busyTimeout: Duration? = null + + var pragmas: Map = emptyMap() + + var checkLocalFilesystem: Boolean = true + } + + class Fonts { + @get:NotBlank + var dataDirectory: String = "" + } + + class Lucene { + @get:NotBlank + var dataDirectory: String = "" + + var indexAnalyzer = IndexAnalyzer() + + @DurationUnit(ChronoUnit.SECONDS) + var commitDelay: Duration = Duration.ofSeconds(2) + + class IndexAnalyzer { + @get:Positive + var minGram: Int = 3 + + @get:Positive + var maxGram: Int = 10 + + var preserveOriginal: Boolean = true + } + } + + class Kobo { + @get:Positive + var syncItemLimit: Int = 100 + + var kepubifyPath: String? = null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt new file mode 100644 index 000000000..95cb3aeae --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt @@ -0,0 +1,129 @@ +package org.gotson.komga.infrastructure.configuration + +import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.ThumbnailSize +import org.gotson.komga.infrastructure.jooq.main.ServerSettingsDao +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +@Service +class KomgaSettingsProvider( + private val serverSettingsDao: ServerSettingsDao, + private val eventPublisher: ApplicationEventPublisher, +) { + var deleteEmptyCollections: Boolean = + serverSettingsDao.getSettingByKey(Settings.DELETE_EMPTY_COLLECTIONS.name, Boolean::class.java) ?: false + set(value) { + serverSettingsDao.saveSetting(Settings.DELETE_EMPTY_COLLECTIONS.name, value) + field = value + } + + var deleteEmptyReadLists: Boolean = + serverSettingsDao.getSettingByKey(Settings.DELETE_EMPTY_READLISTS.name, Boolean::class.java) ?: false + set(value) { + serverSettingsDao.saveSetting(Settings.DELETE_EMPTY_READLISTS.name, value) + field = value + } + + var rememberMeKey: String = + serverSettingsDao.getSettingByKey(Settings.REMEMBER_ME_KEY.name, String::class.java) + ?: getRandomRememberMeKey().also { rememberMeKey = it } + set(value) { + serverSettingsDao.saveSetting(Settings.REMEMBER_ME_KEY.name, value) + field = value + } + + fun renewRememberMeKey() { + rememberMeKey = getRandomRememberMeKey() + } + + private fun getRandomRememberMeKey() = RandomStringUtils.secure().nextAlphanumeric(32) + + var rememberMeDuration: Duration = + (serverSettingsDao.getSettingByKey(Settings.REMEMBER_ME_DURATION.name, Int::class.java) ?: 365).days + set(value) { + serverSettingsDao.saveSetting(Settings.REMEMBER_ME_DURATION.name, value.inWholeDays.toInt()) + field = value + } + + var thumbnailSize: ThumbnailSize = + serverSettingsDao.getSettingByKey(Settings.THUMBNAIL_SIZE.name, String::class.java)?.let { + ThumbnailSize.valueOf(it) + } ?: ThumbnailSize.DEFAULT + set(value) { + serverSettingsDao.saveSetting(Settings.THUMBNAIL_SIZE.name, value.name) + field = value + } + + var taskPoolSize: Int = + serverSettingsDao.getSettingByKey(Settings.TASK_POOL_SIZE.name, Int::class.java) ?: 1 + set(value) { + serverSettingsDao.saveSetting(Settings.TASK_POOL_SIZE.name, value) + field = value + eventPublisher.publishEvent(SettingChangedEvent.TaskPoolSize) + } + + var serverPort: Int? = + serverSettingsDao.getSettingByKey(Settings.SERVER_PORT.name, Int::class.java) + set(value) { + if (value != null) + serverSettingsDao.saveSetting(Settings.SERVER_PORT.name, value) + else + serverSettingsDao.deleteSetting(Settings.SERVER_PORT.name) + field = value + } + + var serverContextPath: String? = + serverSettingsDao.getSettingByKey(Settings.SERVER_CONTEXT_PATH.name, String::class.java) + set(value) { + if (value != null) + serverSettingsDao.saveSetting(Settings.SERVER_CONTEXT_PATH.name, value) + else + serverSettingsDao.deleteSetting(Settings.SERVER_CONTEXT_PATH.name) + field = value + } + + var koboProxy: Boolean = + serverSettingsDao.getSettingByKey(Settings.KOBO_PROXY.name, Boolean::class.java) ?: false + set(value) { + serverSettingsDao.saveSetting(Settings.KOBO_PROXY.name, value) + field = value + } + + var koboPort: Int? = + serverSettingsDao.getSettingByKey(Settings.KOBO_PORT.name, Int::class.java) + set(value) { + if (value != null) + serverSettingsDao.saveSetting(Settings.KOBO_PORT.name, value) + else + serverSettingsDao.deleteSetting(Settings.KOBO_PORT.name) + field = value + } + + var kepubifyPath: String? = + serverSettingsDao.getSettingByKey(Settings.KEPUBIFY_PATH.name, String::class.java)?.ifBlank { null } + set(value) { + if (value != null) + serverSettingsDao.saveSetting(Settings.KEPUBIFY_PATH.name, value) + else + serverSettingsDao.deleteSetting(Settings.KEPUBIFY_PATH.name) + field = value + eventPublisher.publishEvent(SettingChangedEvent.KepubifyPath) + } +} + +private enum class Settings { + DELETE_EMPTY_COLLECTIONS, + DELETE_EMPTY_READLISTS, + REMEMBER_ME_KEY, + REMEMBER_ME_DURATION, + THUMBNAIL_SIZE, + TASK_POOL_SIZE, + SERVER_PORT, + SERVER_CONTEXT_PATH, + KOBO_PROXY, + KOBO_PORT, + KEPUBIFY_PATH, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/SettingChangedEvent.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/SettingChangedEvent.kt new file mode 100644 index 000000000..2b8427dae --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/SettingChangedEvent.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.infrastructure.configuration + +sealed class SettingChangedEvent { + data object TaskPoolSize : SettingChangedEvent() + + data object KepubifyPath : SettingChangedEvent() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt new file mode 100644 index 000000000..2bf70aacd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.infrastructure.configuration + +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class StaticConfiguration { + @Bean("thumbnailType") + fun thumbnailType() = ImageType.JPEG + + @Bean("pdfImageType") + fun pdfImageType() = ImageType.JPEG + + @Bean("pdfResolution") + fun pdfResolution(): Float = 3200F +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt new file mode 100644 index 000000000..3f491eee3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfiguration.kt @@ -0,0 +1,101 @@ +package org.gotson.komga.infrastructure.datasource + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.springframework.boot.jdbc.DataSourceBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.sqlite.SQLiteConfig +import org.sqlite.SQLiteDataSource +import javax.sql.DataSource + +@Configuration +class DataSourcesConfiguration( + private val komgaProperties: KomgaProperties, +) { + @Bean("sqliteDataSourceRW") + @Primary + fun sqliteDataSourceRW(): DataSource = + buildDataSource("SqliteMainPoolRW", SqliteUdfDataSource::class.java, komgaProperties.database) + .apply { + // force pool size to 1 if the pool is only used for writes + if (komgaProperties.database.shouldSeparateReadFromWrites()) this.maximumPoolSize = 1 + } + + @Bean("sqliteDataSourceRO") + fun sqliteDataSourceRO(): DataSource = + if (komgaProperties.database.shouldSeparateReadFromWrites()) + buildDataSource("SqliteMainPoolRO", SqliteUdfDataSource::class.java, komgaProperties.database) + else + sqliteDataSourceRW() + + @Bean("tasksDataSourceRW") + fun tasksDataSourceRW(): DataSource = + buildDataSource("SqliteTasksPoolRW", SQLiteDataSource::class.java, komgaProperties.tasksDb) + .apply { + // pool size is always 1: + // - if there's only 1 pool for read and writes, size should be 1 + // - if there's a separate read pool, the write pool size should be 1 + this.maximumPoolSize = 1 + } + + @Bean("tasksDataSourceRO") + fun tasksDataSourceRO(): DataSource = + if (komgaProperties.tasksDb.shouldSeparateReadFromWrites()) + buildDataSource("SqliteTasksPoolRO", SQLiteDataSource::class.java, komgaProperties.tasksDb) + else + tasksDataSourceRW() + + private fun buildDataSource( + poolName: String, + dataSourceClass: Class, + databaseProps: KomgaProperties.Database, + ): HikariDataSource { + val extraPragmas = + databaseProps.pragmas.let { + if (it.isEmpty()) + "" + else + "?" + it.map { (key, value) -> "$key=$value" }.joinToString(separator = "&") + } + + val dataSource = + DataSourceBuilder + .create() + .driverClassName("org.sqlite.JDBC") + .url("jdbc:sqlite:${databaseProps.file}$extraPragmas") + .type(dataSourceClass) + .build() + + with(dataSource) { + setEnforceForeignKeys(true) + setGetGeneratedKeys(false) + } + with(databaseProps) { + journalMode?.let { dataSource.setJournalMode(it.name) } + busyTimeout?.let { dataSource.config.busyTimeout = it.toMillis().toInt() } + } + + val poolSize = + if (databaseProps.isMemory()) + 1 + else if (databaseProps.poolSize != null) + databaseProps.poolSize!! + else + Runtime.getRuntime().availableProcessors().coerceAtMost(databaseProps.maxPoolSize) + + return HikariDataSource( + HikariConfig().apply { + this.dataSource = dataSource + this.poolName = poolName + this.maximumPoolSize = poolSize + }, + ) + } + + fun KomgaProperties.Database.isMemory() = file.contains(":memory:") || file.contains("mode=memory") + + fun KomgaProperties.Database.shouldSeparateReadFromWrites(): Boolean = !isMemory() && journalMode == SQLiteConfig.JournalMode.WAL +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/FlywaySecondaryMigrationInitializer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/FlywaySecondaryMigrationInitializer.kt new file mode 100644 index 000000000..d432550e7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/FlywaySecondaryMigrationInitializer.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.infrastructure.datasource + +import org.flywaydb.core.Flyway +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import javax.sql.DataSource + +@Component +class FlywaySecondaryMigrationInitializer( + @Qualifier("tasksDataSourceRW") + private val tasksDataSource: DataSource, +) : InitializingBean { + // by default Spring Boot will perform migration only on the @Primary datasource + override fun afterPropertiesSet() { + Flyway + .configure() + .locations("classpath:tasks/migration/sqlite") + .dataSource(tasksDataSource) + .load() + .apply { + migrate() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt new file mode 100644 index 000000000..7d6db1d76 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/datasource/SqliteUdfDataSource.kt @@ -0,0 +1,84 @@ +package org.gotson.komga.infrastructure.datasource + +import com.ibm.icu.text.Collator +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.unicode.Collators +import org.gotson.komga.language.stripAccents +import org.sqlite.Collation +import org.sqlite.Function +import org.sqlite.SQLiteConnection +import org.sqlite.SQLiteDataSource +import java.sql.Connection + +private val log = KotlinLogging.logger {} + +class SqliteUdfDataSource : SQLiteDataSource() { + companion object { + const val UDF_STRIP_ACCENTS = "UDF_STRIP_ACCENTS" + const val COLLATION_UNICODE_1 = "COLLATION_UNICODE_1" + const val COLLATION_UNICODE_3 = "COLLATION_UNICODE_3" + } + + override fun getConnection(): Connection = super.getConnection().also { addAllUdf(it as SQLiteConnection) } + + override fun getConnection( + username: String?, + password: String?, + ): SQLiteConnection = super.getConnection(username, password).also { addAllUdf(it) } + + private fun addAllUdf(connection: SQLiteConnection) { + createUdfRegexp(connection) + createUdfStripAccents(connection) + createUnicodeCollation(connection, COLLATION_UNICODE_3, Collators.collator3) + createUnicodeCollation(connection, COLLATION_UNICODE_1, Collators.collator1) + } + + private fun createUdfRegexp(connection: SQLiteConnection) { + log.debug { "Adding custom REGEXP function" } + Function.create( + connection, + "REGEXP", + object : Function() { + override fun xFunc() { + val regexp = (value_text(0) ?: "").toRegex(RegexOption.IGNORE_CASE) + val text = value_text(1) ?: "" + + result(if (regexp.containsMatchIn(text)) 1 else 0) + } + }, + ) + } + + private fun createUdfStripAccents(connection: SQLiteConnection) { + log.debug { "Adding custom $UDF_STRIP_ACCENTS function" } + Function.create( + connection, + UDF_STRIP_ACCENTS, + object : Function() { + override fun xFunc() = + when (val text = value_text(0)) { + null -> error("Argument must not be null") + else -> result(text.stripAccents()) + } + }, + ) + } + + private fun createUnicodeCollation( + connection: SQLiteConnection, + collationName: String, + collator: Collator, + ) { + log.debug { "Adding custom $collationName collation" } + Collation.create( + connection, + collationName, + object : Collation() { + override fun xCompare( + str1: String, + str2: String, + ): Int = collator.compare(str1, str2) + }, + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/Hasher.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/Hasher.kt new file mode 100644 index 000000000..097714288 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/Hasher.kt @@ -0,0 +1,46 @@ +package org.gotson.komga.infrastructure.hash + +import com.appmattus.crypto.Algorithm +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.io.InputStream +import java.nio.file.Path +import kotlin.io.path.inputStream + +private val logger = KotlinLogging.logger {} + +private const val DEFAULT_BUFFER_SIZE = 8192 +private const val SEED = 0 + +@Component +class Hasher { + fun computeHash(path: Path): String { + logger.debug { "Hashing: $path" } + + return computeHash(path.inputStream()) + } + + fun computeHash(string: String): String = computeHash(string.byteInputStream()) + + fun computeHash(stream: InputStream): String { + val hash = Algorithm.XXH3_128.Seeded(SEED.toLong()).createDigest() + + stream.use { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var len: Int + + do { + len = it.read(buffer) + if (len >= 0) hash.update(buffer, 0, len) + } while (len >= 0) + } + + return hash.digest().toHexString() + } + + @OptIn(ExperimentalUnsignedTypes::class) + fun ByteArray.toHexString(): String = + asUByteArray().joinToString("") { + it.toString(16).padStart(2, '0') + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt new file mode 100644 index 000000000..158678ce0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.infrastructure.hash + +import com.appmattus.crypto.Algorithm +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.io.RandomAccessFile +import java.nio.file.Path + +private val logger = KotlinLogging.logger {} + +@Component +class KoreaderHasher { + fun computeHash(path: Path): String { + logger.debug { "Koreader hashing: $path" } + + return partialMd5(path) + } + + /** + * From https://github.com/koreader/koreader/blob/5bd3f3b42c95fd143d98f8fc9695d486fd92b7c8/frontend/util.lua#L1093-L1119 + */ + @OptIn(ExperimentalStdlibApi::class) + private fun partialMd5(path: Path): String { + val step = 1024L + val size = 1024 + val digest = Algorithm.MD5.createDigest() + + val file = RandomAccessFile(path.toFile(), "r") + + val buffer = ByteArray(size) + (-1..10).forEach { + file.seek(step shl (2 * it)) + val s = file.read(buffer) + if (s > 0) digest.update(buffer) + } + + return digest.digest().toHexString() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/httpexchange/HttpExchangeConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/httpexchange/HttpExchangeConfiguration.kt new file mode 100644 index 000000000..776c0e0f6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/httpexchange/HttpExchangeConfiguration.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.infrastructure.httpexchange + +import org.springframework.boot.actuate.web.exchanges.InMemoryHttpExchangeRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class HttpExchangeConfiguration { + private val httpExchangeRepository = InMemoryHttpExchangeRepository() + + @Bean + fun httpExchangeRepository() = httpExchangeRepository +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageAnalyzer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageAnalyzer.kt new file mode 100644 index 000000000..024de2dae --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageAnalyzer.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.infrastructure.image + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Dimension +import org.springframework.stereotype.Service +import java.io.InputStream +import javax.imageio.ImageIO + +private val logger = KotlinLogging.logger {} + +@Service +class ImageAnalyzer { + /** + * Returns the Dimension of the image contained in the stream. + * The stream will not be closed, nor marked or reset. + */ + fun getDimension(stream: InputStream): Dimension? = + try { + ImageIO.createImageInputStream(stream).use { fis -> + val readers = ImageIO.getImageReaders(fis) + if (readers.hasNext()) { + val reader = readers.next() + reader.input = fis + Dimension(reader.getWidth(0), reader.getHeight(0)) + } else { + logger.warn { "no reader found" } + null + } + } + } catch (e: Exception) { + logger.warn(e) { "Could not get image dimensions" } + null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt new file mode 100644 index 000000000..b27361227 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt @@ -0,0 +1,151 @@ +package org.gotson.komga.infrastructure.image + +import io.github.oshai.kotlinlogging.KotlinLogging +import net.coobird.thumbnailator.Thumbnails +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.springframework.stereotype.Service +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.InputStream +import javax.imageio.ImageIO +import javax.imageio.spi.IIORegistry +import javax.imageio.spi.ImageReaderSpi +import kotlin.math.max +import kotlin.math.min + +private val logger = KotlinLogging.logger {} + +private const val WEBP_NIGHT_MONKEYS = "com.github.gotson.nightmonkeys.webp.imageio.plugins.WebpImageReaderSpi" + +@Service +class ImageConverter( + private val imageAnalyzer: ImageAnalyzer, + private val contentDetector: ContentDetector, +) { + val supportedReadFormats by lazy { ImageIO.getReaderFormatNames().toList() } + val supportedReadMediaTypes by lazy { ImageIO.getReaderMIMETypes().toList() } + val supportedWriteFormats by lazy { ImageIO.getWriterFormatNames().toList() } + val supportedWriteMediaTypes by lazy { ImageIO.getWriterMIMETypes().toList() } + + init { + chooseWebpReader() + logger.info { "Supported read formats: $supportedReadFormats" } + logger.info { "Supported read mediaTypes: $supportedReadMediaTypes" } + logger.info { "Supported write formats: $supportedWriteFormats" } + logger.info { "Supported write mediaTypes: $supportedWriteMediaTypes" } + } + + private fun chooseWebpReader() { + val providers = + IIORegistry + .getDefaultInstance() + .getServiceProviders( + ImageReaderSpi::class.java, + { it is ImageReaderSpi && it.mimeTypes.contains("image/webp") }, + false, + ).asSequence() + .toList() + + if (providers.size > 1) { + logger.debug { "WebP reader providers: ${providers.map { it.javaClass.canonicalName }}" } + providers.firstOrNull { it.javaClass.canonicalName == WEBP_NIGHT_MONKEYS }?.let { nightMonkeys -> + (providers - nightMonkeys).forEach { + logger.debug { "Deregister provider: ${it.javaClass.canonicalName}" } + IIORegistry.getDefaultInstance().deregisterServiceProvider(it) + } + } + } + } + + private val supportsTransparency = listOf("png") + + fun canConvertMediaType( + from: String, + to: String, + ) = supportedReadMediaTypes.contains(from) && supportedWriteMediaTypes.contains(to) + + fun convertImage( + imageBytes: ByteArray, + format: String, + ): ByteArray = + ByteArrayOutputStream().use { baos -> + val image = ImageIO.read(imageBytes.inputStream()) + + val result = + if (!supportsTransparency.contains(format) && containsAlphaChannel(image)) { + if (containsTransparency(image)) + logger.info { "Image contains alpha channel but is not opaque, visual artifacts may appear" } + else + logger.info { "Image contains alpha channel but is opaque, conversion should not generate any visual artifacts" } + BufferedImage(image.width, image.height, BufferedImage.TYPE_INT_RGB).also { + it.createGraphics().drawImage(image, 0, 0, Color.WHITE, null) + } + } else { + image + } + + ImageIO.write(result, format, baos) + + baos.toByteArray() + } + + fun resizeImageToByteArray( + imageBytes: ByteArray, + format: ImageType, + size: Int, + ): ByteArray { + val builder = resizeImageBuilder(imageBytes, format, size) ?: return imageBytes + + return ByteArrayOutputStream().use { + builder.toOutputStream(it) + it.toByteArray() + } + } + + fun resizeImageToBufferedImage( + imageBytes: ByteArray, + format: ImageType, + size: Int, + ): BufferedImage { + val builder = resizeImageBuilder(imageBytes, format, size) ?: return ImageIO.read(imageBytes.inputStream()) + + return builder.asBufferedImage() + } + + private fun resizeImageBuilder( + imageBytes: ByteArray, + format: ImageType, + size: Int, + ): Thumbnails.Builder? { + val longestEdge = + imageAnalyzer.getDimension(imageBytes.inputStream())?.let { + val mediaType = contentDetector.detectMediaType(imageBytes.inputStream()) + val longestEdge = max(it.height, it.width) + // don't resize if source and target format is the same, and source is smaller than desired + if (mediaType == format.mediaType && longestEdge <= size) return null + longestEdge + } + + // prevent upscaling + val resizeTo = if (longestEdge != null) min(longestEdge, size) else size + + return Thumbnails + .of(imageBytes.inputStream()) + .size(resizeTo, resizeTo) + .imageType(BufferedImage.TYPE_INT_ARGB) + .outputFormat(format.imageIOFormat) + } + + private fun containsAlphaChannel(image: BufferedImage): Boolean = image.colorModel.hasAlpha() + + private fun containsTransparency(image: BufferedImage): Boolean { + for (x in 0 until image.width) { + for (y in 0 until image.height) { + val pixel = image.getRGB(x, y) + if (pixel shr 24 == 0x00) return true + } + } + return false + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt new file mode 100644 index 000000000..91b3ea75c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.image + +enum class ImageType( + val mediaType: String, + val imageIOFormat: String, +) { + PNG("image/png", "PNG"), + JPEG("image/jpeg", "JPEG"), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt new file mode 100644 index 000000000..3a612c29d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.infrastructure.image + +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +@Service +class MosaicGenerator( + private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("thumbnailType") + private val thumbnailType: ImageType, + private val imageConverter: ImageConverter, +) { + private val ratio = 0.7066666667 + + fun createMosaic(images: List): ByteArray { + val height = komgaSettingsProvider.thumbnailSize.maxEdge + val width = (height * ratio).roundToInt() + val thumbs = images.map { imageConverter.resizeImageToBufferedImage(it, thumbnailType, height / 2) } + + return ByteArrayOutputStream().use { baos -> + val mosaic = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + mosaic.createGraphics().apply { + listOf( + 0 to 0, + width / 2 to 0, + 0 to height / 2, + width / 2 to height / 2, + ).forEachIndexed { index, (x, y) -> + thumbs.getOrNull(index)?.let { drawImage(it, x, y, null) } + } + } + + ImageIO.write(mosaic, thumbnailType.imageIOFormat, baos) + + baos.toByteArray() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt new file mode 100644 index 000000000..063080044 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt @@ -0,0 +1,246 @@ +package org.gotson.komga.infrastructure.jooq + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.infrastructure.jooq.RequiredJoin.ReadProgress +import org.gotson.komga.jooq.main.Tables +import org.jooq.Condition +import org.jooq.impl.DSL + +private val logger = KotlinLogging.logger {} + +/** + * Helper class to generate a Jooq Condition from a [SearchCondition.Book] and [SearchContext] + */ +class BookSearchHelper( + val context: SearchContext, +) { + fun toCondition(searchCondition: SearchCondition.Book?): Pair> { + val base = toCondition() + val search = toConditionInternal(searchCondition) + return search.first.and(base.first) to (search.second + base.second) + } + + fun toCondition(): Pair> { + val restrictions = ContentRestrictionsSearchHelper(context.restrictions).toCondition() + val authorizedLibraries = toConditionInternal(context.libraryIds) + return restrictions.first.and(authorizedLibraries.first) to (restrictions.second + authorizedLibraries.second) + } + + private fun toConditionInternal(libraryIds: Collection?): Pair> { + if (libraryIds == null) return DSL.noCondition() to emptySet() + if (libraryIds.isEmpty()) return DSL.falseCondition() to emptySet() + return toConditionInternal(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + } + + private fun toConditionInternal(searchCondition: SearchCondition.Book?): Pair> = + when (searchCondition) { + is SearchCondition.AllOfBook -> + searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair>, cond: SearchCondition.Book -> + val bookCondition = toConditionInternal(cond) + acc.first.and(bookCondition.first) to (acc.second + bookCondition.second) + } + + is SearchCondition.AnyOfBook -> + searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair>, cond: SearchCondition.Book -> + val bookCondition = toConditionInternal(cond) + acc.first.or(bookCondition.first) to (acc.second + bookCondition.second) + } + + is SearchCondition.LibraryId -> searchCondition.operator.toCondition(Tables.BOOK.LIBRARY_ID) to emptySet() + + is SearchCondition.SeriesId -> searchCondition.operator.toCondition(Tables.BOOK.SERIES_ID) to emptySet() + + is SearchCondition.ReadListId -> + when (searchCondition.operator) { + // for IS condition we have to do a join, so as to order the books by readList number + is SearchOperator.Is -> + rlbAlias(searchCondition.operator.value) + .READLIST_ID + .eq(searchCondition.operator.value) to setOf(RequiredJoin.ReadList(searchCondition.operator.value)) + + is SearchOperator.IsNot -> { + val inner = { readListId: String -> + DSL + .select(Tables.READLIST_BOOK.BOOK_ID) + .from(Tables.READLIST_BOOK) + .where(Tables.READLIST_BOOK.READLIST_ID.eq(readListId)) + } + Tables.BOOK.ID.notIn(inner(searchCondition.operator.value)) to emptySet() + } + } + + is SearchCondition.Title -> + searchCondition.operator.toCondition(Tables.BOOK_METADATA.TITLE) to + setOf( + RequiredJoin.BookMetadata, + ) + + is SearchCondition.Deleted -> + Tables.BOOK.DELETED_DATE.let { + when (searchCondition.operator) { + SearchOperator.IsFalse -> it.isNull + SearchOperator.IsTrue -> it.isNotNull + } + } to emptySet() + + is SearchCondition.ReleaseDate -> + searchCondition.operator.toCondition(Tables.BOOK_METADATA.RELEASE_DATE) to + setOf( + RequiredJoin.BookMetadata, + ) + + is SearchCondition.NumberSort -> + searchCondition.operator.toCondition(Tables.BOOK_METADATA.NUMBER_SORT) to + setOf( + RequiredJoin.BookMetadata, + ) + + is SearchCondition.ReadStatus -> + if (context.userId == null) { + logger.warn { "SearchCondition.ReadStatus without userId in search context" } + DSL.falseCondition() to emptySet() + } else { + Tables.READ_PROGRESS.COMPLETED.let { + when (searchCondition.operator) { + is SearchOperator.Is -> { + when (searchCondition.operator.value) { + ReadStatus.UNREAD -> it.isNull + ReadStatus.READ -> it.isTrue + ReadStatus.IN_PROGRESS -> it.isFalse + } + } + + is SearchOperator.IsNot -> + when (searchCondition.operator.value) { + ReadStatus.UNREAD -> it.isNotNull + ReadStatus.READ -> it.isNull.or(it.isFalse) + ReadStatus.IN_PROGRESS -> it.isTrue.or(it.isNull) + } + } + } to setOf(ReadProgress(context.userId)) + } + + is SearchCondition.MediaStatus -> + searchCondition.operator.toCondition(Tables.MEDIA.STATUS, Media.Status::name) to + setOf( + RequiredJoin.Media, + ) + + is SearchCondition.MediaProfile -> + Tables.MEDIA.MEDIA_TYPE.let { field -> + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(MediaType.matchingMediaProfile(searchCondition.operator.value).map { it.type }.toSet()) + is SearchOperator.IsNot -> field.notIn(MediaType.matchingMediaProfile(searchCondition.operator.value).map { it.type }.toSet()) + } + } to setOf(RequiredJoin.Media) + + is SearchCondition.Tag -> + Tables.BOOK.ID.let { field -> + val innerEquals = { tag: String -> + DSL + .select(Tables.BOOK_METADATA_TAG.BOOK_ID) + .from(Tables.BOOK_METADATA_TAG) + .where( + Tables.BOOK_METADATA_TAG.TAG + .unicode1() + .equal(tag), + ) + } + val innerAny = { + DSL + .select(Tables.BOOK_METADATA_TAG.BOOK_ID) + .from(Tables.BOOK_METADATA_TAG) + .where(Tables.BOOK_METADATA_TAG.TAG.isNotNull) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + + is SearchCondition.Author -> + Tables.BOOK.ID.let { field -> + val inner = { name: String?, role: String? -> + DSL + .select(Tables.BOOK_METADATA_AUTHOR.BOOK_ID) + .from(Tables.BOOK_METADATA_AUTHOR) + .where(DSL.noCondition()) + .apply { + if (name != null) + and( + Tables.BOOK_METADATA_AUTHOR.NAME + .unicode1() + .equal(name), + ) + }.apply { + if (role != null) + and( + Tables.BOOK_METADATA_AUTHOR.ROLE + .unicode1() + .equal(role), + ) + } + } + when (searchCondition.operator) { + is SearchOperator.Is -> { + if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null) + DSL.noCondition() + else + field.`in`(inner(searchCondition.operator.value.name, searchCondition.operator.value.role)) + } + + is SearchOperator.IsNot -> { + if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null) + DSL.noCondition() + else + field.notIn(inner(searchCondition.operator.value.name, searchCondition.operator.value.role)) + } + } to emptySet() + } + + is SearchCondition.Poster -> + Tables.BOOK.ID.let { field -> + val inner = { type: SearchCondition.PosterMatch.Type?, selected: Boolean? -> + DSL + .select(Tables.THUMBNAIL_BOOK.BOOK_ID) + .from(Tables.THUMBNAIL_BOOK) + .where(DSL.noCondition()) + .apply { + if (type != null) + and(Tables.THUMBNAIL_BOOK.TYPE.equalIgnoreCase(type.name)) + if (selected != null && selected) + and(Tables.THUMBNAIL_BOOK.SELECTED.isTrue) + if (selected != null && !selected) + and(Tables.THUMBNAIL_BOOK.SELECTED.isFalse) + } + } + when (searchCondition.operator) { + is SearchOperator.Is -> { + if (searchCondition.operator.value.type == null && searchCondition.operator.value.selected == null) + DSL.noCondition() + else + field.`in`(inner(searchCondition.operator.value.type, searchCondition.operator.value.selected)) + } + + is SearchOperator.IsNot -> + if (searchCondition.operator.value.type == null && searchCondition.operator.value.selected == null) + DSL.noCondition() + else + field.notIn(inner(searchCondition.operator.value.type, searchCondition.operator.value.selected)) + } to emptySet() + } + + is SearchCondition.OneShot -> searchCondition.operator.toCondition(Tables.BOOK.ONESHOT) to emptySet() + + null -> DSL.noCondition() to emptySet() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ContentRestrictionsSearchHelper.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ContentRestrictionsSearchHelper.kt new file mode 100644 index 000000000..828ae911a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ContentRestrictionsSearchHelper.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.jooq.main.Tables +import org.jooq.Condition +import org.jooq.impl.DSL + +class ContentRestrictionsSearchHelper( + val restrictions: ContentRestrictions, +) { + fun toCondition(): Pair> { + val ageAllowed = + if (restrictions.ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) { + Tables.SERIES_METADATA.AGE_RATING.isNotNull + .and(Tables.SERIES_METADATA.AGE_RATING.lessOrEqual(restrictions.ageRestriction.age)) to setOf(RequiredJoin.SeriesMetadata) + } else { + DSL.noCondition() to emptySet() + } + + val labelAllowed = + if (restrictions.labelsAllow.isNotEmpty()) + Tables.SERIES_METADATA.SERIES_ID.`in`( + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(restrictions.labelsAllow)), + ) to setOf(RequiredJoin.SeriesMetadata) + else + DSL.noCondition() to emptySet() + + val ageDenied = + if (restrictions.ageRestriction?.restriction == AllowExclude.EXCLUDE) + Tables.SERIES_METADATA.AGE_RATING.isNull + .or(Tables.SERIES_METADATA.AGE_RATING.lessThan(restrictions.ageRestriction.age)) to setOf(RequiredJoin.SeriesMetadata) + else + DSL.noCondition() to emptySet() + + val labelDenied = + if (restrictions.labelsExclude.isNotEmpty()) + Tables.SERIES_METADATA.SERIES_ID.notIn( + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(restrictions.labelsExclude)), + ) to setOf(RequiredJoin.SeriesMetadata) + else + DSL.noCondition() to emptySet() + + return ageAllowed.first + .or(labelAllowed.first) + .and(ageDenied.first.and(labelDenied.first)) to (ageAllowed.second + labelAllowed.second + ageDenied.second + labelDenied.second) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaJooqConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaJooqConfiguration.kt new file mode 100644 index 000000000..c8b7c3b75 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaJooqConfiguration.kt @@ -0,0 +1,63 @@ +package org.gotson.komga.infrastructure.jooq + +import org.jooq.DSLContext +import org.jooq.ExecuteListenerProvider +import org.jooq.SQLDialect +import org.jooq.TransactionProvider +import org.jooq.impl.DataSourceConnectionProvider +import org.jooq.impl.DefaultConfiguration +import org.jooq.impl.DefaultDSLContext +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy +import javax.sql.DataSource + +// taken from https://github.com/spring-projects/spring-boot/blob/v3.1.4/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfiguration.java +// as advised in https://docs.spring.io/spring-boot/docs/3.1.4/reference/htmlsingle/#howto.data-access.configure-jooq-with-multiple-datasources +@Configuration +class KomgaJooqConfiguration { + @Bean("dslContextRW") + @Primary + fun mainDslContextRW( + dataSource: DataSource, + transactionProvider: ObjectProvider, + executeListenerProviders: ObjectProvider, + ): DSLContext = createDslContext(dataSource, transactionProvider, executeListenerProviders) + + @Bean("dslContextRO") + fun mainDslContextRO( + @Qualifier("sqliteDataSourceRO") dataSource: DataSource, + transactionProvider: ObjectProvider, + executeListenerProviders: ObjectProvider, + ): DSLContext = createDslContext(dataSource, transactionProvider, executeListenerProviders) + + @Bean("tasksDslContextRW") + fun tasksDslContextRW( + @Qualifier("tasksDataSourceRW") dataSource: DataSource, + transactionProvider: ObjectProvider, + executeListenerProviders: ObjectProvider, + ): DSLContext = createDslContext(dataSource, transactionProvider, executeListenerProviders) + + @Bean("tasksDslContextRO") + fun tasksDslContextRO( + @Qualifier("tasksDataSourceRO") dataSource: DataSource, + transactionProvider: ObjectProvider, + executeListenerProviders: ObjectProvider, + ): DSLContext = createDslContext(dataSource, transactionProvider, executeListenerProviders) + + private fun createDslContext( + dataSource: DataSource, + transactionProvider: ObjectProvider, + executeListenerProviders: ObjectProvider, + ) = DefaultDSLContext( + DefaultConfiguration().also { configuration -> + configuration.set(SQLDialect.SQLITE) + configuration.set(DataSourceConnectionProvider(TransactionAwareDataSourceProxy(dataSource))) + transactionProvider.ifAvailable { newTransactionProvider: TransactionProvider? -> configuration.set(newTransactionProvider) } + configuration.set(*executeListenerProviders.orderedStream().toList().toTypedArray()) + }, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/RequiredJoin.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/RequiredJoin.kt new file mode 100644 index 000000000..cb2a64a1d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/RequiredJoin.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.infrastructure.jooq + +/** + * An indication that some tables need to be joined for query conditions to work + */ +sealed class RequiredJoin { + data object BookMetadata : RequiredJoin() + + data object Media : RequiredJoin() + + data class ReadProgress( + val userId: String, + ) : RequiredJoin() + + data class ReadList( + val readListId: String, + ) : RequiredJoin() + + data class Collection( + val collectionId: String, + ) : RequiredJoin() + + data object BookMetadataAggregation : RequiredJoin() + + data object SeriesMetadata : RequiredJoin() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SearchOperatorUtils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SearchOperatorUtils.kt new file mode 100644 index 000000000..b2f2b413a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SearchOperatorUtils.kt @@ -0,0 +1,76 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.language.stripAccents +import org.jooq.Field +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit + +fun SearchOperator.Equality.toCondition( + field: Field, + ignoreCase: Boolean = false, +) = when (this) { + is SearchOperator.Is -> if (ignoreCase) field.unicode1().equal(this.value) else field.equal(this.value) + is SearchOperator.IsNot -> if (ignoreCase) field.unicode1().notEqual(this.value) else field.notEqual(this.value) +} + +fun SearchOperator.Equality.toCondition(field: Field) = + when (this) { + is SearchOperator.Is -> field.eq(this.value) + is SearchOperator.IsNot -> field.ne(this.value) + } + +fun SearchOperator.Equality.toCondition( + field: Field, + converter: (T) -> String, +) = when (this) { + is SearchOperator.Is -> field.eq(converter(this.value)) + is SearchOperator.IsNot -> field.ne(converter(this.value)) +} + +fun SearchOperator.StringOp.toCondition(field: Field) = + when (this) { + is SearchOperator.BeginsWith -> field.udfStripAccents().startsWithIgnoreCase(value.stripAccents()) + is SearchOperator.DoesNotBeginWith -> field.udfStripAccents().startsWithIgnoreCase(value.stripAccents()).not() + is SearchOperator.EndsWith -> field.udfStripAccents().endsWithIgnoreCase(value.stripAccents()) + is SearchOperator.DoesNotEndWith -> field.udfStripAccents().endsWithIgnoreCase(value.stripAccents()).not() + is SearchOperator.Contains -> field.udfStripAccents().containsIgnoreCase(value.stripAccents()) + is SearchOperator.DoesNotContain -> field.udfStripAccents().notContainsIgnoreCase(value.stripAccents()) + is SearchOperator.Is<*> -> field.unicode1().equal(value as String) + is SearchOperator.IsNot<*> -> field.unicode1().notEqual(value as String) + } + +fun SearchOperator.Date.toCondition(field: Field) = + when (this) { + is SearchOperator.After -> field.gt(dateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDate()) + is SearchOperator.Before -> field.lt(dateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDate()) + is SearchOperator.IsInTheLast -> field.gt(LocalDate.now(ZoneOffset.UTC).minus(duration.toDays(), ChronoUnit.DAYS)) + is SearchOperator.IsNotInTheLast -> field.lt(LocalDate.now(ZoneOffset.UTC).minus(duration.toDays(), ChronoUnit.DAYS)) + SearchOperator.IsNull -> field.isNull + SearchOperator.IsNotNull -> field.isNotNull + } + +fun SearchOperator.NumericNullable.toCondition(field: Field) = + when (this) { + is SearchOperator.Is<*> -> field.eq(value as Int) + is SearchOperator.IsNot<*> -> field.ne(value as Int).or(field.isNull) + is SearchOperator.GreaterThan -> field.greaterOrEqual(value) + is SearchOperator.LessThan -> field.lessOrEqual(value) + is SearchOperator.IsNullT -> field.isNull + is SearchOperator.IsNotNullT -> field.isNotNull + } + +fun SearchOperator.Numeric.toCondition(field: Field) = + when (this) { + is SearchOperator.Is<*> -> field.eq(value as Float) + is SearchOperator.IsNot<*> -> field.ne(value as Float) + is SearchOperator.GreaterThan -> field.greaterOrEqual(value) + is SearchOperator.LessThan -> field.lessOrEqual(value) + } + +fun SearchOperator.Boolean.toCondition(field: Field) = + when (this) { + SearchOperator.IsTrue -> field.isTrue + SearchOperator.IsFalse -> field.isFalse + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt new file mode 100644 index 000000000..07ad92569 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt @@ -0,0 +1,282 @@ +package org.gotson.komga.infrastructure.jooq + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.jooq.main.Tables +import org.jooq.Condition +import org.jooq.impl.DSL + +private val logger = KotlinLogging.logger {} + +/** + * Helper class to generate a Jooq Condition from a [SearchCondition.Series] and [SearchContext] + */ +class SeriesSearchHelper( + val context: SearchContext, +) { + fun toCondition(searchCondition: SearchCondition.Series?): Pair> { + val base = toCondition() + val search = toConditionInternal(searchCondition) + return search.first.and(base.first) to (search.second + base.second) + } + + fun toCondition(): Pair> { + val restrictions = ContentRestrictionsSearchHelper(context.restrictions).toCondition() + val authorizedLibraries = toConditionInternal(context.libraryIds) + return restrictions.first.and(authorizedLibraries.first) to (restrictions.second + authorizedLibraries.second) + } + + private fun toConditionInternal(libraryIds: Collection?): Pair> { + if (libraryIds == null) return DSL.noCondition() to emptySet() + if (libraryIds.isEmpty()) return DSL.falseCondition() to emptySet() + return toConditionInternal(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + } + + private fun toConditionInternal(searchCondition: SearchCondition.Series?): Pair> = + when (searchCondition) { + is SearchCondition.AllOfSeries -> + searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair>, cond: SearchCondition.Series -> + val seriesCondition = toConditionInternal(cond) + acc.first.and(seriesCondition.first) to (acc.second + seriesCondition.second) + } + + is SearchCondition.AnyOfSeries -> + searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair>, cond: SearchCondition.Series -> + val seriesCondition = toConditionInternal(cond) + acc.first.or(seriesCondition.first) to (acc.second + seriesCondition.second) + } + + is SearchCondition.LibraryId -> searchCondition.operator.toCondition(Tables.SERIES.LIBRARY_ID) to emptySet() + + is SearchCondition.Deleted -> + Tables.SERIES.DELETED_DATE.let { + when (searchCondition.operator) { + SearchOperator.IsFalse -> it.isNull + SearchOperator.IsTrue -> it.isNotNull + } + } to emptySet() + + is SearchCondition.ReleaseDate -> + searchCondition.operator.toCondition(Tables.BOOK_METADATA_AGGREGATION.RELEASE_DATE) to setOf(RequiredJoin.BookMetadataAggregation) + + is SearchCondition.ReadStatus -> + if (context.userId == null) { + logger.warn { "SearchCondition.ReadStatus without userId in search context" } + DSL.falseCondition() to emptySet() + } else { + Tables.READ_PROGRESS_SERIES.READ_COUNT.let { field -> + when (searchCondition.operator) { + is SearchOperator.Is -> { + when (searchCondition.operator.value) { + ReadStatus.UNREAD -> field.isNull + ReadStatus.READ -> field.eq(Tables.SERIES.BOOK_COUNT) + ReadStatus.IN_PROGRESS -> field.ne(Tables.SERIES.BOOK_COUNT) + } + } + + is SearchOperator.IsNot -> + when (searchCondition.operator.value) { + ReadStatus.UNREAD -> field.isNotNull + ReadStatus.READ -> field.ne(Tables.SERIES.BOOK_COUNT).or(field.isNull) + ReadStatus.IN_PROGRESS -> field.eq(Tables.SERIES.BOOK_COUNT).or(field.isNull) + } + } + } to setOf(RequiredJoin.ReadProgress(context.userId)) + } + + is SearchCondition.SeriesStatus -> + searchCondition.operator.toCondition(Tables.SERIES_METADATA.STATUS, SeriesMetadata.Status::name) to + setOf( + RequiredJoin.SeriesMetadata, + ) + + is SearchCondition.Tag -> + Tables.SERIES.ID.let { field -> + val innerEquals = { tag: String -> + DSL + .select(Tables.SERIES_METADATA_TAG.SERIES_ID) + .from(Tables.SERIES_METADATA_TAG) + .where( + Tables.SERIES_METADATA_TAG.TAG + .unicode1() + .equal(tag), + ).union( + DSL + .select(Tables.BOOK_METADATA_AGGREGATION_TAG.SERIES_ID) + .from(Tables.BOOK_METADATA_AGGREGATION_TAG) + .where( + Tables.BOOK_METADATA_AGGREGATION_TAG.TAG + .unicode1() + .equal(tag), + ), + ) + } + val innerAny = { + DSL + .select(Tables.SERIES_METADATA_TAG.SERIES_ID) + .from(Tables.SERIES_METADATA_TAG) + .where(Tables.SERIES_METADATA_TAG.TAG.isNotNull) + .union( + DSL + .select(Tables.BOOK_METADATA_AGGREGATION_TAG.SERIES_ID) + .from(Tables.BOOK_METADATA_AGGREGATION_TAG) + .where(Tables.BOOK_METADATA_AGGREGATION_TAG.TAG.isNotNull), + ) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + + is SearchCondition.Author -> + Tables.SERIES.ID.let { field -> + val inner = { name: String?, role: String? -> + DSL + .select(Tables.BOOK_METADATA_AGGREGATION_AUTHOR.SERIES_ID) + .from(Tables.BOOK_METADATA_AGGREGATION_AUTHOR) + .where(DSL.noCondition()) + .apply { + if (name != null) + and( + Tables.BOOK_METADATA_AGGREGATION_AUTHOR.NAME + .unicode1() + .equal(name), + ) + }.apply { + if (role != null) + and( + Tables.BOOK_METADATA_AGGREGATION_AUTHOR.ROLE + .unicode1() + .equal(role), + ) + } + } + when (searchCondition.operator) { + is SearchOperator.Is -> { + if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null) + DSL.noCondition() + else + field.`in`(inner(searchCondition.operator.value.name, searchCondition.operator.value.role)) + } + + is SearchOperator.IsNot -> { + if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null) + DSL.noCondition() + else + field.notIn(inner(searchCondition.operator.value.name, searchCondition.operator.value.role)) + } + } to emptySet() + } + + is SearchCondition.OneShot -> searchCondition.operator.toCondition(Tables.SERIES.ONESHOT) to emptySet() + + is SearchCondition.AgeRating -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.AGE_RATING) to setOf(RequiredJoin.SeriesMetadata) + + is SearchCondition.CollectionId -> + when (searchCondition.operator) { + // for IS condition we have to do a join, so as to order the series by collection number + is SearchOperator.Is -> + csAlias(searchCondition.operator.value) + .COLLECTION_ID + .eq(searchCondition.operator.value) to setOf(RequiredJoin.Collection(searchCondition.operator.value)) + + is SearchOperator.IsNot -> { + val inner = { collectionId: String -> + DSL + .select(Tables.COLLECTION_SERIES.SERIES_ID) + .from(Tables.COLLECTION_SERIES) + .where(Tables.COLLECTION_SERIES.COLLECTION_ID.eq(collectionId)) + } + Tables.SERIES.ID.notIn(inner(searchCondition.operator.value)) to emptySet() + } + } + + is SearchCondition.Complete -> + Tables.SERIES_METADATA.TOTAL_BOOK_COUNT.let { field -> + when (searchCondition.operator) { + SearchOperator.IsTrue -> field.isNotNull.and(field.eq(Tables.SERIES.BOOK_COUNT)) + SearchOperator.IsFalse -> field.isNotNull.and(field.ne(Tables.SERIES.BOOK_COUNT)) + } to setOf(RequiredJoin.SeriesMetadata) + } + + is SearchCondition.Genre -> + Tables.SERIES.ID.let { field -> + val innerEquals = { genre: String -> + DSL + .select(Tables.SERIES_METADATA_GENRE.SERIES_ID) + .from(Tables.SERIES_METADATA_GENRE) + .where( + Tables.SERIES_METADATA_GENRE.GENRE + .unicode1() + .equal(genre), + ) + } + val innerAny = { + DSL + .select(Tables.SERIES_METADATA_GENRE.SERIES_ID) + .from(Tables.SERIES_METADATA_GENRE) + .where(Tables.SERIES_METADATA_GENRE.GENRE.isNotNull) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + + is SearchCondition.Language -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.LANGUAGE, true) to setOf(RequiredJoin.SeriesMetadata) + + is SearchCondition.Publisher -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.PUBLISHER, true) to setOf(RequiredJoin.SeriesMetadata) + + is SearchCondition.SharingLabel -> + Tables.SERIES.ID.let { field -> + val innerEquals = { label: String -> + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where( + Tables.SERIES_METADATA_SHARING.LABEL + .unicode1() + .equal(label), + ) + } + val innerAny = { + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where(Tables.SERIES_METADATA_SHARING.LABEL.isNotNull) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + + is SearchCondition.Title -> + searchCondition.operator.toCondition(Tables.SERIES_METADATA.TITLE) to + setOf( + RequiredJoin.SeriesMetadata, + ) + + is SearchCondition.TitleSort -> + searchCondition.operator.toCondition(Tables.SERIES_METADATA.TITLE_SORT) to + setOf( + RequiredJoin.SeriesMetadata, + ) + + null -> DSL.noCondition() to emptySet() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SplitDslDaoBase.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SplitDslDaoBase.kt new file mode 100644 index 000000000..37855f4f4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SplitDslDaoBase.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.infrastructure.jooq + +import org.jooq.DSLContext +import org.springframework.transaction.support.TransactionSynchronizationManager + +abstract class SplitDslDaoBase { + val dslRW: DSLContext + private val _dslRO: DSLContext + + constructor(dslRW: DSLContext, dslRO: DSLContext) { + this.dslRW = dslRW + this._dslRO = dslRO + } + + val dslRO: DSLContext + get() = + if (TransactionSynchronizationManager.isActualTransactionActive() && !TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + dslRW + else + _dslRO +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/TempTable.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/TempTable.kt new file mode 100644 index 000000000..ff1d231d2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/TempTable.kt @@ -0,0 +1,64 @@ +package org.gotson.komga.infrastructure.jooq + +import com.github.f4b6a3.tsid.TsidCreator +import org.jooq.DSLContext +import org.jooq.impl.DSL +import java.io.Closeable + +/** + * Temporary table with a single STRING column. + * This is made to store collection of values that are too long to be specified in a query condition, + * by using a sub-select instead. + * + * The table name is automatically generated, and the table is dropped when the object is closed. + */ +class TempTable private constructor( + private val dslContext: DSLContext, + val name: String, +) : Closeable { + constructor(dslContext: DSLContext) : this(dslContext, generateName()) + + private var created = false + + fun create() { + dslContext.execute("CREATE TEMPORARY TABLE $name (STRING varchar NOT NULL);") + created = true + } + + fun insertTempStrings( + batchSize: Int, + collection: Collection, + ) { + if (!created) create() + if (collection.isNotEmpty()) { + collection.chunked(batchSize).forEach { chunk -> + dslContext + .batch( + dslContext.insertInto(DSL.table(DSL.name(name)), DSL.field(DSL.name("STRING"), String::class.java)).values(null as String?), + ).also { step -> + chunk.forEach { + step.bind(it) + } + }.execute() + } + } + } + + fun selectTempStrings() = dslContext.select(DSL.field(DSL.name("STRING"), String::class.java)).from(DSL.table(DSL.name(name))) + + override fun close() { + if (created) dslContext.dropTableIfExists(name).execute() + } + + companion object { + private fun generateName() = "temp_${TsidCreator.getTsid256()}" + + fun DSLContext.withTempTable( + batchSize: Int, + collection: Collection, + ) = TempTable(this, generateName()) + .also { + it.insertTempStrings(batchSize, collection) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/UnpagedSorted.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/UnpagedSorted.kt new file mode 100644 index 000000000..61efde92e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/UnpagedSorted.kt @@ -0,0 +1,28 @@ +package org.gotson.komga.infrastructure.jooq + +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort + +class UnpagedSorted( + private val sort: Sort, +) : Pageable { + override fun getPageNumber(): Int = throw UnsupportedOperationException() + + override fun hasPrevious(): Boolean = false + + override fun getSort(): Sort = sort + + override fun isPaged(): Boolean = false + + override fun next(): Pageable = this + + override fun getPageSize(): Int = throw UnsupportedOperationException() + + override fun getOffset(): Long = throw UnsupportedOperationException() + + override fun first(): Pageable = this + + override fun withPage(pageNumber: Int): Pageable = this + + override fun previousOrFirst(): Pageable = this +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt new file mode 100644 index 000000000..f8a74be11 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt @@ -0,0 +1,156 @@ +package org.gotson.komga.infrastructure.jooq + +import com.fasterxml.jackson.databind.ObjectMapper +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.MediaExtension +import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource +import org.gotson.komga.jooq.main.Tables +import org.jooq.Condition +import org.jooq.Field +import org.jooq.SortField +import org.jooq.impl.DSL +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +fun Field.noCase() = this.collate("NOCASE") + +/** + * Warning: SQLite doesn't use collations with LIKE + */ +fun Field.unicode1() = this.collate(SqliteUdfDataSource.COLLATION_UNICODE_1) + +fun Field.unicode3() = this.collate(SqliteUdfDataSource.COLLATION_UNICODE_3) + +fun Field.udfStripAccents() = DSL.function(SqliteUdfDataSource.UDF_STRIP_ACCENTS, String::class.java, this) + +fun Sort.toOrderBy(sorts: Map>): List> = + this.mapNotNull { + it.toSortField(sorts) + } + +fun Sort.Order.toSortField(sorts: Map>): SortField? { + val f = sorts[property] ?: return null + return if (isAscending) f.asc() else f.desc() +} + +fun Field.sortByValues( + values: List, + asc: Boolean = true, +): Field { + var c = DSL.choose(this).`when`("dummy dsl", Int.MAX_VALUE) + val multiplier = if (asc) 1 else -1 + values.forEachIndexed { index, value -> c = c.`when`(value, index * multiplier) } + return c.otherwise(Int.MAX_VALUE) +} + +fun Field.inOrNoCondition(list: Collection?): Condition = + when { + list == null -> DSL.noCondition() + list.isEmpty() -> DSL.falseCondition() + else -> this.`in`(list) + } + +fun ContentRestrictions.toCondition(): Condition { + val ageAllowed = + if (ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) { + Tables.SERIES_METADATA.AGE_RATING.isNotNull + .and(Tables.SERIES_METADATA.AGE_RATING.lessOrEqual(ageRestriction.age)) + } else { + DSL.noCondition() + } + + val labelAllowed = + if (labelsAllow.isNotEmpty()) + Tables.SERIES_METADATA.SERIES_ID.`in`( + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(labelsAllow)), + ) + else + DSL.noCondition() + + val ageDenied = + if (ageRestriction?.restriction == AllowExclude.EXCLUDE) + Tables.SERIES_METADATA.AGE_RATING.isNull + .or(Tables.SERIES_METADATA.AGE_RATING.lessThan(ageRestriction.age)) + else + DSL.noCondition() + + val labelDenied = + if (labelsExclude.isNotEmpty()) + Tables.SERIES_METADATA.SERIES_ID.notIn( + DSL + .select(Tables.SERIES_METADATA_SHARING.SERIES_ID) + .from(Tables.SERIES_METADATA_SHARING) + .where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(labelsExclude)), + ) + else + DSL.noCondition() + + return ageAllowed + .or(labelAllowed) + .and(ageDenied.and(labelDenied)) +} + +fun ObjectMapper.serializeJsonGz(obj: Any): ByteArray? = + try { + ByteArrayOutputStream().use { baos -> + GZIPOutputStream(baos).use { gz -> + this.writeValue(gz, obj) + baos.toByteArray() + } + } + } catch (e: Exception) { + null + } + +inline fun ObjectMapper.deserializeJsonGz(gzJson: ByteArray?): T? { + if (gzJson == null) return null + return try { + GZIPInputStream(gzJson.inputStream()).use { gz -> + this.readValue(gz, T::class.java) as T + } + } catch (e: Exception) { + null + } +} + +fun ObjectMapper.deserializeMediaExtension( + extensionClass: String?, + extensionBlob: ByteArray?, +): MediaExtension? { + if (extensionClass == null || extensionBlob == null) return null + return try { + GZIPInputStream(extensionBlob.inputStream()).use { gz -> + this.readValue(gz, Class.forName(extensionClass)) as MediaExtension + } + } catch (e: Exception) { + null + } +} + +fun rlbAlias(readListId: String) = Tables.READLIST_BOOK.`as`("RLB_$readListId") + +fun csAlias(collectionId: String) = Tables.COLLECTION_SERIES.`as`("CS_$collectionId") + +fun buildPage( + items: List, + pageable: Pageable, + count: Int, + sort: Sort?, +): PageImpl = + PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, sort ?: pageable.sort) + else + PageRequest.of(0, maxOf(count, 20), sort ?: pageable.sort), + count.toLong(), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt new file mode 100644 index 000000000..d6b1aad75 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt @@ -0,0 +1,132 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.AuthenticationActivity +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.toOrderBy +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.AuthenticationActivityRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class AuthenticationActivityDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + AuthenticationActivityRepository { + private val aa = Tables.AUTHENTICATION_ACTIVITY + + private val sorts = + mapOf( + "dateTime" to aa.DATE_TIME, + "email" to aa.EMAIL, + "success" to aa.SUCCESS, + "ip" to aa.IP, + "error" to aa.ERROR, + "userId" to aa.USER_ID, + "userAgent" to aa.USER_AGENT, + ) + + override fun findAll(pageable: Pageable): Page { + val conditions: Condition = DSL.trueCondition() + return findAll(conditions, pageable) + } + + override fun findAllByUser( + user: KomgaUser, + pageable: Pageable, + ): Page { + val conditions = aa.USER_ID.eq(user.id).or(aa.EMAIL.eq(user.email)) + return findAll(conditions, pageable) + } + + override fun findMostRecentByUser( + user: KomgaUser, + apiKeyId: String?, + ): AuthenticationActivity? = + dslRO + .selectFrom(aa) + .where(aa.USER_ID.eq(user.id)) + .or(aa.EMAIL.eq(user.email)) + .apply { apiKeyId?.let { and(aa.API_KEY_ID.eq(it)) } } + .orderBy(aa.DATE_TIME.desc()) + .limit(1) + .fetchOne() + ?.toDomain() + + private fun findAll( + conditions: Condition, + pageable: Pageable, + ): PageImpl { + val count = dslRO.fetchCount(aa, conditions) + + val orderBy = pageable.sort.toOrderBy(sorts) + + val items = + dslRO + .selectFrom(aa) + .where(conditions) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(aa) + .map { it.toDomain() } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun insert(activity: AuthenticationActivity) { + dslRW + .insertInto(aa, aa.USER_ID, aa.EMAIL, aa.API_KEY_ID, aa.API_KEY_COMMENT, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR, aa.SOURCE) + .values(activity.userId, activity.email, activity.apiKeyId, activity.apiKeyComment, activity.ip, activity.userAgent, activity.success, activity.error, activity.source) + .execute() + } + + override fun deleteByUser(user: KomgaUser) { + dslRW + .deleteFrom(aa) + .where(aa.USER_ID.eq(user.id)) + .or(aa.EMAIL.eq(user.email)) + .execute() + } + + override fun deleteOlderThan(dateTime: LocalDateTime) { + dslRW + .deleteFrom(aa) + .where(aa.DATE_TIME.lt(dateTime)) + .execute() + } + + private fun AuthenticationActivityRecord.toDomain() = + AuthenticationActivity( + userId = userId, + email = email, + apiKeyId = apiKeyId, + apiKeyComment = apiKeyComment, + ip = ip, + userAgent = userAgent, + success = success, + error = error, + dateTime = dateTime.toCurrentTimeZone(), + source = source, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookCommonDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookCommonDao.kt new file mode 100644 index 000000000..b5ad1149d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookCommonDao.kt @@ -0,0 +1,132 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.toCondition +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.jooq.Field +import org.jooq.Record +import org.jooq.Record1 +import org.jooq.SelectConditionStep +import org.jooq.SelectJoinStep +import org.jooq.impl.DSL +import org.jooq.impl.DSL.falseCondition +import org.jooq.impl.DSL.name +import org.jooq.impl.DSL.select +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class BookCommonDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO) { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val r = Tables.READ_PROGRESS + private val rs = Tables.READ_PROGRESS_SERIES + private val s = Tables.SERIES + private val sd = Tables.SERIES_METADATA + + fun getBooksOnDeckQuery( + userId: String, + restrictions: ContentRestrictions, + filterOnLibraryIds: Collection?, + selectFields: Array>, + ): Triple, Field, SelectJoinStep>> { + // On Deck books are the first unread book in a series that has at least one book read, but is not in progress + // cteSeries will return On Deck series + val cteSeries = + name("cte_series") + .asMaterialized( + select(s.ID, rs.MOST_RECENT_READ_DATE) + .from(s) + .innerJoin(rs) + .on(s.ID.eq(rs.SERIES_ID).and(rs.USER_ID.eq(userId))) + .innerJoin(sd) + .on(s.ID.eq(sd.SERIES_ID)) + .where(rs.IN_PROGRESS_COUNT.eq(0)) + .and(rs.READ_COUNT.ne(s.BOOK_COUNT)) + .and(restrictions.toCondition()) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }, + ) + + val cteBooksFieldBookId = b.ID.`as`("cte_books_book_id") + val cteBooksFieldSeriesId = b.SERIES_ID.`as`("cte_books_series_id") + val cteBooksFieldNumberSort = d.NUMBER_SORT.`as`("cte_books_number_sort") + val cteBooks = + name("cte_books") + .asMaterialized( + select( + cteBooksFieldBookId, + cteBooksFieldSeriesId, + cteBooksFieldNumberSort, + ).from(b) + .innerJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(r.USER_ID.eq(userId)) + .where(r.COMPLETED.isNull) + .and( + b.SERIES_ID.`in`(select(cteSeries.field(s.ID)).from(cteSeries)), + ), + ) + + // finding the first unread book by number_sort is similar to finding the greatest-n-per-group + val b1 = cteBooks.`as`("b1") + val b2 = cteBooks.`as`("b2") + val query = + dslRO + .with(cteSeries) + .with(cteBooks) + .select(*selectFields) + .from(cteSeries) + .innerJoin(b1) + .on(cteSeries.field(s.ID)!!.eq(b1.field(cteBooksFieldSeriesId))) + // we join the cteBooks table on itself, using the grouping ID (seriesId) using a left outer join + // it returns the row b1 for which no other row b2 exists with the same seriesId and a smaller numberSort + // when b2 is null, it means the left outer join fond no such match, and therefore b1 has the smaller value of numberSort + .leftOuterJoin(b2) + .on( + b1 + .field(cteBooksFieldSeriesId)!! + .eq(b2.field(cteBooksFieldSeriesId)) + .and( + b1 + .field(cteBooksFieldNumberSort)!! + .gt(b2.field(cteBooksFieldNumberSort)) + .or( + b1 + .field(cteBooksFieldNumberSort)!! + .eq(b2.field(cteBooksFieldNumberSort)) + .and(b1.field(cteBooksFieldBookId)!!.gt(b2.field(cteBooksFieldBookId))), + ), + ), + ).innerJoin(b) + .on(b1.field(cteBooksFieldBookId)!!.eq(b.ID)) + .innerJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .innerJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .innerJoin(sd) + .on(b.SERIES_ID.eq(sd.SERIES_ID)) + // fetchAndMap expects some values for ReadProgress + // On Deck books are by definition unread, thus don't have read progress + // we join on the table to keep fetchAndMap, with a false condition to only get null values + .leftOuterJoin(r) + .on(falseCondition()) + .where(b2.field(cteBooksFieldBookId)!!.isNull) + + val mostRecentReadDateQuery = + dslRO + .with(cteSeries) + .select(DSL.max(cteSeries.field(rs.MOST_RECENT_READ_DATE))) + .from(cteSeries) + + return Triple(query, cteSeries.field(rs.MOST_RECENT_READ_DATE)!!, mostRecentReadDateQuery) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt new file mode 100644 index 000000000..73a6fa533 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt @@ -0,0 +1,438 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.infrastructure.jooq.BookSearchHelper +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.rlbAlias +import org.gotson.komga.infrastructure.jooq.toOrderBy +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort.unsorted +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.net.URL +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class BookDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + BookRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val sd = Tables.SERIES_METADATA + private val r = Tables.READ_PROGRESS + + private val sorts = + mapOf( + "createdDate" to b.CREATED_DATE, + "seriesId" to b.SERIES_ID, + "number" to b.NUMBER, + ) + + override fun findByIdOrNull(bookId: String): Book? = + dslRO + .selectFrom(b) + .where(b.ID.eq(bookId)) + .fetchOneInto(b) + ?.toDomain() + + override fun findNotDeletedByLibraryIdAndUrlOrNull( + libraryId: String, + url: URL, + ): Book? = + dslRO + .selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId).and(b.URL.eq(url.toString()))) + .and(b.DELETED_DATE.isNull) + .orderBy(b.LAST_MODIFIED_DATE.desc()) + .fetchInto(b) + .firstOrNull() + ?.toDomain() + + override fun findAllBySeriesId(seriesId: String): Collection = + dslRO + .selectFrom(b) + .where(b.SERIES_ID.eq(seriesId)) + .fetchInto(b) + .map { it.toDomain() } + + @Transactional + override fun findAllBySeriesIds(seriesIds: Collection): Collection { + dslRO.withTempTable(batchSize, seriesIds).use { tempTable -> + return dslRO + .selectFrom(b) + .where(b.SERIES_ID.`in`(tempTable.selectTempStrings())) + .fetchInto(b) + .map { it.toDomain() } + } + } + + @Transactional + override fun findAllNotDeletedByLibraryIdAndUrlNotIn( + libraryId: String, + urls: Collection, + ): Collection { + dslRO.withTempTable(batchSize, urls.map { it.toString() }).use { tempTable -> + + return dslRO + .selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(b.DELETED_DATE.isNull) + .and(b.URL.notIn(tempTable.selectTempStrings())) + .fetchInto(b) + .map { it.toDomain() } + } + } + + override fun findAllDeletedByFileSize(fileSize: Long): Collection = + dslRO + .selectFrom(b) + .where(b.DELETED_DATE.isNotNull.and(b.FILE_SIZE.eq(fileSize))) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAll(): Collection = + dslRO + .selectFrom(b) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAll( + searchCondition: SearchCondition.Book?, + searchContext: SearchContext, + pageable: Pageable, + ): Page { + val bookCondition = BookSearchHelper(searchContext).toCondition(searchCondition) + + val count = + dslRO + .selectCount() + .from(b) + .apply { + bookCondition.second.forEach { join -> + when (join) { + RequiredJoin.BookMetadata -> innerJoin(d).on(b.ID.eq(d.BOOK_ID)) + RequiredJoin.SeriesMetadata -> innerJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + RequiredJoin.Media -> innerJoin(m).on(b.ID.eq(m.BOOK_ID)) + is RequiredJoin.ReadProgress -> leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(join.userId)) + is RequiredJoin.ReadList -> { + val rlbAlias = rlbAlias(join.readListId) + leftJoin(rlbAlias).on(rlbAlias.BOOK_ID.eq(b.ID).and(rlbAlias.READLIST_ID.eq(join.readListId))) + } + // shouldn't be required for books + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.Collection -> Unit + } + } + }.where(bookCondition.first) + .fetchOne(0, Long::class.java) ?: 0 + + val orderBy = pageable.sort.toOrderBy(sorts) + + val items = + dslRO + .select(*b.fields()) + .from(b) + .apply { + bookCondition.second.forEach { join -> + when (join) { + RequiredJoin.BookMetadata -> innerJoin(d).on(b.ID.eq(d.BOOK_ID)) + RequiredJoin.SeriesMetadata -> innerJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + RequiredJoin.Media -> innerJoin(m).on(b.ID.eq(m.BOOK_ID)) + is RequiredJoin.ReadProgress -> leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(join.userId)) + is RequiredJoin.ReadList -> { + val rlbAlias = rlbAlias(join.readListId) + leftJoin(rlbAlias).on(rlbAlias.BOOK_ID.eq(b.ID).and(rlbAlias.READLIST_ID.eq(join.readListId))) + } + // shouldn't be required for books + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.Collection -> Unit + } + } + }.where(bookCondition.first) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(b) + .map { it.toDomain() } + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else unsorted() + + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count.toInt(), 20), pageSort), + count, + ) + } + + override fun getLibraryIdOrNull(bookId: String): String? = + dslRO + .select(b.LIBRARY_ID) + .from(b) + .where(b.ID.eq(bookId)) + .fetchOne(b.LIBRARY_ID) + + override fun getSeriesIdOrNull(bookId: String): String? = + dslRO + .select(b.SERIES_ID) + .from(b) + .where(b.ID.eq(bookId)) + .fetchOne(b.SERIES_ID) + + override fun findFirstIdInSeriesOrNull(seriesId: String): String? = + dslRO + .select(b.ID) + .from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .where(b.SERIES_ID.eq(seriesId)) + .orderBy(d.NUMBER_SORT) + .limit(1) + .fetchOne(b.ID) + + override fun findLastIdInSeriesOrNull(seriesId: String): String? = + dslRO + .select(b.ID) + .from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .where(b.SERIES_ID.eq(seriesId)) + .orderBy(d.NUMBER_SORT.desc()) + .limit(1) + .fetchOne(b.ID) + + override fun findFirstUnreadIdInSeriesOrNull( + seriesId: String, + userId: String, + ): String? = + dslRO + .select(b.ID) + .from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(r.USER_ID.eq(userId).or(r.USER_ID.isNull)) + .where(b.SERIES_ID.eq(seriesId)) + .and(r.COMPLETED.isNull.or(r.COMPLETED.isFalse)) + .orderBy(d.NUMBER_SORT) + .limit(1) + .fetchOne(b.ID) + + override fun findAllIdsBySeriesId(seriesId: String): Collection = + dslRO + .select(b.ID) + .from(b) + .where(b.SERIES_ID.eq(seriesId)) + .fetch(b.ID) + + override fun findAllIdsByLibraryId(libraryId: String): Collection = + dslRO + .select(b.ID) + .from(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .fetch(b.ID) + + override fun existsById(bookId: String): Boolean = dslRO.fetchExists(b, b.ID.eq(bookId)) + + override fun findAllByLibraryIdAndMediaTypes( + libraryId: String, + mediaTypes: Collection, + ): Collection = + dslRO + .select(*b.fields()) + .from(b) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(m.MEDIA_TYPE.`in`(mediaTypes)) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllByLibraryIdAndMismatchedExtension( + libraryId: String, + mediaType: String, + extension: String, + ): Collection = + dslRO + .select(*b.fields()) + .from(b) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(m.MEDIA_TYPE.eq(mediaType)) + .and(b.URL.notLike("%.$extension")) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllByLibraryIdAndWithEmptyHash(libraryId: String): Collection = + dslRO + .selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(b.FILE_HASH.eq("")) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllByLibraryIdAndWithEmptyHashKoreader(libraryId: String): Collection = + dslRO + .selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(b.FILE_HASH_KOREADER.eq("")) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllByHashKoreader(hashKoreader: String): Collection = + dslRO + .selectFrom(b) + .where(b.FILE_HASH_KOREADER.eq(hashKoreader)) + .fetchInto(b) + .map { it.toDomain() } + + @Transactional + override fun insert(book: Book) { + insert(listOf(book)) + } + + @Transactional + override fun insert(books: Collection) { + if (books.isNotEmpty()) { + books.chunked(batchSize).forEach { chunk -> + dslRW + .batch( + dslRW + .insertInto( + b, + b.ID, + b.NAME, + b.URL, + b.NUMBER, + b.FILE_LAST_MODIFIED, + b.FILE_SIZE, + b.FILE_HASH, + b.FILE_HASH_KOREADER, + b.LIBRARY_ID, + b.SERIES_ID, + b.DELETED_DATE, + b.ONESHOT, + ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null), + ).also { step -> + chunk.forEach { + step.bind( + it.id, + it.name, + it.url, + it.number, + it.fileLastModified, + it.fileSize, + it.fileHash, + it.fileHashKoreader, + it.libraryId, + it.seriesId, + it.deletedDate, + it.oneshot, + ) + } + }.execute() + } + } + } + + @Transactional + override fun update(book: Book) { + updateBook(book) + } + + @Transactional + override fun update(books: Collection) { + books.map { updateBook(it) } + } + + private fun updateBook(book: Book) { + dslRW + .update(b) + .set(b.NAME, book.name) + .set(b.URL, book.url.toString()) + .set(b.NUMBER, book.number) + .set(b.FILE_LAST_MODIFIED, book.fileLastModified) + .set(b.FILE_SIZE, book.fileSize) + .set(b.FILE_HASH, book.fileHash) + .set(b.FILE_HASH_KOREADER, book.fileHashKoreader) + .set(b.LIBRARY_ID, book.libraryId) + .set(b.SERIES_ID, book.seriesId) + .set(b.DELETED_DATE, book.deletedDate) + .set(b.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .set(b.ONESHOT, book.oneshot) + .where(b.ID.eq(book.id)) + .execute() + } + + override fun delete(bookId: String) { + dslRW.deleteFrom(b).where(b.ID.eq(bookId)).execute() + } + + @Transactional + override fun delete(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { tempTable -> + dslRW.deleteFrom(b).where(b.ID.`in`(tempTable.selectTempStrings())).execute() + } + } + + override fun deleteAll() { + dslRW.deleteFrom(b).execute() + } + + override fun count(): Long = dslRO.fetchCount(b).toLong() + + override fun countGroupedByLibraryId(): Map = + dslRO + .select(b.LIBRARY_ID, DSL.count(b.ID)) + .from(b) + .groupBy(b.LIBRARY_ID) + .fetchMap(b.LIBRARY_ID, DSL.count(b.ID)) + + override fun getFilesizeGroupedByLibraryId(): Map = + dslRO + .select(b.LIBRARY_ID, DSL.sum(b.FILE_SIZE)) + .from(b) + .groupBy(b.LIBRARY_ID) + .fetchMap(b.LIBRARY_ID, DSL.sum(b.FILE_SIZE)) + + private fun BookRecord.toDomain() = + Book( + name = name, + url = URL(url), + fileLastModified = fileLastModified, + fileSize = fileSize, + fileHash = fileHash, + fileHashKoreader = fileHashKoreader, + id = id, + libraryId = libraryId, + seriesId = seriesId, + deletedDate = deletedDate, + oneshot = oneshot, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + number = number, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt new file mode 100644 index 000000000..ad89d3a5d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt @@ -0,0 +1,554 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.infrastructure.jooq.BookSearchHelper +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.noCase +import org.gotson.komga.infrastructure.jooq.rlbAlias +import org.gotson.komga.infrastructure.jooq.sortByValues +import org.gotson.komga.infrastructure.jooq.toCondition +import org.gotson.komga.infrastructure.jooq.toOrderBy +import org.gotson.komga.infrastructure.jooq.toSortField +import org.gotson.komga.infrastructure.jooq.unicode3 +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.gotson.komga.infrastructure.search.LuceneHelper +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.BookMetadataDto +import org.gotson.komga.interfaces.api.rest.dto.MediaDto +import org.gotson.komga.interfaces.api.rest.dto.ReadProgressDto +import org.gotson.komga.interfaces.api.rest.dto.WebLinkDto +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookMetadataRecord +import org.gotson.komga.jooq.main.tables.records.BookRecord +import org.gotson.komga.jooq.main.tables.records.MediaRecord +import org.gotson.komga.jooq.main.tables.records.ReadProgressRecord +import org.gotson.komga.language.toUTC +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.jooq.SelectOnConditionStep +import org.jooq.impl.DSL +import org.jooq.impl.DSL.falseCondition +import org.jooq.impl.DSL.noCondition +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import java.net.URL + +@Component +class BookDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val luceneHelper: LuceneHelper, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, + private val bookCommonDao: BookCommonDao, +) : SplitDslDaoBase(dslRW, dslRO), + BookDtoRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val r = Tables.READ_PROGRESS + private val a = Tables.BOOK_METADATA_AUTHOR + private val sd = Tables.SERIES_METADATA + private val rlb = Tables.READLIST_BOOK + private val bt = Tables.BOOK_METADATA_TAG + private val bl = Tables.BOOK_METADATA_LINK + + private val onDeckFields = b.fields() + m.fields() + d.fields() + r.fields() + sd.TITLE + + private val sorts = + mapOf( + "name" to b.NAME.unicode3(), + "series" to sd.TITLE_SORT.unicode3(), + "created" to b.CREATED_DATE, + "createdDate" to b.CREATED_DATE, + "lastModified" to b.LAST_MODIFIED_DATE, + "lastModifiedDate" to b.LAST_MODIFIED_DATE, + "fileSize" to b.FILE_SIZE, + "size" to b.FILE_SIZE, + "fileHash" to b.FILE_HASH, + "url" to b.URL.noCase(), + "media.status" to m.STATUS.noCase(), + "media.comment" to m.COMMENT.noCase(), + "media.mediaType" to m.MEDIA_TYPE.noCase(), + "media.pagesCount" to m.PAGE_COUNT, + "metadata.title" to d.TITLE.unicode3(), + "metadata.numberSort" to d.NUMBER_SORT, + "metadata.releaseDate" to d.RELEASE_DATE, + "readProgress.lastModified" to r.LAST_MODIFIED_DATE, + "readProgress.readDate" to r.READ_DATE, + "readList.number" to rlb.NUMBER, + ) + + override fun findAll(pageable: Pageable): Page = findAll(BookSearch(), SearchContext.ofAnonymousUser(), pageable) + + override fun findAll( + context: SearchContext, + pageable: Pageable, + ): Page = findAll(BookSearch(), context, pageable) + + override fun findAll( + search: BookSearch, + context: SearchContext, + pageable: Pageable, + ): Page { + requireNotNull(context.userId) { "Missing userId in search context" } + + val (conditions, joins) = BookSearchHelper(context).toCondition(search.condition) + return findAll(conditions, context.userId, pageable, search.fullTextSearch, joins) + } + + private fun findAll( + conditions: Condition, + userId: String, + pageable: Pageable, + searchTerm: String?, + joins: Set, + ): Page { + val bookIds = luceneHelper.searchEntitiesIds(searchTerm, LuceneEntity.Book) + + val orderBy = + pageable.sort.mapNotNull { + if (it.property == "relevance" && !bookIds.isNullOrEmpty()) { + b.ID.sortByValues(bookIds, it.isAscending) + } else { + if (it.property == "readList.number") { + val readListId = joins.filterIsInstance().firstOrNull()?.readListId ?: return@mapNotNull null + val f = rlbAlias(readListId).NUMBER + if (it.isAscending) f.asc() else f.desc() + } else { + it.toSortField(sorts) + } + } + } + + // don't use the DSLContext.withTempTable form to control optional creation + TempTable(dslRO).use { tempTable -> + + val searchCondition = + when { + bookIds == null -> noCondition() + bookIds.isEmpty() -> falseCondition() + // use temp table in case there are many search results + else -> { + tempTable.insertTempStrings(batchSize, bookIds) + b.ID.`in`(tempTable.selectTempStrings()) + } + } + + val count = + dslRO.fetchCount( + dslRO + .select(b.ID) + .from(b) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .leftJoin(sd) + .on(b.SERIES_ID.eq(sd.SERIES_ID)) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.ReadList -> { + val rlbAlias = rlbAlias(join.readListId) + leftJoin(rlbAlias).on(rlbAlias.BOOK_ID.eq(b.ID).and(rlbAlias.READLIST_ID.eq(join.readListId))) + } + // always joined + RequiredJoin.BookMetadata -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadProgress -> Unit + // Series joins - not needed + RequiredJoin.BookMetadataAggregation -> Unit + RequiredJoin.SeriesMetadata -> Unit + is RequiredJoin.Collection -> Unit + } + } + }.where(conditions) + .and(searchCondition) + .groupBy(b.ID), + ) + + val dtos = + dslRO + .selectBase(userId, joins) + .where(conditions) + .and(searchCondition) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO) + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + dtos, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + } + + override fun findByIdOrNull( + bookId: String, + userId: String, + ): BookDto? = + dslRO + .selectBase(userId) + .where(b.ID.eq(bookId)) + .fetchAndMap(dslRO) + .firstOrNull() + + override fun findPreviousInSeriesOrNull( + bookId: String, + userId: String, + ): BookDto? = findSiblingSeries(bookId, userId, next = false) + + override fun findNextInSeriesOrNull( + bookId: String, + userId: String, + ): BookDto? = findSiblingSeries(bookId, userId, next = true) + + override fun findPreviousInReadListOrNull( + readList: ReadList, + bookId: String, + userId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): BookDto? = findSiblingReadList(readList, bookId, userId, filterOnLibraryIds, restrictions, next = false) + + override fun findNextInReadListOrNull( + readList: ReadList, + bookId: String, + userId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): BookDto? = findSiblingReadList(readList, bookId, userId, filterOnLibraryIds, restrictions, next = true) + + override fun findAllOnDeck( + userId: String, + filterOnLibraryIds: Collection?, + pageable: Pageable, + restrictions: ContentRestrictions, + ): Page { + val (query, sortField, _) = bookCommonDao.getBooksOnDeckQuery(userId, restrictions, filterOnLibraryIds, onDeckFields) + + val count = dslRO.fetchCount(query) + val dtos = + query + .orderBy(sortField.desc()) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO) + + return PageImpl( + dtos, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted()) + else + PageRequest.of(0, maxOf(count, 20), Sort.unsorted()), + count.toLong(), + ) + } + + override fun findAllDuplicates( + userId: String, + pageable: Pageable, + ): Page { + val hashes = + dslRO + .select(b.FILE_HASH, DSL.count(b.ID)) + .from(b) + .where(b.FILE_HASH.ne("")) + .groupBy(b.FILE_HASH, b.FILE_SIZE) + .having(DSL.count(b.ID).gt(1)) + .fetch() + .associate { it.value1() to it.value2() } + + val count = hashes.values.sum() + + val orderBy = pageable.sort.toOrderBy(sorts) + val dtos = + dslRO + .selectBase(userId) + .where(b.FILE_HASH.`in`(hashes.keys)) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO) + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + dtos, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + private fun readProgressCondition(userId: String): Condition = r.USER_ID.eq(userId) + + private fun findSiblingSeries( + bookId: String, + userId: String, + next: Boolean, + ): BookDto? { + val record = + dslRO + .select(b.SERIES_ID, d.NUMBER_SORT) + .from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .where(b.ID.eq(bookId)) + .fetchOne()!! + val seriesId = record.get(0, String::class.java) + val numberSort = record.get(1, Float::class.java) + + return dslRO + .selectBase(userId) + .where(b.SERIES_ID.eq(seriesId)) + .orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() }) + .seek(numberSort) + .limit(1) + .fetchAndMap(dslRO) + .firstOrNull() + } + + private fun findSiblingReadList( + readList: ReadList, + bookId: String, + userId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + next: Boolean, + ): BookDto? { + if (readList.ordered) { + val numberSort = + dslRO + .select(rlb.NUMBER) + .from(b) + .leftJoin(rlb) + .on(b.ID.eq(rlb.BOOK_ID)) + .where(b.ID.eq(bookId)) + .and(rlb.READLIST_ID.eq(readList.id)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .fetchOne(rlb.NUMBER) + + return dslRO + .selectBase(userId, setOf(RequiredJoin.ReadList(readList.id))) + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(rlbAlias(readList.id).NUMBER.let { if (next) it.asc() else it.desc() }) + .seek(numberSort) + .limit(1) + .fetchAndMap(dslRO) + .firstOrNull() + } else { + // it is too complex to perform a seek by release date as it could be null and could also have multiple occurrences of the same value + // instead we pull the whole list of ids, and perform the seek on the list + val bookIds = + dslRO + .select(b.ID) + .from(b) + .leftJoin(rlb) + .on(b.ID.eq(rlb.BOOK_ID)) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .apply { if (restrictions.isRestricted) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + .where(rlb.READLIST_ID.eq(readList.id)) + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(d.RELEASE_DATE) + .fetch(b.ID) + + val bookIndex = bookIds.indexOfFirst { it == bookId } + if (bookIndex == -1) return null + val siblingId = bookIds.getOrNull(bookIndex + if (next) 1 else -1) ?: return null + + return dslRO + .selectBase(userId) + .where(b.ID.eq(siblingId)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .limit(1) + .fetchAndMap(dslRO) + .firstOrNull() + } + } + + private fun DSLContext.selectBase( + userId: String, + joins: Set = emptySet(), + ): SelectOnConditionStep { + val selectFields = + listOf( + *b.fields(), + *m.fields(), + *d.fields(), + *r.fields(), + sd.TITLE, + ) + + return this + .select(selectFields) + .from(b) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .leftJoin(sd) + .on(b.SERIES_ID.eq(sd.SERIES_ID)) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.ReadList -> { + val rlbAlias = rlbAlias(join.readListId) + leftJoin(rlbAlias).on(rlbAlias.BOOK_ID.eq(b.ID).and(rlbAlias.READLIST_ID.eq(join.readListId))) + } + // always joined + RequiredJoin.BookMetadata -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadProgress -> Unit + // Series joins - not needed + RequiredJoin.BookMetadataAggregation -> Unit + RequiredJoin.SeriesMetadata -> Unit + is RequiredJoin.Collection -> Unit + } + } + } + } + + private fun ResultQuery.fetchAndMap(dsl: DSLContext): MutableList { + val records = fetch() + val bookIds = records.getValues(b.ID) + + lateinit var authors: Map> + lateinit var tags: Map> + lateinit var links: Map> + dsl.withTempTable(batchSize, bookIds).use { tempTable -> + authors = + dsl + .selectFrom(a) + .where(a.BOOK_ID.`in`(tempTable.selectTempStrings())) + .filter { it.name != null } + .groupBy({ it.bookId }, { AuthorDto(it.name, it.role) }) + + tags = + dsl + .selectFrom(bt) + .where(bt.BOOK_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.bookId }, { it.tag }) + + links = + dsl + .selectFrom(bl) + .where(bl.BOOK_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.bookId }, { WebLinkDto(it.label, it.url) }) + } + + return records + .map { rec -> + val br = rec.into(b) + val mr = rec.into(m) + val dr = rec.into(d) + val rr = rec.into(r) + val seriesTitle = rec.into(sd.TITLE).component1() + + br.toDto(mr.toDto(), dr.toDto(authors[br.id].orEmpty(), tags[br.id].orEmpty().toSet(), links[br.id].orEmpty()), if (rr.userId != null) rr.toDto() else null, seriesTitle) + } + } + + private fun BookRecord.toDto( + media: MediaDto, + metadata: BookMetadataDto, + readProgress: ReadProgressDto?, + seriesTitle: String, + ) = BookDto( + id = id, + seriesId = seriesId, + seriesTitle = seriesTitle, + libraryId = libraryId, + name = name, + url = URL(url).toFilePath(), + number = number, + created = createdDate, + lastModified = lastModifiedDate, + fileLastModified = fileLastModified.toUTC(), + sizeBytes = fileSize, + media = media, + metadata = metadata, + readProgress = readProgress, + deleted = deletedDate != null, + fileHash = fileHash, + oneshot = oneshot, + ) + + private fun MediaRecord.toDto() = + MediaDto( + status = status, + mediaType = mediaType ?: "", + pagesCount = pageCount.toInt(), + comment = comment ?: "", + epubDivinaCompatible = epubDivinaCompatible, + epubIsKepub = epubIsKepub, + ) + + private fun BookMetadataRecord.toDto( + authors: List, + tags: Set, + links: List, + ) = BookMetadataDto( + title = title, + titleLock = titleLock, + summary = summary, + summaryLock = summaryLock, + number = number, + numberLock = numberLock, + numberSort = numberSort, + numberSortLock = numberSortLock, + releaseDate = releaseDate, + releaseDateLock = releaseDateLock, + authors = authors, + authorsLock = authorsLock, + tags = tags, + tagsLock = tagsLock, + isbn = isbn, + isbnLock = isbnLock, + links = links, + linksLock = linksLock, + created = createdDate, + lastModified = lastModifiedDate, + ) + + private fun ReadProgressRecord.toDto() = + ReadProgressDto( + page = page, + completed = completed, + readDate = readDate, + created = createdDate, + lastModified = lastModifiedDate, + deviceId = deviceId, + deviceName = deviceName, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt new file mode 100644 index 000000000..d0167e00e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt @@ -0,0 +1,166 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadataAggregation +import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookMetadataAggregationAuthorRecord +import org.gotson.komga.jooq.main.tables.records.BookMetadataAggregationRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class BookMetadataAggregationDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + BookMetadataAggregationRepository { + private val d = Tables.BOOK_METADATA_AGGREGATION + private val a = Tables.BOOK_METADATA_AGGREGATION_AUTHOR + private val t = Tables.BOOK_METADATA_AGGREGATION_TAG + + override fun findById(seriesId: String): BookMetadataAggregation = dslRO.findOne(listOf(seriesId)).first() + + override fun findByIdOrNull(seriesId: String): BookMetadataAggregation? = dslRO.findOne(listOf(seriesId)).firstOrNull() + + private fun DSLContext.findOne(seriesIds: Collection) = + this + .select(*d.fields(), *a.fields()) + .from(d) + .leftJoin(a) + .on(d.SERIES_ID.eq(a.SERIES_ID)) + .where(d.SERIES_ID.`in`(seriesIds)) + .fetchGroups( + { it.into(d) }, + { it.into(a) }, + ).map { (dr, ar) -> + dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.seriesId)) + } + + private fun DSLContext.findTags(seriesId: String) = + this + .select(t.TAG) + .from(t) + .where(t.SERIES_ID.eq(seriesId)) + .fetchSet(t.TAG) + + @Transactional + override fun insert(metadata: BookMetadataAggregation) { + dslRW + .insertInto(d) + .set(d.SERIES_ID, metadata.seriesId) + .set(d.RELEASE_DATE, metadata.releaseDate) + .set(d.SUMMARY, metadata.summary) + .set(d.SUMMARY_NUMBER, metadata.summaryNumber) + .execute() + + dslRW.insertAuthors(metadata) + dslRW.insertTags(metadata) + } + + @Transactional + override fun update(metadata: BookMetadataAggregation) { + dslRW + .update(d) + .set(d.SUMMARY, metadata.summary) + .set(d.SUMMARY_NUMBER, metadata.summaryNumber) + .set(d.RELEASE_DATE, metadata.releaseDate) + .set(d.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(d.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(a) + .where(a.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(t) + .where(t.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW.insertAuthors(metadata) + dslRW.insertTags(metadata) + } + + private fun DSLContext.insertAuthors(metadata: BookMetadataAggregation) { + if (metadata.authors.isNotEmpty()) { + metadata.authors.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(a, a.SERIES_ID, a.NAME, a.ROLE) + .values(null as String?, null, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it.name, it.role) + } + }.execute() + } + } + } + + private fun DSLContext.insertTags(metadata: BookMetadataAggregation) { + if (metadata.tags.isNotEmpty()) { + metadata.tags.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(t, t.SERIES_ID, t.TAG) + .values(null as String?, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it) + } + }.execute() + } + } + } + + @Transactional + override fun delete(seriesId: String) { + dslRW.deleteFrom(a).where(a.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(t).where(t.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(d).where(d.SERIES_ID.eq(seriesId)).execute() + } + + @Transactional + override fun delete(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW.deleteFrom(a).where(a.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(t).where(t.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(d).where(d.SERIES_ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun count(): Long = dslRO.fetchCount(d).toLong() + + private fun BookMetadataAggregationRecord.toDomain( + authors: List, + tags: Set, + ) = BookMetadataAggregation( + authors = authors, + tags = tags, + releaseDate = releaseDate, + summary = summary, + summaryNumber = summaryNumber, + seriesId = seriesId, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) + + private fun BookMetadataAggregationAuthorRecord.toDomain() = + Author( + name = name, + role = role, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt new file mode 100644 index 000000000..bbbcf299b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt @@ -0,0 +1,294 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookMetadataAuthorRecord +import org.gotson.komga.jooq.main.tables.records.BookMetadataRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URI +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class BookMetadataDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + BookMetadataRepository { + private val d = Tables.BOOK_METADATA + private val a = Tables.BOOK_METADATA_AUTHOR + private val bt = Tables.BOOK_METADATA_TAG + private val bl = Tables.BOOK_METADATA_LINK + + private val groupFields = arrayOf(*d.fields(), *a.fields()) + + override fun findById(bookId: String): BookMetadata = dslRO.find(listOf(bookId)).first() + + override fun findByIdOrNull(bookId: String): BookMetadata? = dslRO.find(listOf(bookId)).firstOrNull() + + override fun findAllByIds(bookIds: Collection): Collection = dslRO.find(bookIds) + + private fun DSLContext.find( + bookIds: Collection, + ) = this + .select(*groupFields) + .from(d) + .leftJoin(a) + .on(d.BOOK_ID.eq(a.BOOK_ID)) + .where(d.BOOK_ID.`in`(bookIds)) + .groupBy(*groupFields) + .fetchGroups( + { it.into(d) }, + { it.into(a) }, + ).map { (dr, ar) -> + dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.bookId), this.findLinks(dr.bookId)) + } + + private fun DSLContext.findTags(bookId: String) = + this + .select(bt.TAG) + .from(bt) + .where(bt.BOOK_ID.eq(bookId)) + .fetchSet(bt.TAG) + + private fun DSLContext.findLinks(bookId: String) = + this + .select(bl.LABEL, bl.URL) + .from(bl) + .where(bl.BOOK_ID.eq(bookId)) + .fetchInto(bl) + .map { WebLink(it.label, URI(it.url)) } + + @Transactional + override fun insert(metadata: BookMetadata) { + insert(listOf(metadata)) + } + + @Transactional + override fun insert(metadatas: Collection) { + if (metadatas.isNotEmpty()) { + metadatas.chunked(batchSize).forEach { chunk -> + dslRW + .batch( + dslRW + .insertInto( + d, + d.BOOK_ID, + d.TITLE, + d.TITLE_LOCK, + d.SUMMARY, + d.SUMMARY_LOCK, + d.NUMBER, + d.NUMBER_LOCK, + d.NUMBER_SORT, + d.NUMBER_SORT_LOCK, + d.RELEASE_DATE, + d.RELEASE_DATE_LOCK, + d.AUTHORS_LOCK, + d.TAGS_LOCK, + d.ISBN, + d.ISBN_LOCK, + d.LINKS_LOCK, + ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null), + ).also { step -> + chunk.forEach { + step.bind( + it.bookId, + it.title, + it.titleLock, + it.summary, + it.summaryLock, + it.number, + it.numberLock, + it.numberSort, + it.numberSortLock, + it.releaseDate, + it.releaseDateLock, + it.authorsLock, + it.tagsLock, + it.isbn, + it.isbnLock, + it.linksLock, + ) + } + }.execute() + } + + dslRW.insertAuthors(metadatas) + dslRW.insertTags(metadatas) + dslRW.insertLinks(metadatas) + } + } + + @Transactional + override fun update(metadata: BookMetadata) { + updateMetadata(metadata) + } + + @Transactional + override fun update(metadatas: Collection) { + metadatas.forEach { updateMetadata(it) } + } + + private fun updateMetadata(metadata: BookMetadata) { + dslRW + .update(d) + .set(d.TITLE, metadata.title) + .set(d.TITLE_LOCK, metadata.titleLock) + .set(d.SUMMARY, metadata.summary) + .set(d.SUMMARY_LOCK, metadata.summaryLock) + .set(d.NUMBER, metadata.number) + .set(d.NUMBER_LOCK, metadata.numberLock) + .set(d.NUMBER_SORT, metadata.numberSort) + .set(d.NUMBER_SORT_LOCK, metadata.numberSortLock) + .set(d.RELEASE_DATE, metadata.releaseDate) + .set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock) + .set(d.AUTHORS_LOCK, metadata.authorsLock) + .set(d.TAGS_LOCK, metadata.tagsLock) + .set(d.ISBN, metadata.isbn) + .set(d.ISBN_LOCK, metadata.isbnLock) + .set(d.LINKS_LOCK, metadata.linksLock) + .set(d.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(d.BOOK_ID.eq(metadata.bookId)) + .execute() + + dslRW + .deleteFrom(a) + .where(a.BOOK_ID.eq(metadata.bookId)) + .execute() + dslRW + .deleteFrom(bt) + .where(bt.BOOK_ID.eq(metadata.bookId)) + .execute() + dslRW + .deleteFrom(bl) + .where(bl.BOOK_ID.eq(metadata.bookId)) + .execute() + + dslRW.insertAuthors(listOf(metadata)) + dslRW.insertTags(listOf(metadata)) + dslRW.insertLinks(listOf(metadata)) + } + + private fun DSLContext.insertAuthors(metadatas: Collection) { + if (metadatas.any { it.authors.isNotEmpty() }) { + metadatas.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(a, a.BOOK_ID, a.NAME, a.ROLE) + .values(null as String?, null, null), + ).also { step -> + chunk.forEach { metadata -> + metadata.authors.forEach { + step.bind(metadata.bookId, it.name, it.role) + } + } + }.execute() + } + } + } + + private fun DSLContext.insertTags(metadatas: Collection) { + if (metadatas.any { it.tags.isNotEmpty() }) { + metadatas.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(bt, bt.BOOK_ID, bt.TAG) + .values(null as String?, null), + ).also { step -> + chunk.forEach { metadata -> + metadata.tags.forEach { + step.bind(metadata.bookId, it) + } + } + }.execute() + } + } + } + + private fun DSLContext.insertLinks(metadatas: Collection) { + if (metadatas.any { it.links.isNotEmpty() }) { + metadatas.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(bl, bl.BOOK_ID, bl.LABEL, bl.URL) + .values(null as String?, null, null), + ).also { step -> + chunk.forEach { metadata -> + metadata.links.forEach { + step.bind(metadata.bookId, it.label, it.url.toString()) + } + } + }.execute() + } + } + } + + @Transactional + override fun delete(bookId: String) { + dslRW.deleteFrom(a).where(a.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(bt).where(bt.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(bl).where(bl.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(d).where(d.BOOK_ID.eq(bookId)).execute() + } + + @Transactional + override fun delete(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { + dslRW.deleteFrom(a).where(a.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(bt).where(bt.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(bl).where(bl.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(d).where(d.BOOK_ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun count(): Long = dslRO.fetchCount(d).toLong() + + private fun BookMetadataRecord.toDomain( + authors: List, + tags: Set, + links: List, + ) = BookMetadata( + title = title, + summary = summary, + number = number, + numberSort = numberSort, + releaseDate = releaseDate, + authors = authors, + tags = tags, + isbn = isbn, + links = links, + bookId = bookId, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + titleLock = titleLock, + summaryLock = summaryLock, + numberLock = numberLock, + numberSortLock = numberSortLock, + releaseDateLock = releaseDateLock, + authorsLock = authorsLock, + tagsLock = tagsLock, + isbnLock = isbnLock, + linksLock = linksLock, + ) + + private fun BookMetadataAuthorRecord.toDomain() = + Author( + name = name, + role = role, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDao.kt new file mode 100644 index 000000000..92035a5af --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDao.kt @@ -0,0 +1,87 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.interfaces.api.rest.dto.ClientSettingDto +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component + +@Component +class ClientSettingsDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO) { + private val g = Tables.CLIENT_SETTINGS_GLOBAL + private val u = Tables.CLIENT_SETTINGS_USER + + fun findAllGlobal(onlyUnauthorized: Boolean = false): Map = + dslRO + .selectFrom(g) + .apply { if (onlyUnauthorized) where(g.ALLOW_UNAUTHORIZED.isTrue) } + .fetch() + .associate { it.key to ClientSettingDto(it.value, it.allowUnauthorized) } + + fun findAllUser(userId: String): Map = + dslRO + .selectFrom(u) + .where(u.USER_ID.eq(userId)) + .fetch() + .associate { it.key to ClientSettingDto(it.value, null) } + + fun saveGlobal( + key: String, + value: String, + allowUnauthorized: Boolean, + ) { + dslRW + .insertInto(g, g.KEY, g.VALUE, g.ALLOW_UNAUTHORIZED) + .values(key, value, allowUnauthorized) + .onDuplicateKeyUpdate() + .set(g.VALUE, value) + .execute() + } + + fun saveForUser( + userId: String, + key: String, + value: String, + ) { + dslRW + .insertInto(u, u.USER_ID, u.KEY, u.VALUE) + .values(userId, key, value) + .onDuplicateKeyUpdate() + .set(u.VALUE, value) + .execute() + } + + fun deleteAll() { + dslRW.deleteFrom(g).execute() + dslRW.deleteFrom(u).execute() + } + + fun deleteGlobalByKeys(keys: Collection) { + dslRW + .deleteFrom(g) + .where(g.KEY.`in`(keys)) + .execute() + } + + fun deleteByUserIdAndKeys( + userId: String, + keys: Collection, + ) { + dslRW + .deleteFrom(u) + .where(u.KEY.`in`(keys)) + .and(u.USER_ID.eq(userId)) + .execute() + } + + fun deleteByUserId(userId: String) { + dslRW + .deleteFrom(u) + .where(u.USER_ID.eq(userId)) + .execute() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDao.kt new file mode 100644 index 000000000..9e4f9d829 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDao.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.HistoricalEvent +import org.gotson.komga.domain.persistence.HistoricalEventRepository +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class HistoricalEventDao( + private val dslRW: DSLContext, +) : HistoricalEventRepository { + private val e = Tables.HISTORICAL_EVENT + private val ep = Tables.HISTORICAL_EVENT_PROPERTIES + + @Transactional + override fun insert(event: HistoricalEvent) { + dslRW + .insertInto(e) + .set(e.ID, event.id) + .set(e.TYPE, event.type) + .set(e.BOOK_ID, event.bookId) + .set(e.SERIES_ID, event.seriesId) + .set(e.TIMESTAMP, event.timestamp) + .execute() + + if (event.properties.isNotEmpty()) { + dslRW + .batch( + dslRW + .insertInto(ep, ep.ID, ep.KEY, ep.VALUE) + .values(null as String?, null, null), + ).also { step -> + event.properties.forEach { (key, value) -> + step.bind(event.id, key, value) + } + }.execute() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDtoDao.kt new file mode 100644 index 000000000..8e718dff9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/HistoricalEventDtoDao.kt @@ -0,0 +1,66 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.toOrderBy +import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component + +@Component +class HistoricalEventDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + HistoricalEventDtoRepository { + private val e = Tables.HISTORICAL_EVENT + private val ep = Tables.HISTORICAL_EVENT_PROPERTIES + + private val sorts = + mapOf( + "type" to e.TYPE, + "bookId" to e.BOOK_ID, + "seriesId" to e.SERIES_ID, + "timestamp" to e.TIMESTAMP, + ) + + override fun findAll(pageable: Pageable): Page { + val count = dslRO.fetchCount(e) + + val orderBy = pageable.sort.toOrderBy(sorts) + + val items = + dslRO + .selectFrom(e) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .map { er -> + val epr = dslRO.selectFrom(ep).where(ep.ID.eq(er.id)).fetch() + HistoricalEventDto( + id = er.id, + type = er.type, + timestamp = er.timestamp, + bookId = er.bookId, + seriesId = er.seriesId, + properties = epr.filterNot { it.key == null }.associate { it.key to it.value }, + ) + } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt new file mode 100644 index 000000000..a72184640 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt @@ -0,0 +1,115 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.fasterxml.jackson.databind.ObjectMapper +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension +import org.gotson.komga.interfaces.api.kobo.dto.ContributorDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboSeriesDto +import org.gotson.komga.interfaces.api.kobo.dto.PublisherDto +import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.time.ZoneId + +@Component +class KoboDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val mapper: ObjectMapper, +) : SplitDslDaoBase(dslRW, dslRO), + KoboDtoRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val a = Tables.BOOK_METADATA_AUTHOR + private val sd = Tables.SERIES_METADATA + private val bt = Tables.THUMBNAIL_BOOK + + override fun findBookMetadataByIds( + bookIds: Collection, + ): Collection { + val records = + dslRO + .select( + d.BOOK_ID, + d.TITLE, + d.NUMBER, + d.NUMBER_SORT, + d.ISBN, + d.SUMMARY, + d.RELEASE_DATE, + d.CREATED_DATE, + sd.SERIES_ID, + sd.TITLE, + sd.PUBLISHER, + sd.LANGUAGE, + b.FILE_SIZE, + b.ONESHOT, + m.EPUB_IS_KEPUB, + m.EXTENSION_CLASS, + m.EXTENSION_VALUE_BLOB, + bt.ID, + ).from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .leftJoin(sd) + .on(b.SERIES_ID.eq(sd.SERIES_ID)) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .leftJoin(bt) + .on(b.ID.eq(bt.BOOK_ID)) + .and(bt.SELECTED.isTrue) + .where(d.BOOK_ID.`in`(bookIds)) + .fetch() + + return records.map { rec -> + val br = rec.into(b) + val dr = rec.into(d) + val sr = rec.into(sd) + val mr = rec.into(m) + val btr = rec.into(bt) + val mediaExtension = mapper.deserializeMediaExtension(mr.extensionClass, mr.extensionValueBlob) as? MediaExtensionEpub + + val authors = + dslRO + .selectFrom(a) + .where(a.BOOK_ID.`in`(bookIds)) + .filter { it.name != null } + .groupBy({ it.bookId }, { it }) + + KoboBookMetadataDto( + contributorRoles = authors[dr.bookId].orEmpty().map { ContributorDto(it.name) }, + contributors = authors[dr.bookId].orEmpty().map { it.name }, + coverImageId = btr.id, + crossRevisionId = dr.bookId, + // if null or empty Kobo will not update it, force it to blank + description = dr.summary.ifEmpty { " " }, + entitlementId = dr.bookId, + isbn = dr.isbn.ifBlank { null }, + language = sr.language.take(2).ifBlank { "en" }, + publicationDate = dr.releaseDate?.atStartOfDay(ZoneId.of("Z")) ?: dr.createdDate.atZone(ZoneId.of("Z")), + publisher = PublisherDto(name = sr.publisher), + revisionId = dr.bookId, + series = + if (!br.oneshot) + KoboSeriesDto( + id = sr.seriesId, + name = sr.title, + number = dr.number, + numberFloat = dr.numberSort, + ) + else + null, + title = dr.title, + workId = dr.bookId, + isKepub = mr.epubIsKepub, + isPrePaginated = mediaExtension?.isFixedLayout == true, + fileSize = br.fileSize, + ) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt new file mode 100644 index 000000000..3d882da99 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt @@ -0,0 +1,321 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ApiKey +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.UserApiKeyRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class KomgaUserDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + KomgaUserRepository { + private val u = Tables.USER + private val ur = Tables.USER_ROLE + private val ul = Tables.USER_LIBRARY_SHARING + private val us = Tables.USER_SHARING + private val ar = Tables.ANNOUNCEMENTS_READ + private val uak = Tables.USER_API_KEY + + override fun count(): Long = dslRO.fetchCount(u).toLong() + + override fun findAll(): Collection = + dslRO + .selectBase() + .fetchAndMap(dslRO) + + override fun findApiKeyByUserId(userId: String): Collection = + dslRO + .selectFrom(uak) + .where(uak.USER_ID.eq(userId)) + .fetchInto(uak) + .map { + it.toDomain() + } + + override fun findByIdOrNull(id: String): KomgaUser? = + dslRO + .selectBase() + .where(u.ID.equal(id)) + .fetchAndMap(dslRO) + .firstOrNull() + + private fun DSLContext.selectBase() = + select(*u.fields()) + .select(ul.LIBRARY_ID) + .from(u) + .leftJoin(ul) + .onKey() + + private fun ResultQuery.fetchAndMap(dsl: DSLContext) = + this + .fetchGroups({ it.into(u) }, { it.into(ul) }) + .map { (userRecord, ulr) -> + val usr = + dsl + .selectFrom(us) + .where(us.USER_ID.eq(userRecord.id)) + .toList() + val roles = + dsl + .select(ur.ROLE) + .from(ur) + .where(ur.USER_ID.eq(userRecord.id)) + .fetch(ur.ROLE) + KomgaUser( + email = userRecord.email, + password = userRecord.password, + roles = UserRoles.valuesOf(roles), + sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(), + sharedAllLibraries = userRecord.sharedAllLibraries, + restrictions = + ContentRestrictions( + ageRestriction = + if (userRecord.ageRestriction != null && userRecord.ageRestrictionAllowOnly != null) + AgeRestriction(userRecord.ageRestriction, if (userRecord.ageRestrictionAllowOnly) AllowExclude.ALLOW_ONLY else AllowExclude.EXCLUDE) + else + null, + labelsAllow = usr.filter { it.allow }.map { it.label }.toSet(), + labelsExclude = usr.filterNot { it.allow }.map { it.label }.toSet(), + ), + id = userRecord.id, + createdDate = userRecord.createdDate.toCurrentTimeZone(), + lastModifiedDate = userRecord.lastModifiedDate.toCurrentTimeZone(), + ) + } + + @Transactional + override fun insert(user: KomgaUser) { + dslRW + .insertInto(u) + .set(u.ID, user.id) + .set(u.EMAIL, user.email) + .set(u.PASSWORD, user.password) + .set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries) + .set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age) + .set( + u.AGE_RESTRICTION_ALLOW_ONLY, + when (user.restrictions.ageRestriction?.restriction) { + AllowExclude.ALLOW_ONLY -> true + AllowExclude.EXCLUDE -> false + null -> null + }, + ).execute() + + dslRW.insertRoles(user) + dslRW.insertSharedLibraries(user) + dslRW.insertSharingRestrictions(user) + } + + override fun insert(apiKey: ApiKey) { + dslRW + .insertInto(uak) + .set(uak.ID, apiKey.id) + .set(uak.USER_ID, apiKey.userId) + .set(uak.API_KEY, apiKey.key) + .set(uak.COMMENT, apiKey.comment) + .execute() + } + + @Transactional + override fun update(user: KomgaUser) { + dslRW + .update(u) + .set(u.EMAIL, user.email) + .set(u.PASSWORD, user.password) + .set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries) + .set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age) + .set( + u.AGE_RESTRICTION_ALLOW_ONLY, + when (user.restrictions.ageRestriction?.restriction) { + AllowExclude.ALLOW_ONLY -> true + AllowExclude.EXCLUDE -> false + null -> null + }, + ).set(u.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(u.ID.eq(user.id)) + .execute() + + dslRW + .deleteFrom(ur) + .where(ur.USER_ID.eq(user.id)) + .execute() + + dslRW + .deleteFrom(ul) + .where(ul.USER_ID.eq(user.id)) + .execute() + + dslRW + .deleteFrom(us) + .where(us.USER_ID.eq(user.id)) + .execute() + + dslRW.insertRoles(user) + dslRW.insertSharedLibraries(user) + dslRW.insertSharingRestrictions(user) + } + + override fun saveAnnouncementIdsRead( + user: KomgaUser, + announcementIds: Set, + ) { + dslRW + .batch( + announcementIds.map { + dslRW.insertInto(ar).values(user.id, it).onDuplicateKeyIgnore() + }, + ).execute() + } + + private fun DSLContext.insertRoles(user: KomgaUser) { + user.roles.forEach { + this + .insertInto(ur) + .columns(ur.USER_ID, ur.ROLE) + .values(user.id, it.name) + .execute() + } + } + + private fun DSLContext.insertSharedLibraries(user: KomgaUser) { + user.sharedLibrariesIds.forEach { + this + .insertInto(ul) + .columns(ul.USER_ID, ul.LIBRARY_ID) + .values(user.id, it) + .execute() + } + } + + private fun DSLContext.insertSharingRestrictions(user: KomgaUser) { + user.restrictions.labelsAllow.forEach { label -> + this + .insertInto(us) + .columns(us.USER_ID, us.ALLOW, us.LABEL) + .values(user.id, true, label) + .execute() + } + + user.restrictions.labelsExclude.forEach { label -> + this + .insertInto(us) + .columns(us.USER_ID, us.ALLOW, us.LABEL) + .values(user.id, false, label) + .execute() + } + } + + @Transactional + override fun delete(userId: String) { + dslRW.deleteFrom(uak).where(uak.USER_ID.equal(userId)).execute() + dslRW.deleteFrom(ar).where(ar.USER_ID.equal(userId)).execute() + dslRW.deleteFrom(us).where(us.USER_ID.equal(userId)).execute() + dslRW.deleteFrom(ul).where(ul.USER_ID.equal(userId)).execute() + dslRW.deleteFrom(ur).where(ur.USER_ID.equal(userId)).execute() + dslRW.deleteFrom(u).where(u.ID.equal(userId)).execute() + } + + @Transactional + override fun deleteAll() { + dslRW.deleteFrom(uak).execute() + dslRW.deleteFrom(ar).execute() + dslRW.deleteFrom(us).execute() + dslRW.deleteFrom(ul).execute() + dslRW.deleteFrom(ur).execute() + dslRW.deleteFrom(u).execute() + } + + override fun deleteApiKeyByIdAndUserId( + apiKeyId: String, + userId: String, + ) { + dslRW + .deleteFrom(uak) + .where(uak.ID.eq(apiKeyId)) + .and(uak.USER_ID.eq(userId)) + .execute() + } + + override fun deleteApiKeyByUserId(userId: String) { + dslRW.deleteFrom(uak).where(uak.USER_ID.eq(userId)).execute() + } + + override fun findAnnouncementIdsReadByUserId(userId: String): Set = + dslRO + .select(ar.ANNOUNCEMENT_ID) + .from(ar) + .where(ar.USER_ID.eq(userId)) + .fetchSet(ar.ANNOUNCEMENT_ID) + + override fun existsByEmailIgnoreCase(email: String): Boolean = + dslRO.fetchExists( + dslRO + .selectFrom(u) + .where(u.EMAIL.equalIgnoreCase(email)), + ) + + override fun existsApiKeyByIdAndUserId( + apiKeyId: String, + userId: String, + ): Boolean = dslRO.fetchExists(uak, uak.ID.eq(apiKeyId).and(uak.USER_ID.eq(userId))) + + override fun existsApiKeyByCommentAndUserId( + comment: String, + userId: String, + ): Boolean = dslRO.fetchExists(uak, uak.COMMENT.equalIgnoreCase(comment).and(uak.USER_ID.eq(userId))) + + override fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? = + dslRO + .selectBase() + .where(u.EMAIL.equalIgnoreCase(email)) + .fetchAndMap(dslRO) + .firstOrNull() + + override fun findByApiKeyOrNull(apiKey: String): Pair? { + val user = + dslRO + .selectBase() + .leftJoin(uak) + .on(u.ID.eq(uak.USER_ID)) + .where(uak.API_KEY.eq(apiKey)) + .fetchAndMap(dslRO) + .firstOrNull() ?: return null + + val key = + dslRO + .selectFrom(uak) + .where(uak.API_KEY.eq(apiKey)) + .fetchInto(uak) + .map { it.toDomain() } + .firstOrNull() ?: return null + + return Pair(user, key) + } + + private fun UserApiKeyRecord.toDomain() = + ApiKey( + id = id, + userId = userId, + key = apiKey, + comment = comment, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt new file mode 100644 index 000000000..90dfc4d0c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt @@ -0,0 +1,212 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.LibraryRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class LibraryDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + LibraryRepository { + private val l = Tables.LIBRARY + private val ul = Tables.USER_LIBRARY_SHARING + private val le = Tables.LIBRARY_EXCLUSIONS + + override fun findByIdOrNull(libraryId: String): Library? = + findOne(libraryId) + .fetchAndMap() + .firstOrNull() + + override fun findById(libraryId: String): Library = + findOne(libraryId) + .fetchAndMap() + .first() + + private fun findOne(libraryId: String) = + dslRO + .selectBase() + .where(l.ID.eq(libraryId)) + + override fun findAll(): Collection = + dslRO + .selectBase() + .fetchAndMap() + + override fun findAllByIds(libraryIds: Collection): Collection = + dslRO + .selectBase() + .where(l.ID.`in`(libraryIds)) + .fetchAndMap() + + private fun DSLContext.selectBase() = + select() + .from(l) + .leftJoin(le) + .onKey() + + private fun ResultQuery.fetchAndMap(): Collection = + this + .fetchGroups({ it.into(l) }, { it.into(le) }) + .map { (lr, ler) -> + lr.toDomain(ler.mapNotNull { it.exclusion }.toSet()) + } + + @Transactional + override fun delete(libraryId: String) { + dslRW.deleteFrom(le).where(le.LIBRARY_ID.eq(libraryId)).execute() + dslRW.deleteFrom(ul).where(ul.LIBRARY_ID.eq(libraryId)).execute() + dslRW.deleteFrom(l).where(l.ID.eq(libraryId)).execute() + } + + @Transactional + override fun deleteAll() { + dslRW.deleteFrom(le).execute() + dslRW.deleteFrom(ul).execute() + dslRW.deleteFrom(l).execute() + } + + @Transactional + override fun insert(library: Library) { + dslRW + .insertInto(l) + .set(l.ID, library.id) + .set(l.NAME, library.name) + .set(l.ROOT, library.root.toString()) + .set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook) + .set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries) + .set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection) + .set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList) + .set(l.IMPORT_COMICINFO_SERIES_APPEND_VOLUME, library.importComicInfoSeriesAppendVolume) + .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) + .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) + .set(l.IMPORT_MYLAR_SERIES, library.importMylarSeries) + .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) + .set(l.IMPORT_BARCODE_ISBN, library.importBarcodeIsbn) + .set(l.SCAN_FORCE_MODIFIED_TIME, library.scanForceModifiedTime) + .set(l.SCAN_CBX, library.scanCbx) + .set(l.SCAN_PDF, library.scanPdf) + .set(l.SCAN_EPUB, library.scanEpub) + .set(l.SCAN_STARTUP, library.scanOnStartup) + .set(l.SCAN_INTERVAL, library.scanInterval.toString()) + .set(l.REPAIR_EXTENSIONS, library.repairExtensions) + .set(l.CONVERT_TO_CBZ, library.convertToCbz) + .set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan) + .set(l.SERIES_COVER, library.seriesCover.toString()) + .set(l.HASH_FILES, library.hashFiles) + .set(l.HASH_PAGES, library.hashPages) + .set(l.HASH_KOREADER, library.hashKoreader) + .set(l.ANALYZE_DIMENSIONS, library.analyzeDimensions) + .set(l.ONESHOTS_DIRECTORY, library.oneshotsDirectory) + .set(l.UNAVAILABLE_DATE, library.unavailableDate) + .execute() + + dslRW.insertDirectoryExclusions(library) + } + + @Transactional + override fun update(library: Library) { + dslRW + .update(l) + .set(l.NAME, library.name) + .set(l.ROOT, library.root.toString()) + .set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook) + .set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries) + .set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection) + .set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList) + .set(l.IMPORT_COMICINFO_SERIES_APPEND_VOLUME, library.importComicInfoSeriesAppendVolume) + .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) + .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) + .set(l.IMPORT_MYLAR_SERIES, library.importMylarSeries) + .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) + .set(l.IMPORT_BARCODE_ISBN, library.importBarcodeIsbn) + .set(l.SCAN_FORCE_MODIFIED_TIME, library.scanForceModifiedTime) + .set(l.SCAN_CBX, library.scanCbx) + .set(l.SCAN_PDF, library.scanPdf) + .set(l.SCAN_EPUB, library.scanEpub) + .set(l.SCAN_STARTUP, library.scanOnStartup) + .set(l.SCAN_INTERVAL, library.scanInterval.toString()) + .set(l.REPAIR_EXTENSIONS, library.repairExtensions) + .set(l.CONVERT_TO_CBZ, library.convertToCbz) + .set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan) + .set(l.SERIES_COVER, library.seriesCover.toString()) + .set(l.HASH_FILES, library.hashFiles) + .set(l.HASH_PAGES, library.hashPages) + .set(l.HASH_KOREADER, library.hashKoreader) + .set(l.ANALYZE_DIMENSIONS, library.analyzeDimensions) + .set(l.ONESHOTS_DIRECTORY, library.oneshotsDirectory) + .set(l.UNAVAILABLE_DATE, library.unavailableDate) + .set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(l.ID.eq(library.id)) + .execute() + + dslRW.deleteFrom(le).where(le.LIBRARY_ID.eq(library.id)).execute() + dslRW.insertDirectoryExclusions(library) + } + + override fun count(): Long = dslRO.fetchCount(l).toLong() + + private fun DSLContext.insertDirectoryExclusions(library: Library) { + if (library.scanDirectoryExclusions.isNotEmpty()) { + this + .batch( + this + .insertInto(le, le.LIBRARY_ID, le.EXCLUSION) + .values(null as String?, null), + ).also { step -> + library.scanDirectoryExclusions.forEach { + step.bind(library.id, it) + } + }.execute() + } + } + + private fun LibraryRecord.toDomain(directoryExclusions: Set) = + Library( + name = name, + root = URL(root), + importComicInfoBook = importComicinfoBook, + importComicInfoSeries = importComicinfoSeries, + importComicInfoCollection = importComicinfoCollection, + importComicInfoReadList = importComicinfoReadlist, + importComicInfoSeriesAppendVolume = importComicinfoSeriesAppendVolume, + importEpubBook = importEpubBook, + importEpubSeries = importEpubSeries, + importMylarSeries = importMylarSeries, + importLocalArtwork = importLocalArtwork, + importBarcodeIsbn = importBarcodeIsbn, + scanForceModifiedTime = scanForceModifiedTime, + scanCbx = scanCbx, + scanPdf = scanPdf, + scanEpub = scanEpub, + scanOnStartup = scanStartup, + scanInterval = Library.ScanInterval.valueOf(scanInterval), + scanDirectoryExclusions = directoryExclusions, + repairExtensions = repairExtensions, + convertToCbz = convertToCbz, + emptyTrashAfterScan = emptyTrashAfterScan, + seriesCover = Library.SeriesCover.valueOf(seriesCover), + hashFiles = hashFiles, + hashPages = hashPages, + hashKoreader = hashKoreader, + analyzeDimensions = analyzeDimensions, + oneshotsDirectory = oneshotsDirectory, + unavailableDate = unavailableDate, + id = id, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt new file mode 100644 index 000000000..e5f7375c2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt @@ -0,0 +1,352 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtension +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.ProxyExtension +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension +import org.gotson.komga.infrastructure.jooq.serializeJsonGz +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.MediaFileRecord +import org.gotson.komga.jooq.main.tables.records.MediaPageRecord +import org.gotson.komga.jooq.main.tables.records.MediaRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +private val logger = KotlinLogging.logger {} + +@Component +class MediaDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, + private val mapper: ObjectMapper, +) : SplitDslDaoBase(dslRW, dslRO), + MediaRepository { + private val m = Tables.MEDIA + private val p = Tables.MEDIA_PAGE + private val f = Tables.MEDIA_FILE + private val b = Tables.BOOK + + private val groupFields = + arrayOf( + m.BOOK_ID, + m.MEDIA_TYPE, + m.STATUS, + m.CREATED_DATE, + m.LAST_MODIFIED_DATE, + m.COMMENT, + m.PAGE_COUNT, + m.EXTENSION_CLASS, + m.EPUB_DIVINA_COMPATIBLE, + m.EPUB_IS_KEPUB, + *p.fields(), + ) + + override fun findById(bookId: String): Media = dslRO.find(bookId)!! + + override fun findByIdOrNull(bookId: String): Media? = dslRO.find(bookId) + + override fun findExtensionByIdOrNull(bookId: String): MediaExtension? = + dslRO + .select(m.EXTENSION_CLASS, m.EXTENSION_VALUE_BLOB) + .from(m) + .where(m.BOOK_ID.eq(bookId)) + .fetchOne() + ?.map { mapper.deserializeMediaExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) } + + override fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash( + libraryId: String, + mediaTypes: Collection, + pageHashing: Int, + ): Collection { + val pagesCount = DSL.count(p.BOOK_ID) + val hashedCount = DSL.sum(DSL.`when`(p.FILE_HASH.eq(""), 0).otherwise(1)).cast(Int::class.java) + val neededHash = pageHashing * 2 + val neededHashForBook = DSL.`when`(pagesCount.lt(neededHash), pagesCount).otherwise(neededHash) + + return dslRO + .select(b.ID) + .from(b) + .leftJoin(p) + .on(b.ID.eq(p.BOOK_ID)) + .leftJoin(m) + .on(b.ID.eq(m.BOOK_ID)) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(m.STATUS.eq(Media.Status.READY.name)) + .and(m.MEDIA_TYPE.`in`(mediaTypes)) + .groupBy(b.ID) + .having(hashedCount.lt(neededHashForBook)) + .fetch() + .map { it.value1() } + } + + override fun getPagesSizes(bookIds: Collection): Collection> = + dslRO + .select(m.BOOK_ID, m.PAGE_COUNT) + .from(m) + .where(m.BOOK_ID.`in`(bookIds)) + .fetch() + .map { Pair(it[m.BOOK_ID], it[m.PAGE_COUNT]) } + + private fun DSLContext.find( + bookId: String, + ): Media? = + this + .select(*groupFields) + .from(m) + .leftJoin(p) + .on(m.BOOK_ID.eq(p.BOOK_ID)) + .where(m.BOOK_ID.eq(bookId)) + .groupBy(*groupFields) + .orderBy(p.NUMBER.asc()) + .fetchGroups( + { it.into(m) }, + { it.into(p) }, + ).map { (mr, pr) -> + val files = + this + .selectFrom(f) + .where(f.BOOK_ID.eq(bookId)) + .fetchInto(f) + + mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files.map { it.toDomain() }) + }.firstOrNull() + + @Transactional + override fun insert(media: Media) { + insert(listOf(media)) + } + + @Transactional + override fun insert(medias: Collection) { + if (medias.isNotEmpty()) { + medias.chunked(batchSize).forEach { chunk -> + dslRW + .batch( + dslRW + .insertInto( + m, + m.BOOK_ID, + m.STATUS, + m.MEDIA_TYPE, + m.COMMENT, + m.PAGE_COUNT, + m.EPUB_DIVINA_COMPATIBLE, + m.EPUB_IS_KEPUB, + m.EXTENSION_CLASS, + m.EXTENSION_VALUE_BLOB, + ).values(null as String?, null, null, null, null, null, null, null, null), + ).also { step -> + chunk.forEach { media -> + step.bind( + media.bookId, + media.status, + media.mediaType, + media.comment, + media.pageCount, + media.epubDivinaCompatible, + media.epubIsKepub, + media.extension?.let { + if (it is ProxyExtension) { + logger.error { "Found ProxyExtension while trying to insert Media: this is not expected and should be reported." } + null + } else { + it::class.qualifiedName + } + }, + media.extension?.let { if (it is ProxyExtension) null else mapper.serializeJsonGz(it) }, + ) + } + }.execute() + } + + dslRW.insertPages(medias) + dslRW.insertFiles(medias) + } + } + + private fun DSLContext.insertPages(medias: Collection) { + if (medias.any { it.pages.isNotEmpty() }) { + medias.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto( + p, + p.BOOK_ID, + p.FILE_NAME, + p.MEDIA_TYPE, + p.NUMBER, + p.WIDTH, + p.HEIGHT, + p.FILE_HASH, + p.FILE_SIZE, + ).values(null as String?, null, null, null, null, null, null, null), + ).also { step -> + chunk.forEach { media -> + media.pages.forEachIndexed { index, page -> + step.bind( + media.bookId, + page.fileName, + page.mediaType, + index, + page.dimension?.width, + page.dimension?.height, + page.fileHash, + page.fileSize, + ) + } + } + }.execute() + } + } + } + + private fun DSLContext.insertFiles(medias: Collection) { + if (medias.any { it.files.isNotEmpty() }) { + medias.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto( + f, + f.BOOK_ID, + f.FILE_NAME, + f.MEDIA_TYPE, + f.SUB_TYPE, + f.FILE_SIZE, + ).values(null as String?, null, null, null, null), + ).also { step -> + chunk.forEach { media -> + media.files.forEach { + step.bind( + media.bookId, + it.fileName, + it.mediaType, + it.subType, + it.fileSize, + ) + } + } + }.execute() + } + } + } + + @Transactional + override fun update(media: Media) { + dslRW + .update(m) + .set(m.STATUS, media.status.toString()) + .set(m.MEDIA_TYPE, media.mediaType) + .set(m.COMMENT, media.comment) + .set(m.PAGE_COUNT, media.pageCount) + .set(m.EPUB_DIVINA_COMPATIBLE, media.epubDivinaCompatible) + .set(m.EPUB_IS_KEPUB, media.epubIsKepub) + .apply { + if (media.extension != null) { + if (media.extension !is ProxyExtension) { + set(m.EXTENSION_CLASS, media.extension::class.qualifiedName) + set(m.EXTENSION_VALUE_BLOB, mapper.serializeJsonGz(media.extension)) + } else { + logger.error { "Found ProxyExtension while trying to update Media: this is not expected and should be reported." } + } + } + }.set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(m.BOOK_ID.eq(media.bookId)) + .execute() + + dslRW + .deleteFrom(p) + .where(p.BOOK_ID.eq(media.bookId)) + .execute() + + dslRW + .deleteFrom(f) + .where(f.BOOK_ID.eq(media.bookId)) + .execute() + + dslRW.insertPages(listOf(media)) + dslRW.insertFiles(listOf(media)) + } + + @Transactional + override fun copy( + fromBookId: String, + toBookId: String, + ) { + val source = findById(fromBookId) + val sourceExtension = findExtensionByIdOrNull(fromBookId) + + val copy = source.copy(bookId = toBookId, extension = sourceExtension) + update(copy) + } + + @Transactional + override fun delete(bookId: String) { + dslRW.deleteFrom(p).where(p.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(f).where(f.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(m).where(m.BOOK_ID.eq(bookId)).execute() + } + + @Transactional + override fun delete(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { + dslRW.deleteFrom(p).where(p.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(f).where(f.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(m).where(m.BOOK_ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun count(): Long = dslRO.fetchCount(m).toLong() + + private fun MediaRecord.toDomain( + pages: List, + files: List, + ) = Media( + status = Media.Status.valueOf(status), + mediaType = mediaType, + pages = pages, + pageCount = pageCount, + files = files, + extension = ProxyExtension.of(extensionClass), + comment = comment, + bookId = bookId, + epubDivinaCompatible = epubDivinaCompatible, + epubIsKepub = epubIsKepub, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) + + private fun MediaPageRecord.toDomain() = + BookPage( + fileName = fileName, + mediaType = mediaType, + dimension = if (width != null && height != null) Dimension(width, height) else null, + fileHash = fileHash, + fileSize = fileSize, + ) + + private fun MediaFileRecord.toDomain() = + MediaFile( + fileName = fileName, + mediaType = mediaType, + subType = subType?.let { MediaFile.SubType.valueOf(it) }, + fileSize = fileSize, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDao.kt new file mode 100644 index 000000000..c4acc059b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDao.kt @@ -0,0 +1,263 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.domain.model.PageHashMatch +import org.gotson.komga.domain.model.PageHashUnknown +import org.gotson.komga.domain.persistence.PageHashRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.toOrderBy +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.PageHashRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class PageHashDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + PageHashRepository { + private val p = Tables.MEDIA_PAGE + private val b = Tables.BOOK + private val ph = Tables.PAGE_HASH + private val pht = Tables.PAGE_HASH_THUMBNAIL + + private val sortsKnown = + mapOf( + "hash" to ph.HASH, + "matchCount" to DSL.field("count"), + "deleteCount" to ph.DELETE_COUNT, + "deleteSize" to ph.SIZE * ph.DELETE_COUNT, + "fileSize" to ph.SIZE, + "size" to ph.SIZE, + "createdDate" to ph.CREATED_DATE, + "created" to ph.CREATED_DATE, + "lastModifiedDate" to ph.LAST_MODIFIED_DATE, + "lastModified" to ph.LAST_MODIFIED_DATE, + ) + + private val sortsUnknown = + mapOf( + "hash" to p.FILE_HASH, + "fileSize" to p.FILE_SIZE, + "size" to p.FILE_SIZE, + "matchCount" to DSL.field("count"), + "totalSize" to DSL.field("totalSize"), + "url" to b.URL, + "bookId" to b.ID, + "pageNumber" to p.NUMBER, + ) + + override fun findKnown(pageHash: String): PageHashKnown? = + dslRO + .selectFrom(ph) + .where(ph.HASH.eq(pageHash)) + .fetchOneInto(ph) + ?.toDomain() + + override fun findAllKnown( + actions: List?, + pageable: Pageable, + ): Page { + val query = + dslRO + .select(*ph.fields(), DSL.count(p.FILE_HASH).`as`("count")) + .from(ph) + .leftJoin(p) + .on(ph.HASH.eq(p.FILE_HASH)) + .apply { actions?.let { where(ph.ACTION.`in`(actions)) } } + .groupBy(*ph.fields()) + + val count = dslRO.fetchCount(query) + + val orderBy = pageable.sort.toOrderBy(sortsKnown) + val items = + query + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetch() + .map { it.into(ph).toDomain(it.get("count", Int::class.java)) } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun findAllUnknown(pageable: Pageable): Page { + val bookCount = DSL.count(p.BOOK_ID) + val query = + dslRO + .select( + p.FILE_HASH, + p.FILE_SIZE, + bookCount.`as`("count"), + (bookCount * p.FILE_SIZE).`as`("totalSize"), + ).from(p) + .where(p.FILE_HASH.ne("")) + .and( + DSL.notExists( + dslRO + .selectOne() + .from(ph) + .where(ph.HASH.eq(p.FILE_HASH)), + ), + ).groupBy(p.FILE_HASH) + .having(DSL.count(p.BOOK_ID).gt(1)) + + val count = dslRO.fetchCount(query) + + val orderBy = pageable.sort.toOrderBy(sortsUnknown) + val items = + query + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetch { + PageHashUnknown(it.value1(), it.value2(), it.value3()) + } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun findMatchesByHash( + pageHash: String, + pageable: Pageable, + ): Page { + val query = + dslRO + .select(p.BOOK_ID, b.URL, p.NUMBER, p.FILE_NAME, p.FILE_SIZE, p.MEDIA_TYPE) + .from(p) + .leftJoin(b) + .on(p.BOOK_ID.eq(b.ID)) + .where(p.FILE_HASH.eq(pageHash)) + + val count = dslRO.fetchCount(query) + + val orderBy = pageable.sort.toOrderBy(sortsUnknown) + val items = + query + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetch { + PageHashMatch( + bookId = it.value1(), + url = URL(it.value2()), + pageNumber = it.value3() + 1, + fileName = it.value4(), + fileSize = it.value5(), + mediaType = it.value6(), + ) + } + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun findMatchesByKnownHashAction( + actions: List?, + libraryId: String?, + ): Map> = + dslRO + .select(p.BOOK_ID, p.FILE_NAME, p.NUMBER, p.FILE_HASH, p.MEDIA_TYPE, p.FILE_SIZE) + .from(p) + .innerJoin(ph) + .on(p.FILE_HASH.eq(ph.HASH)) + .apply { libraryId?.let { innerJoin(b).on(b.ID.eq(p.BOOK_ID)) } } + .where(ph.ACTION.`in`(actions)) + .apply { libraryId?.let { and(b.LIBRARY_ID.eq(it)) } } + .fetch { + it.value1() to + BookPageNumbered( + fileName = it.value2(), + pageNumber = it.value3() + 1, + fileHash = it.value4(), + mediaType = it.value5(), + fileSize = it.value6(), + ) + }.groupingBy { it.first } + .fold(emptyList()) { acc, (_, new) -> acc + new } + + override fun getKnownThumbnail(pageHash: String): ByteArray? = + dslRO + .select(pht.THUMBNAIL) + .from(pht) + .where(pht.HASH.eq(pageHash)) + .fetchOne() + ?.value1() + + @Transactional + override fun insert( + pageHash: PageHashKnown, + thumbnail: ByteArray?, + ) { + dslRW + .insertInto(ph) + .set(ph.HASH, pageHash.hash) + .set(ph.SIZE, pageHash.size) + .set(ph.ACTION, pageHash.action.name) + .execute() + + if (thumbnail != null) { + dslRW + .insertInto(pht) + .set(pht.HASH, pageHash.hash) + .set(pht.THUMBNAIL, thumbnail) + .execute() + } + } + + override fun update(pageHash: PageHashKnown) { + dslRW + .update(ph) + .set(ph.ACTION, pageHash.action.name) + .set(ph.SIZE, pageHash.size) + .set(ph.DELETE_COUNT, pageHash.deleteCount) + .set(ph.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(ph.HASH.eq(pageHash.hash)) + .execute() + } +} + +private fun PageHashRecord.toDomain(matchCount: Int = 0) = + PageHashKnown( + hash = hash, + size = size, + deleteCount = deleteCount, + matchCount = matchCount, + action = PageHashKnown.Action.valueOf(action), + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDao.kt new file mode 100644 index 000000000..29eaf090d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDao.kt @@ -0,0 +1,309 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.inOrNoCondition +import org.gotson.komga.infrastructure.jooq.sortByValues +import org.gotson.komga.infrastructure.jooq.toCondition +import org.gotson.komga.infrastructure.jooq.toSortField +import org.gotson.komga.infrastructure.jooq.unicode3 +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.gotson.komga.infrastructure.search.LuceneHelper +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ReadlistRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.SortedMap + +@Component +class ReadListDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val luceneHelper: LuceneHelper, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + ReadListRepository { + private val rl = Tables.READLIST + private val rlb = Tables.READLIST_BOOK + private val b = Tables.BOOK + private val sd = Tables.SERIES_METADATA + + private val sorts = + mapOf( + "name" to rl.NAME.unicode3(), + "createdDate" to rl.CREATED_DATE, + "lastModifiedDate" to rl.LAST_MODIFIED_DATE, + ) + + override fun findByIdOrNull( + readListId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): ReadList? = + dslRO + .selectBase(restrictions.isRestricted) + .where(rl.ID.eq(readListId)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + .firstOrNull() + + override fun findAll( + belongsToLibraryIds: Collection?, + filterOnLibraryIds: Collection?, + search: String?, + pageable: Pageable, + restrictions: ContentRestrictions, + ): Page { + val readListIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.ReadList) + val searchCondition = rl.ID.inOrNoCondition(readListIds) + + val conditions = + searchCondition + .and(b.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds)) + .and(b.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds)) + .and(restrictions.toCondition()) + + val queryIds = + if (belongsToLibraryIds == null && filterOnLibraryIds == null && !restrictions.isRestricted) + null + else + dslRO + .selectDistinct(rl.ID) + .from(rl) + .leftJoin(rlb) + .on(rl.ID.eq(rlb.READLIST_ID)) + .leftJoin(b) + .on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (restrictions.isRestricted) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + .where(conditions) + + val count = + if (queryIds != null) + dslRO.fetchCount(queryIds) + else + dslRO.fetchCount(rl, searchCondition) + + val orderBy = + pageable.sort.mapNotNull { + if (it.property == "relevance" && !readListIds.isNullOrEmpty()) + rl.ID.sortByValues(readListIds, it.isAscending) + else + it.toSortField(sorts) + } + + val items = + dslRO + .selectBase(restrictions.isRestricted) + .where(conditions) + .apply { if (queryIds != null) and(rl.ID.`in`(queryIds)) } + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun findAllContainingBookId( + containsBookId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): Collection { + val queryIds = + dslRO + .select(rl.ID) + .from(rl) + .leftJoin(rlb) + .on(rl.ID.eq(rlb.READLIST_ID)) + .apply { if (restrictions.isRestricted) leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)).leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + .where(rlb.BOOK_ID.eq(containsBookId)) + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + + return dslRO + .selectBase(restrictions.isRestricted) + .where(rl.ID.`in`(queryIds)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + } + + override fun findAllEmpty(): Collection = + dslRO + .selectFrom(rl) + .where( + rl.ID.`in`( + dslRO + .select(rl.ID) + .from(rl) + .leftJoin(rlb) + .on(rl.ID.eq(rlb.READLIST_ID)) + .where(rlb.READLIST_ID.isNull), + ), + ).fetchInto(rl) + .map { it.toDomain(sortedMapOf()) } + + override fun findByNameOrNull(name: String): ReadList? = + dslRO + .selectBase() + .where(rl.NAME.equalIgnoreCase(name)) + .fetchAndMap(dslRO, null) + .firstOrNull() + + private fun DSLContext.selectBase(joinOnSeriesMetadata: Boolean = false) = + this + .selectDistinct(*rl.fields()) + .from(rl) + .leftJoin(rlb) + .on(rl.ID.eq(rlb.READLIST_ID)) + .leftJoin(b) + .on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (joinOnSeriesMetadata) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + + private fun ResultQuery.fetchAndMap( + dsl: DSLContext, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): List = + fetchInto(rl) + .map { rr -> + val bookIds = + dsl + .select(*rlb.fields()) + .from(rlb) + .leftJoin(b) + .on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (restrictions.isRestricted) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + .where(rlb.READLIST_ID.eq(rr.id)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .orderBy(rlb.NUMBER.asc()) + .fetchInto(rlb) + .mapNotNull { it.number to it.bookId } + .toMap() + .toSortedMap() + rr.toDomain(bookIds) + } + + @Transactional + override fun insert(readList: ReadList) { + dslRW + .insertInto(rl) + .set(rl.ID, readList.id) + .set(rl.NAME, readList.name) + .set(rl.SUMMARY, readList.summary) + .set(rl.ORDERED, readList.ordered) + .set(rl.BOOK_COUNT, readList.bookIds.size) + .execute() + + dslRW.insertBooks(readList) + } + + private fun DSLContext.insertBooks(readList: ReadList) { + readList.bookIds.map { (index, id) -> + this + .insertInto(rlb) + .set(rlb.READLIST_ID, readList.id) + .set(rlb.BOOK_ID, id) + .set(rlb.NUMBER, index) + .execute() + } + } + + @Transactional + override fun update(readList: ReadList) { + dslRW + .update(rl) + .set(rl.NAME, readList.name) + .set(rl.SUMMARY, readList.summary) + .set(rl.ORDERED, readList.ordered) + .set(rl.BOOK_COUNT, readList.bookIds.size) + .set(rl.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(rl.ID.eq(readList.id)) + .execute() + + dslRW.deleteFrom(rlb).where(rlb.READLIST_ID.eq(readList.id)).execute() + + dslRW.insertBooks(readList) + } + + override fun removeBookFromAll(bookId: String) { + dslRW + .deleteFrom(rlb) + .where(rlb.BOOK_ID.eq(bookId)) + .execute() + } + + @Transactional + override fun removeBooksFromAll(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { + dslRW + .deleteFrom(rlb) + .where(rlb.BOOK_ID.`in`(it.selectTempStrings())) + .execute() + } + } + + @Transactional + override fun delete(readListId: String) { + dslRW.deleteFrom(rlb).where(rlb.READLIST_ID.eq(readListId)).execute() + dslRW.deleteFrom(rl).where(rl.ID.eq(readListId)).execute() + } + + @Transactional + override fun delete(readListIds: Collection) { + dslRW.deleteFrom(rlb).where(rlb.READLIST_ID.`in`(readListIds)).execute() + dslRW.deleteFrom(rl).where(rl.ID.`in`(readListIds)).execute() + } + + @Transactional + override fun deleteAll() { + dslRW.deleteFrom(rlb).execute() + dslRW.deleteFrom(rl).execute() + } + + override fun existsByName(name: String): Boolean = + dslRO.fetchExists( + dslRO + .selectFrom(rl) + .where(rl.NAME.equalIgnoreCase(name)), + ) + + override fun count(): Long = dslRO.fetchCount(rl).toLong() + + private fun ReadlistRecord.toDomain(bookIds: SortedMap) = + ReadList( + name = name, + summary = summary, + ordered = ordered, + bookIds = bookIds, + id = id, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + filtered = bookCount != bookIds.size, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListRequestDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListRequestDao.kt new file mode 100644 index 000000000..3395ae1f4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListRequestDao.kt @@ -0,0 +1,76 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.ReadListRequestBook +import org.gotson.komga.domain.model.ReadListRequestBookMatchBook +import org.gotson.komga.domain.model.ReadListRequestBookMatchSeries +import org.gotson.komga.domain.model.ReadListRequestBookMatches +import org.gotson.komga.domain.persistence.ReadListRequestRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.noCase +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.jooq.impl.DSL.ltrim +import org.jooq.impl.DSL.row +import org.jooq.impl.DSL.value +import org.jooq.impl.DSL.values +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.time.LocalDate + +@Component +class ReadListRequestDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + ReadListRequestRepository { + private val sd = Tables.SERIES_METADATA + private val b = Tables.BOOK + private val bd = Tables.BOOK_METADATA + private val bma = Tables.BOOK_METADATA_AGGREGATION + + override fun matchBookRequests(requests: Collection): Collection { + // use a table expression to join the requests to their potential matches + val requestsAsRows = requests.flatMapIndexed { i, r -> r.series.map { row(i, it, r.number) } } + val seriesField = "series" + val indexField = "index" + val numberField = "number" + val requestsTable = values(*requestsAsRows.toTypedArray()).`as`("request", indexField, seriesField, numberField) + val matchedRequests = + dslRO + .select( + requestsTable.field(indexField, Int::class.java), + sd.SERIES_ID, + sd.TITLE, + bd.BOOK_ID, + bd.NUMBER, + bd.TITLE, + bma.RELEASE_DATE, + ).from(requestsTable) + .innerJoin(sd) + .on(requestsTable.field(seriesField, String::class.java)?.eq(sd.TITLE.noCase())) + .leftJoin(bma) + .on(sd.SERIES_ID.eq(bma.SERIES_ID)) + .innerJoin(b) + .on(sd.SERIES_ID.eq(b.SERIES_ID)) + .innerJoin(bd) + .on( + b.ID + .eq(bd.BOOK_ID) + .and(ltrim(bd.NUMBER, value("0")).eq(ltrim(requestsTable.field(numberField, String::class.java), value("0")).noCase())), + ).fetchGroups(requestsTable.field(indexField, Int::class.java)) + .mapValues { (_, records) -> + // use the requests index to match results + records.groupBy( + { ReadListRequestBookMatchSeries(it.get(1, String::class.java), it.get(2, String::class.java), it.get(6, LocalDate::class.java)) }, + { ReadListRequestBookMatchBook(it.get(3, String::class.java), it.get(4, String::class.java), it.get(5, String::class.java)) }, + ) + } + + return requests.mapIndexed { i, request -> + ReadListRequestBookMatches( + request, + matchedRequests.getOrDefault(i, emptyMap()), + ) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt new file mode 100644 index 000000000..bf1014d51 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt @@ -0,0 +1,244 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.fasterxml.jackson.databind.ObjectMapper +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.deserializeJsonGz +import org.gotson.komga.infrastructure.jooq.serializeJsonGz +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ReadProgressRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.gotson.komga.language.toUTC +import org.jooq.DSLContext +import org.jooq.Query +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class ReadProgressDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, + private val mapper: ObjectMapper, +) : SplitDslDaoBase(dslRW, dslRO), + ReadProgressRepository { + private val r = Tables.READ_PROGRESS + private val rs = Tables.READ_PROGRESS_SERIES + private val b = Tables.BOOK + + override fun findAll(): Collection = + dslRO + .selectFrom(r) + .fetchInto(r) + .map { it.toDomain() } + + override fun findByBookIdAndUserIdOrNull( + bookId: String, + userId: String, + ): ReadProgress? = + dslRO + .selectFrom(r) + .where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId))) + .fetchOneInto(r) + ?.toDomain() + + override fun findAllByUserId(userId: String): Collection = + dslRO + .selectFrom(r) + .where(r.USER_ID.eq(userId)) + .fetchInto(r) + .map { it.toDomain() } + + override fun findAllByBookId(bookId: String): Collection = + dslRO + .selectFrom(r) + .where(r.BOOK_ID.eq(bookId)) + .fetchInto(r) + .map { it.toDomain() } + + override fun findAllByBookIdsAndUserId( + bookIds: Collection, + userId: String, + ): Collection = + dslRO + .selectFrom(r) + .where(r.BOOK_ID.`in`(bookIds).and(r.USER_ID.eq(userId))) + .fetchInto(r) + .map { it.toDomain() } + + @Transactional + override fun save(readProgress: ReadProgress) { + readProgress.toQuery(dslRW).execute() + dslRW.aggregateSeriesProgress(listOf(readProgress.bookId), readProgress.userId) + } + + @Transactional + override fun save(readProgresses: Collection) { + readProgresses + .map { it.toQuery(dslRW) } + .chunked(batchSize) + .forEach { chunk -> dslRW.batch(chunk).execute() } + + readProgresses + .groupBy { it.userId } + .forEach { (userId, readProgresses) -> + dslRW.aggregateSeriesProgress(readProgresses.map { it.bookId }, userId) + } + } + + private fun ReadProgress.toQuery(dsl: DSLContext): Query = + dsl + .insertInto( + r, + r.BOOK_ID, + r.USER_ID, + r.PAGE, + r.COMPLETED, + r.READ_DATE, + r.DEVICE_ID, + r.DEVICE_NAME, + r.LOCATOR, + ).values( + bookId, + userId, + page, + completed, + readDate.toUTC(), + deviceId, + deviceName, + locator?.let { mapper.serializeJsonGz(it) }, + ).onDuplicateKeyUpdate() + .set(r.PAGE, page) + .set(r.COMPLETED, completed) + .set(r.READ_DATE, readDate.toUTC()) + .set(r.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .set(r.DEVICE_ID, deviceId) + .set(r.DEVICE_NAME, deviceName) + .set(r.LOCATOR, locator?.let { mapper.serializeJsonGz(it) }) + + @Transactional + override fun delete( + bookId: String, + userId: String, + ) { + dslRW.deleteFrom(r).where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId))).execute() + dslRW.aggregateSeriesProgress(listOf(bookId), userId) + } + + @Transactional + override fun deleteByUserId(userId: String) { + dslRW.deleteFrom(r).where(r.USER_ID.eq(userId)).execute() + dslRW.deleteFrom(rs).where(rs.USER_ID.eq(userId)).execute() + } + + @Transactional + override fun deleteByBookId(bookId: String) { + dslRW.deleteFrom(r).where(r.BOOK_ID.eq(bookId)).execute() + dslRW.aggregateSeriesProgress(listOf(bookId)) + } + + @Transactional + override fun deleteByBookIds(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { tempTable -> + dslRW.deleteFrom(r).where(r.BOOK_ID.`in`(tempTable.selectTempStrings())).execute() + dslRW.aggregateSeriesProgress(tempTable) + } + } + + @Transactional + override fun deleteBySeriesIds(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW.deleteFrom(rs).where(rs.SERIES_ID.`in`(it.selectTempStrings())).execute() + } + } + + @Transactional + override fun deleteByBookIdsAndUserId( + bookIds: Collection, + userId: String, + ) { + dslRW.withTempTable(batchSize, bookIds).use { tempTable -> + dslRW + .deleteFrom(r) + .where(r.BOOK_ID.`in`(tempTable.selectTempStrings())) + .and(r.USER_ID.eq(userId)) + .execute() + dslRW.aggregateSeriesProgress(tempTable, userId) + } + } + + @Transactional + override fun deleteAll() { + dslRW.deleteFrom(r).execute() + dslRW.deleteFrom(rs).execute() + } + + private fun DSLContext.aggregateSeriesProgress( + bookIds: Collection, + userId: String? = null, + ) { + this.withTempTable(batchSize, bookIds).use { tempTable -> + this.aggregateSeriesProgress(tempTable, userId) + } + } + + /** + * Get the book IDs from an existing TempTable to avoid recreating another temporary table if one already exists. + */ + private fun DSLContext.aggregateSeriesProgress( + bookIdsTempTable: TempTable, + userId: String? = null, + ) { + val seriesIdsQuery = + this + .select(b.SERIES_ID) + .from(b) + .where(b.ID.`in`(bookIdsTempTable.selectTempStrings())) + + this + .deleteFrom(rs) + .where(rs.SERIES_ID.`in`(seriesIdsQuery)) + .apply { userId?.let { and(rs.USER_ID.eq(it)) } } + .execute() + + this + .insertInto(rs) + .select( + this + .select(b.SERIES_ID, r.USER_ID) + .select(DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0))) + .select(DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0))) + .select(DSL.max(r.READ_DATE)) + .select(DSL.currentTimestamp()) + .from(b) + .innerJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .where(b.SERIES_ID.`in`(seriesIdsQuery)) + .apply { userId?.let { and(r.USER_ID.eq(it)) } } + .groupBy(b.SERIES_ID, r.USER_ID), + ).execute() + } + + private fun ReadProgressRecord.toDomain() = + ReadProgress( + bookId = bookId, + userId = userId, + page = page, + completed = completed, + readDate = readDate.toCurrentTimeZone(), + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + deviceId = deviceId, + deviceName = deviceName, + locator = mapper.deserializeJsonGz(locator), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDtoDao.kt new file mode 100644 index 000000000..cd79b8b61 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDtoDao.kt @@ -0,0 +1,176 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressDto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressV2Dto +import org.gotson.komga.jooq.main.Tables +import org.jooq.AggregateFunction +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Record2 +import org.jooq.impl.DSL +import org.jooq.impl.DSL.rowNumber +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class ReadProgressDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + ReadProgressDtoRepository { + private val rlb = Tables.READLIST_BOOK + private val b = Tables.BOOK + private val d = Tables.BOOK_METADATA + private val r = Tables.READ_PROGRESS + + private val countUnread: AggregateFunction = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0)) + private val countRead: AggregateFunction = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0)) + private val countInProgress: AggregateFunction = DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0)) + + override fun findProgressV2BySeries( + seriesId: String, + userId: String, + ): TachiyomiReadProgressV2Dto { + val numberSortReadProgress = + dslRO + .select( + d.NUMBER_SORT, + r.COMPLETED, + ).from(b) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .where(b.SERIES_ID.eq(seriesId)) + .orderBy(d.NUMBER_SORT) + .fetch() + .toList() + + val maxNumberSort = + dslRO + .select(DSL.max(d.NUMBER_SORT)) + .from(b) + .leftJoin(d) + .on(b.ID.eq(d.BOOK_ID)) + .where(b.SERIES_ID.eq(seriesId)) + .fetchOne(DSL.max(d.NUMBER_SORT)) ?: 0F + + val booksCount = getSeriesBooksCount(seriesId, userId) + + return booksCountToDtoV2(booksCount, numberSortReadProgress.lastRead() ?: 0F, maxNumberSort) + } + + private fun getSeriesBooksCount( + seriesId: String, + userId: String, + ) = dslRO + .select(countUnread.`as`(BOOKS_UNREAD_COUNT)) + .select(countRead.`as`(BOOKS_READ_COUNT)) + .select(countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT)) + .from(b) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .where(b.SERIES_ID.eq(seriesId)) + .fetch() + .first() + .map { + BooksCount( + unreadCount = it.get(BOOKS_UNREAD_COUNT, Int::class.java), + readCount = it.get(BOOKS_READ_COUNT, Int::class.java), + inProgressCount = it.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java), + ) + } + + override fun findProgressByReadList( + readListId: String, + userId: String, + ): TachiyomiReadProgressDto { + val indexedReadProgress = + dslRO + .select( + rowNumber().over().orderBy(rlb.NUMBER), + r.COMPLETED, + ).from(b) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .leftJoin(rlb) + .on(b.ID.eq(rlb.BOOK_ID)) + .where(rlb.READLIST_ID.eq(readListId)) + .orderBy(rlb.NUMBER) + .fetch() + .toList() + + val booksCountRecord = + dslRO + .select(countUnread.`as`(BOOKS_UNREAD_COUNT)) + .select(countRead.`as`(BOOKS_READ_COUNT)) + .select(countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT)) + .from(b) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(readProgressCondition(userId)) + .leftJoin(rlb) + .on(b.ID.eq(rlb.BOOK_ID)) + .where(rlb.READLIST_ID.eq(readListId)) + .fetch() + .first() + + val booksCount = + BooksCount( + unreadCount = booksCountRecord.get(BOOKS_UNREAD_COUNT, Int::class.java), + readCount = booksCountRecord.get(BOOKS_READ_COUNT, Int::class.java), + inProgressCount = booksCountRecord.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java), + ) + + return booksCountToDto(booksCount, indexedReadProgress.lastRead() ?: 0) + } + + private fun booksCountToDto( + booksCount: BooksCount, + lastReadContinuousIndex: Int, + ): TachiyomiReadProgressDto = + TachiyomiReadProgressDto( + booksCount = booksCount.totalCount, + booksUnreadCount = booksCount.unreadCount, + booksInProgressCount = booksCount.inProgressCount, + booksReadCount = booksCount.readCount, + lastReadContinuousIndex = lastReadContinuousIndex, + ) + + private fun booksCountToDtoV2( + booksCount: BooksCount, + lastReadContinuousNumberSort: Float, + maxNumberSort: Float, + ): TachiyomiReadProgressV2Dto = + TachiyomiReadProgressV2Dto( + booksCount = booksCount.totalCount, + booksUnreadCount = booksCount.unreadCount, + booksInProgressCount = booksCount.inProgressCount, + booksReadCount = booksCount.readCount, + lastReadContinuousNumberSort = lastReadContinuousNumberSort, + maxNumberSort = maxNumberSort, + ) + + private fun readProgressCondition(userId: String): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull) + + private fun List>.lastRead(): T? = + this + .takeWhile { it.component2() == true } + .lastOrNull() + ?.component1() + + private data class BooksCount( + val unreadCount: Int, + val readCount: Int, + val inProgressCount: Int, + ) { + val totalCount + get() = unreadCount + readCount + inProgressCount + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt new file mode 100644 index 000000000..724fea916 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt @@ -0,0 +1,849 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.FilterBy +import org.gotson.komga.domain.model.FilterByEntity +import org.gotson.komga.domain.model.FilterTags +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.infrastructure.jooq.ContentRestrictionsSearchHelper +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.buildPage +import org.gotson.komga.infrastructure.jooq.udfStripAccents +import org.gotson.komga.infrastructure.jooq.unicode3 +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookMetadataAggregationAuthorRecord +import org.gotson.komga.jooq.main.tables.records.BookMetadataAuthorRecord +import org.gotson.komga.language.stripAccents +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.OrderField +import org.jooq.SelectFieldOrAsterisk +import org.jooq.TableField +import org.jooq.impl.DSL +import org.jooq.impl.DSL.select +import org.jooq.impl.TableImpl +import org.jooq.impl.TableRecordImpl +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.domain.Sort.Order +import org.springframework.stereotype.Component +import java.time.LocalDate + +@Component +class ReferentialDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + ReferentialRepository { + private val a = Tables.BOOK_METADATA_AUTHOR + private val sd = Tables.SERIES_METADATA + private val bma = Tables.BOOK_METADATA_AGGREGATION + private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR + private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG + private val s = Tables.SERIES + private val b = Tables.BOOK + private val g = Tables.SERIES_METADATA_GENRE + private val bt = Tables.BOOK_METADATA_TAG + private val st = Tables.SERIES_METADATA_TAG + private val cs = Tables.COLLECTION_SERIES + private val rb = Tables.READLIST_BOOK + private val sl = Tables.SERIES_METADATA_SHARING + private val at = Tables.SERIES_AND_BOOK_TAG + + @Deprecated("Use findAuthors instead") + override fun findAllAuthorsByName( + search: String, + filterOnLibraryIds: Collection?, + ): List = + dslRO + .selectDistinct(a.NAME, a.ROLE) + .from(a) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(a.BOOK_ID.eq(b.ID)) } } + .where(a.NAME.udfStripAccents().contains(search.stripAccents())) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(a.NAME.unicode3()) + .fetchInto(a) + .map { it.toDomain() } + + @Deprecated("Use findAuthors instead") + override fun findAllAuthorsByNameAndLibrary( + search: String, + libraryId: String, + filterOnLibraryIds: Collection?, + ): List = + dslRO + .selectDistinct(bmaa.NAME, bmaa.ROLE) + .from(bmaa) + .leftJoin(s) + .on(bmaa.SERIES_ID.eq(s.ID)) + .where(bmaa.NAME.udfStripAccents().contains(search.stripAccents())) + .and(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bmaa.NAME.unicode3()) + .fetchInto(bmaa) + .map { it.toDomain() } + + @Deprecated("Use findAuthors instead") + override fun findAllAuthorsByNameAndCollection( + search: String, + collectionId: String, + filterOnLibraryIds: Collection?, + ): List = + dslRO + .selectDistinct(bmaa.NAME, bmaa.ROLE) + .from(bmaa) + .leftJoin(cs) + .on(bmaa.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bmaa.SERIES_ID.eq(s.ID)) } } + .where(bmaa.NAME.udfStripAccents().contains(search.stripAccents())) + .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bmaa.NAME.unicode3()) + .fetchInto(bmaa) + .map { it.toDomain() } + + @Deprecated("Use findAuthors instead") + override fun findAllAuthorsByNameAndSeries( + search: String, + seriesId: String, + filterOnLibraryIds: Collection?, + ): List = + dslRO + .selectDistinct(bmaa.NAME, bmaa.ROLE) + .from(bmaa) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bmaa.SERIES_ID.eq(s.ID)) } } + .where(bmaa.NAME.udfStripAccents().contains(search.stripAccents())) + .and(bmaa.SERIES_ID.eq(seriesId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bmaa.NAME.unicode3()) + .fetchInto(bmaa) + .map { it.toDomain() } + + override fun findAuthors( + context: SearchContext, + search: String?, + role: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page = findGeneric(context, search, filterBy, pageable, a, a.NAME, null, a.BOOK_ID, { it?.toDomain() }, Sort.by("name"), listOf(a.ROLE), role?.let { a.ROLE.eq(role) }) + + override fun findAuthorsRoles( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page = findGeneric(context, null, filterBy, pageable, a, null, null, a.BOOK_ID, { it?.role }, Sort.by("role"), listOf(a.ROLE), sortField = a.ROLE) + + override fun findAuthorsNames( + context: SearchContext, + search: String?, + role: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page = findGeneric(context, search, filterBy, pageable, a, a.NAME, null, a.BOOK_ID, { it?.name }, Sort.by("name"), emptyList(), role?.let { a.ROLE.eq(role) }) + + @Deprecated("Use findAuthorsNames instead") + override fun findAllAuthorsNamesByName( + search: String, + filterOnLibraryIds: Collection?, + ): List = + dslRO + .selectDistinct(a.NAME) + .from(a) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(a.BOOK_ID.eq(b.ID)) } } + .where(a.NAME.udfStripAccents().contains(search.stripAccents())) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(a.NAME.unicode3()) + .fetch(a.NAME) + + @Deprecated("Use findAuthorsRoles instead") + override fun findAllAuthorsRoles(filterOnLibraryIds: Collection?): List = + dslRO + .selectDistinct(a.ROLE) + .from(a) + .apply { + filterOnLibraryIds?.let { + leftJoin(b) + .on(a.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(it)) + } + }.orderBy(a.ROLE) + .fetch(a.ROLE) + + @Deprecated("Use findGenres instead") + override fun findAllGenres(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(g.GENRE) + .from(g) + .apply { + filterOnLibraryIds?.let { + leftJoin(s) + .on(g.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + }.orderBy(g.GENRE.unicode3()) + .fetchSet(g.GENRE) + + @Deprecated("Use findGenres instead") + override fun findAllGenresByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(g.GENRE) + .from(g) + .leftJoin(s) + .on(g.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(g.GENRE.unicode3()) + .fetchSet(g.GENRE) + + @Deprecated("Use findGenres instead") + override fun findAllGenresByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(g.GENRE) + .from(g) + .leftJoin(cs) + .on(g.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(g.SERIES_ID.eq(s.ID)) } } + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(g.GENRE.unicode3()) + .fetchSet(g.GENRE) + + override fun findGenres( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + return findGeneric(context, search, filterBy, pageable, g, g.GENRE, g.SERIES_ID, null, { it?.genre }, Sort.by("genre")) + } + + @Deprecated("Use findTags instead") + override fun findAllSeriesAndBookTags(filterOnLibraryIds: Collection?): Set = + dslRO + .select(bt.TAG.`as`("tag")) + .from(bt) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(bt.BOOK_ID.eq(b.ID)).where(b.LIBRARY_ID.`in`(it)) } } + .union( + select(st.TAG.`as`("tag")) + .from(st) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(st.SERIES_ID.eq(s.ID)).where(s.LIBRARY_ID.`in`(it)) } }, + ).fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + + @Deprecated("Use findTags instead") + override fun findAllSeriesAndBookTagsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bt.TAG.`as`("tag")) + .from(bt) + .leftJoin(b) + .on(bt.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .union( + select(st.TAG.`as`("tag")) + .from(st) + .leftJoin(s) + .on(st.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }, + ).fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + + @Deprecated("Use findTags instead") + override fun findAllSeriesAndBookTagsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bmat.TAG.`as`("tag")) + .from(bmat) + .leftJoin(s) + .on(bmat.SERIES_ID.eq(s.ID)) + .leftJoin(cs) + .on(bmat.SERIES_ID.eq(cs.SERIES_ID)) + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .union( + select(st.TAG.`as`("tag")) + .from(st) + .leftJoin(cs) + .on(st.SERIES_ID.eq(cs.SERIES_ID)) + .leftJoin(s) + .on(st.SERIES_ID.eq(s.ID)) + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }, + ).fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + + @Deprecated("Use findTags instead") + override fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set = + dslRO + .select(st.TAG) + .from(st) + .apply { + filterOnLibraryIds?.let { + leftJoin(s) + .on(st.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + }.orderBy(st.TAG.unicode3()) + .fetchSet(st.TAG) + + @Deprecated("Use findTags instead") + override fun findAllSeriesTagsByLibrary( + libraryId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(st.TAG) + .from(st) + .leftJoin(s) + .on(st.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(st.TAG.unicode3()) + .fetchSet(st.TAG) + + @Deprecated("Use findTags instead") + override fun findAllBookTagsBySeries( + seriesId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bt.TAG) + .from(bt) + .leftJoin(b) + .on(bt.BOOK_ID.eq(b.ID)) + .where(b.SERIES_ID.eq(seriesId)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(bt.TAG.unicode3()) + .fetchSet(bt.TAG) + + @Deprecated("Use findTags instead") + override fun findAllBookTagsByReadList( + readListId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bt.TAG) + .from(bt) + .leftJoin(b) + .on(bt.BOOK_ID.eq(b.ID)) + .leftJoin(rb) + .on(bt.BOOK_ID.eq(rb.BOOK_ID)) + .where(rb.READLIST_ID.eq(readListId)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .orderBy(bt.TAG.unicode3()) + .fetchSet(bt.TAG) + + override fun findTags( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + filterTags: FilterTags, + pageable: Pageable, + ): Page = + when (filterTags) { + FilterTags.SERIES -> findGeneric(context, search, filterBy, pageable, st, st.TAG, st.SERIES_ID, null, { it?.tag }, Sort.by("tag")) + FilterTags.BOOK -> findGeneric(context, search, filterBy, pageable, bt, bt.TAG, null, bt.BOOK_ID, { it?.tag }, Sort.by("tag")) + FilterTags.BOTH -> findGeneric(context, search, filterBy, pageable, at, at.TAG, at.SERIES_ID, null, { it?.tag }, Sort.by("tag")) + } + + @Deprecated("Use findTags instead") + override fun findAllSeriesTagsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(st.TAG) + .from(st) + .leftJoin(cs) + .on(st.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(st.SERIES_ID.eq(s.ID)) } } + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(st.TAG.unicode3()) + .fetchSet(st.TAG) + + @Deprecated("Use findTags instead") + override fun findAllBookTags(filterOnLibraryIds: Collection?): Set = + dslRO + .select(bt.TAG) + .from(bt) + .apply { + filterOnLibraryIds?.let { + leftJoin(b) + .on(bt.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(it)) + } + }.orderBy(bt.TAG.unicode3()) + .fetchSet(bt.TAG) + + @Deprecated("Use findLanguages instead") + override fun findAllLanguages(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(sd.LANGUAGE) + .from(sd) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(sd.LANGUAGE.ne("")) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.LANGUAGE) + .fetchSet(sd.LANGUAGE) + + @Deprecated("Use findLanguages instead") + override fun findAllLanguagesByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.LANGUAGE) + .from(sd) + .leftJoin(s) + .on(sd.SERIES_ID.eq(s.ID)) + .where(sd.LANGUAGE.ne("")) + .and(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.LANGUAGE) + .fetchSet(sd.LANGUAGE) + + @Deprecated("Use findLanguages instead") + override fun findAllLanguagesByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.LANGUAGE) + .from(sd) + .leftJoin(cs) + .on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(sd.LANGUAGE.ne("")) + .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.LANGUAGE) + .fetchSet(sd.LANGUAGE) + + override fun findLanguages( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + return findGeneric(context, search, filterBy, pageable, sd, sd.LANGUAGE, sd.SERIES_ID, null, { it?.language }, Sort.by("language"), extraCondition = sd.LANGUAGE.ne("")) + } + + @Deprecated("Use findPublishers instead") + override fun findAllPublishers(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(sd.PUBLISHER) + .from(sd) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(sd.PUBLISHER.ne("")) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.PUBLISHER.unicode3()) + .fetchSet(sd.PUBLISHER) + + @Deprecated("Use findPublishers instead") + override fun findAllPublishers( + filterOnLibraryIds: Collection?, + pageable: Pageable, + ): Page { + val query = + dslRO + .selectDistinct(sd.PUBLISHER) + .from(sd) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(sd.PUBLISHER.ne("")) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + + val count = dslRO.fetchCount(query) + val sort = sd.PUBLISHER.unicode3() + + val items = + query + .orderBy(sort) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetch(sd.PUBLISHER) + + val pageSort = Sort.by("name") + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + @Deprecated("Use findPublishers instead") + override fun findAllPublishersByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.PUBLISHER) + .from(sd) + .leftJoin(s) + .on(sd.SERIES_ID.eq(s.ID)) + .where(sd.PUBLISHER.ne("")) + .and(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.PUBLISHER.unicode3()) + .fetchSet(sd.PUBLISHER) + + @Deprecated("Use findPublishers instead") + override fun findAllPublishersByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.PUBLISHER) + .from(sd) + .leftJoin(cs) + .on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(sd.PUBLISHER.ne("")) + .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.PUBLISHER.unicode3()) + .fetchSet(sd.PUBLISHER) + + override fun findPublishers( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + return findGeneric(context, search, filterBy, pageable, sd, sd.PUBLISHER, sd.SERIES_ID, null, { it?.publisher }, Sort.by("publisher"), extraCondition = sd.PUBLISHER.ne("")) + } + + @Deprecated("Use findAgeRatings instead") + override fun findAllAgeRatings(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(sd.AGE_RATING) + .from(sd) + .apply { + filterOnLibraryIds?.let { + leftJoin(s) + .on(sd.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + }.orderBy(sd.AGE_RATING) + .fetchSet(sd.AGE_RATING) + + @Deprecated("Use findAgeRatings instead") + override fun findAllAgeRatingsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.AGE_RATING) + .from(sd) + .leftJoin(s) + .on(sd.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.AGE_RATING) + .fetchSet(sd.AGE_RATING) + + @Deprecated("Use findAgeRatings instead") + override fun findAllAgeRatingsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sd.AGE_RATING) + .from(sd) + .leftJoin(cs) + .on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sd.AGE_RATING) + .fetchSet(sd.AGE_RATING) + + override fun findAgeRatings( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + return findGeneric(context, null, filterBy, pageable, sd, null, sd.SERIES_ID, null, { it?.ageRating }, Sort.by("ageRating"), listOf(sd.AGE_RATING), sortField = sd.AGE_RATING) + } + + @Deprecated("Use findSeriesReleaseDates instead") + override fun findAllSeriesReleaseDates(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(bma.RELEASE_DATE) + .from(bma) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) } } + .where(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bma.RELEASE_DATE.desc()) + .fetchSet(bma.RELEASE_DATE) + + @Deprecated("Use findSeriesReleaseDates instead") + override fun findAllSeriesReleaseDatesByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(bma.RELEASE_DATE) + .from(bma) + .leftJoin(s) + .on(bma.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(libraryIds)) + .and(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bma.RELEASE_DATE.desc()) + .fetchSet(bma.RELEASE_DATE) + + @Deprecated("Use findSeriesReleaseDates instead") + override fun findAllSeriesReleaseDatesByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(bma.RELEASE_DATE) + .from(bma) + .leftJoin(cs) + .on(bma.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) } } + .where(cs.COLLECTION_ID.eq(collectionId)) + .and(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(bma.RELEASE_DATE.desc()) + .fetchSet(bma.RELEASE_DATE) + + override fun findSeriesReleaseYears( + context: SearchContext, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + val sortField = bma.RELEASE_DATE.desc() + val restrictionCondition = ContentRestrictionsSearchHelper(context.restrictions).toCondition() + val query = + dslRO + .selectDistinct(DSL.year(bma.RELEASE_DATE)) + .from(bma) + .apply { + restrictionCondition.second.forEach { join -> + when (join) { + RequiredJoin.SeriesMetadata -> innerJoin(sd).on(bma.SERIES_ID.eq(sd.SERIES_ID)) + // shouldn't be required + RequiredJoin.BookMetadata -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.Collection -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadList -> Unit + is RequiredJoin.ReadProgress -> Unit + } + } + }.apply { if (!context.libraryIds.isNullOrEmpty() || filterBy?.type == FilterByEntity.LIBRARY) leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) } + .apply { if (filterBy?.type == FilterByEntity.COLLECTION) leftJoin(cs).on(bma.SERIES_ID.eq(cs.SERIES_ID)) } + .apply { + if (filterBy?.type == FilterByEntity.READLIST) + leftJoin(b) + .on(bma.SERIES_ID.eq(b.SERIES_ID)) + .leftJoin(rb) + .on(b.ID.eq(rb.BOOK_ID)) + }.where(restrictionCondition.first) + .apply { context.libraryIds?.let { this.and(s.LIBRARY_ID.`in`(it)) } } + .apply { + filterBy?.let { + when (it.type) { + FilterByEntity.LIBRARY -> this.and(s.LIBRARY_ID.`in`(it.ids)) + FilterByEntity.COLLECTION -> this.and(cs.COLLECTION_ID.`in`(it.ids)) + FilterByEntity.SERIES -> this.and(bma.SERIES_ID.`in`(it.ids)) + FilterByEntity.READLIST -> this.and(rb.READLIST_ID.`in`(it.ids)) + } + } + } + val count = dslRO.fetchCount(query) + val items = + query + .orderBy(sortField) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchArray(0) + .mapNotNull { it?.toString() } + return buildPage(items, pageable, count, Sort.by(Order.desc("year"))) + } + + @Deprecated("Use findSharingLabels instead") + override fun findAllSharingLabels(filterOnLibraryIds: Collection?): Set = + dslRO + .selectDistinct(sl.LABEL) + .from(sl) + .apply { + filterOnLibraryIds?.let { + leftJoin(s) + .on(sl.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + }.orderBy(sl.LABEL.unicode3()) + .fetchSet(sl.LABEL) + + @Deprecated("Use findSharingLabels instead") + override fun findAllSharingLabelsByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sl.LABEL) + .from(sl) + .leftJoin(s) + .on(sl.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sl.LABEL.unicode3()) + .fetchSet(sl.LABEL) + + @Deprecated("Use findSharingLabels instead") + override fun findAllSharingLabelsByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .selectDistinct(sl.LABEL) + .from(sl) + .leftJoin(cs) + .on(sl.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sl.SERIES_ID.eq(s.ID)) } } + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .orderBy(sl.LABEL.unicode3()) + .fetchSet(sl.LABEL) + + override fun findSharingLabels( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + ): Page { + filterBy?.let { require(it.type in setOf(FilterByEntity.LIBRARY, FilterByEntity.COLLECTION)) } + + return findGeneric(context, search, filterBy, pageable, sl, sl.LABEL, sl.SERIES_ID, null, { it?.label }, Sort.by("label")) + } + + private fun , T : TableImpl, O : Any> findGeneric( + context: SearchContext, + search: String?, + filterBy: FilterBy?, + pageable: Pageable, + table: T, + searchableField: TableField?, + seriesIdField: TableField<*, String>?, + bookIdField: TableField<*, String>?, + mapper: (R?) -> O?, + sort: Sort, + extraFields: List = emptyList(), + extraCondition: Condition? = DSL.noCondition(), + sortField: OrderField<*>? = null, + ): Page { + // depending on what is being searched, we may need to filter by series, or book, or both + // we need to have at least 1 of those 2 fields, and the other can be found by joining as necessary through the Book table + require(seriesIdField != null || bookIdField != null) + + val restrictionCondition = ContentRestrictionsSearchHelper(context.restrictions).toCondition() + + val seriesIdRequired = filterBy?.type in listOf(FilterByEntity.SERIES, FilterByEntity.COLLECTION, FilterByEntity.LIBRARY) || restrictionCondition.second.contains(RequiredJoin.SeriesMetadata) || !context.libraryIds.isNullOrEmpty() + val bookIdRequired = filterBy?.type == FilterByEntity.READLIST + val effectiveSeriesIdField = seriesIdField ?: b.SERIES_ID + val effectiveBookIdField = bookIdField ?: b.ID + + val query = + dslRO + .selectDistinct(*(listOfNotNull(searchableField) + extraFields).toTypedArray()) + .from(table) + .apply { if (seriesIdRequired && seriesIdField == null) innerJoin(b).on(bookIdField!!.eq(b.ID)) } + .apply { if (bookIdRequired && bookIdField == null) innerJoin(b).on(seriesIdField!!.eq(b.SERIES_ID)) } + .apply { + restrictionCondition.second.forEach { join -> + when (join) { + RequiredJoin.SeriesMetadata -> if (table != sd) innerJoin(sd).on(effectiveSeriesIdField.eq(sd.SERIES_ID)) + // shouldn't be required + RequiredJoin.BookMetadata -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.Collection -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadList -> Unit + is RequiredJoin.ReadProgress -> Unit + } + } + }.apply { if (!context.libraryIds.isNullOrEmpty() || filterBy?.type == FilterByEntity.LIBRARY) leftJoin(s).on(effectiveSeriesIdField.eq(s.ID)) } + .apply { if (filterBy?.type == FilterByEntity.COLLECTION) leftJoin(cs).on(effectiveSeriesIdField.eq(cs.SERIES_ID)) } + .apply { + if (filterBy?.type == FilterByEntity.READLIST) + leftJoin(rb) + .on(effectiveBookIdField.eq(rb.BOOK_ID)) + }.where(restrictionCondition.first) + .apply { extraCondition?.let { and(it) } } + .apply { if (search != null && searchableField != null) and(searchableField.udfStripAccents().contains(search.stripAccents())) } + .apply { context.libraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .apply { + filterBy?.let { + when (it.type) { + FilterByEntity.LIBRARY -> and(s.LIBRARY_ID.`in`(it.ids)) + FilterByEntity.COLLECTION -> and(cs.COLLECTION_ID.`in`(it.ids)) + FilterByEntity.SERIES -> and(effectiveSeriesIdField.`in`(it.ids)) + FilterByEntity.READLIST -> and(rb.READLIST_ID.`in`(it.ids)) + } + } + } + + val count = dslRO.fetchCount(query) + + val items = + query + .apply { + if (sortField != null) + orderBy(sortField) + else if (searchableField != null) + orderBy(searchableField.unicode3()) + }.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(table) + .mapNotNull { mapper(it) } + + return buildPage(items, pageable, count, sort) + } + + private fun BookMetadataAuthorRecord.toDomain(): Author = + Author( + name = name, + role = role, + ) + + private fun BookMetadataAggregationAuthorRecord.toDomain(): Author = + Author( + name = name, + role = role, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDao.kt new file mode 100644 index 000000000..fe47c3982 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDao.kt @@ -0,0 +1,303 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.inOrNoCondition +import org.gotson.komga.infrastructure.jooq.sortByValues +import org.gotson.komga.infrastructure.jooq.toCondition +import org.gotson.komga.infrastructure.jooq.toSortField +import org.gotson.komga.infrastructure.jooq.unicode3 +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.gotson.komga.infrastructure.search.LuceneHelper +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.CollectionRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SeriesCollectionDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val luceneHelper: LuceneHelper, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + SeriesCollectionRepository { + private val c = Tables.COLLECTION + private val cs = Tables.COLLECTION_SERIES + private val s = Tables.SERIES + private val sd = Tables.SERIES_METADATA + + private val sorts = + mapOf( + "name" to c.NAME.unicode3(), + ) + + override fun findByIdOrNull( + collectionId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): SeriesCollection? = + dslRO + .selectBase(restrictions.isRestricted) + .where(c.ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + .firstOrNull() + + override fun findAll( + belongsToLibraryIds: Collection?, + filterOnLibraryIds: Collection?, + search: String?, + pageable: Pageable, + restrictions: ContentRestrictions, + ): Page { + val collectionIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.Collection) + val searchCondition = c.ID.inOrNoCondition(collectionIds) + + val conditions = + searchCondition + .and(s.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds)) + .and(s.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds)) + .and(restrictions.toCondition()) + + val queryIds = + if (belongsToLibraryIds == null && filterOnLibraryIds == null && !restrictions.isRestricted) + null + else + dslRO + .selectDistinct(c.ID) + .from(c) + .leftJoin(cs) + .on(c.ID.eq(cs.COLLECTION_ID)) + .leftJoin(s) + .on(cs.SERIES_ID.eq(s.ID)) + .leftJoin(sd) + .on(cs.SERIES_ID.eq(sd.SERIES_ID)) + .where(conditions) + + val count = + if (queryIds != null) + dslRO.fetchCount(queryIds) + else + dslRO.fetchCount(c, searchCondition) + + val orderBy = + pageable.sort.mapNotNull { + if (it.property == "relevance" && !collectionIds.isNullOrEmpty()) + c.ID.sortByValues(collectionIds, it.isAscending) + else + it.toSortField(sorts) + } + + val items = + dslRO + .selectBase(restrictions.isRestricted) + .where(conditions) + .apply { if (queryIds != null) and(c.ID.`in`(queryIds)) } + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + override fun findAllContainingSeriesId( + containsSeriesId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions, + ): Collection { + val queryIds = + dslRO + .select(c.ID) + .from(c) + .leftJoin(cs) + .on(c.ID.eq(cs.COLLECTION_ID)) + .apply { if (restrictions.isRestricted) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } + .where(cs.SERIES_ID.eq(containsSeriesId)) + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + + return dslRO + .selectBase(restrictions.isRestricted) + .where(c.ID.`in`(queryIds)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .fetchAndMap(dslRO, filterOnLibraryIds, restrictions) + } + + override fun findAllEmpty(): Collection = + dslRO + .selectFrom(c) + .where( + c.ID.`in`( + dslRO + .select(c.ID) + .from(c) + .leftJoin(cs) + .on(c.ID.eq(cs.COLLECTION_ID)) + .where(cs.COLLECTION_ID.isNull), + ), + ).fetchInto(c) + .map { it.toDomain(emptyList()) } + + override fun findByNameOrNull(name: String): SeriesCollection? = + dslRO + .selectBase() + .where(c.NAME.equalIgnoreCase(name)) + .fetchAndMap(dslRO, null) + .firstOrNull() + + private fun DSLContext.selectBase(joinOnSeriesMetadata: Boolean = false) = + this + .selectDistinct(*c.fields()) + .from(c) + .leftJoin(cs) + .on(c.ID.eq(cs.COLLECTION_ID)) + .leftJoin(s) + .on(cs.SERIES_ID.eq(s.ID)) + .apply { if (joinOnSeriesMetadata) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } + + private fun ResultQuery.fetchAndMap( + dsl: DSLContext, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): List = + fetchInto(c) + .map { cr -> + val seriesIds = + dsl + .select(*cs.fields()) + .from(cs) + .leftJoin(s) + .on(cs.SERIES_ID.eq(s.ID)) + .apply { if (restrictions.isRestricted) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } + .where(cs.COLLECTION_ID.eq(cr.id)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isRestricted) and(restrictions.toCondition()) } + .orderBy(cs.NUMBER.asc()) + .fetchInto(cs) + .mapNotNull { it.seriesId } + cr.toDomain(seriesIds) + } + + @Transactional + override fun insert(collection: SeriesCollection) { + dslRW + .insertInto(c) + .set(c.ID, collection.id) + .set(c.NAME, collection.name) + .set(c.ORDERED, collection.ordered) + .set(c.SERIES_COUNT, collection.seriesIds.size) + .execute() + + dslRW.insertSeries(collection) + } + + private fun DSLContext.insertSeries(collection: SeriesCollection) { + collection.seriesIds.forEachIndexed { index, id -> + this + .insertInto(cs) + .set(cs.COLLECTION_ID, collection.id) + .set(cs.SERIES_ID, id) + .set(cs.NUMBER, index) + .execute() + } + } + + @Transactional + override fun update(collection: SeriesCollection) { + dslRW + .update(c) + .set(c.NAME, collection.name) + .set(c.ORDERED, collection.ordered) + .set(c.SERIES_COUNT, collection.seriesIds.size) + .set(c.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(c.ID.eq(collection.id)) + .execute() + + dslRW.deleteFrom(cs).where(cs.COLLECTION_ID.eq(collection.id)).execute() + + dslRW.insertSeries(collection) + } + + @Transactional + override fun removeSeriesFromAll(seriesId: String) { + dslRW + .deleteFrom(cs) + .where(cs.SERIES_ID.eq(seriesId)) + .execute() + } + + @Transactional + override fun removeSeriesFromAll(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW + .deleteFrom(cs) + .where(cs.SERIES_ID.`in`(it.selectTempStrings())) + .execute() + } + } + + @Transactional + override fun delete(collectionId: String) { + dslRW.deleteFrom(cs).where(cs.COLLECTION_ID.eq(collectionId)).execute() + dslRW.deleteFrom(c).where(c.ID.eq(collectionId)).execute() + } + + @Transactional + override fun delete(collectionIds: Collection) { + dslRW.deleteFrom(cs).where(cs.COLLECTION_ID.`in`(collectionIds)).execute() + dslRW.deleteFrom(c).where(c.ID.`in`(collectionIds)).execute() + } + + @Transactional + override fun deleteAll() { + dslRW.deleteFrom(cs).execute() + dslRW.deleteFrom(c).execute() + } + + override fun existsByName(name: String): Boolean = + dslRO.fetchExists( + dslRO + .selectFrom(c) + .where(c.NAME.equalIgnoreCase(name)), + ) + + override fun count(): Long = dslRO.fetchCount(c).toLong() + + private fun CollectionRecord.toDomain(seriesIds: List) = + SeriesCollection( + name = name, + ordered = ordered, + seriesIds = seriesIds, + id = id, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + filtered = seriesCount != seriesIds.size, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDao.kt new file mode 100644 index 000000000..07097304d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDao.kt @@ -0,0 +1,230 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SeriesSearchHelper +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.csAlias +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.SeriesRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort.unsorted +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SeriesDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + SeriesRepository { + private val s = Tables.SERIES + private val d = Tables.SERIES_METADATA + private val rs = Tables.READ_PROGRESS_SERIES + private val bma = Tables.BOOK_METADATA_AGGREGATION + + override fun findAll(): Collection = + dslRO + .selectFrom(s) + .fetchInto(s) + .map { it.toDomain() } + + override fun findByIdOrNull(seriesId: String): Series? = + dslRO + .selectFrom(s) + .where(s.ID.eq(seriesId)) + .fetchOneInto(s) + ?.toDomain() + + override fun findAllByLibraryId(libraryId: String): List = + dslRO + .selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId)) + .fetchInto(s) + .map { it.toDomain() } + + @Transactional + override fun findAllNotDeletedByLibraryIdAndUrlNotIn( + libraryId: String, + urls: Collection, + ): List { + dslRO.withTempTable(batchSize, urls.map { it.toString() }).use { tempTable -> + return dslRO + .selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId)) + .and(s.DELETED_DATE.isNull) + .and(s.URL.notIn(tempTable.selectTempStrings())) + .fetchInto(s) + .map { it.toDomain() } + } + } + + override fun findNotDeletedByLibraryIdAndUrlOrNull( + libraryId: String, + url: URL, + ): Series? = + dslRO + .selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId).and(s.URL.eq(url.toString()))) + .and(s.DELETED_DATE.isNull) + .orderBy(s.LAST_MODIFIED_DATE.desc()) + .fetchInto(s) + .firstOrNull() + ?.toDomain() + + override fun findAllByTitleContaining(title: String): Collection = + dslRO + .selectDistinct(*s.fields()) + .from(s) + .leftJoin(d) + .on(s.ID.eq(d.SERIES_ID)) + .where(d.TITLE.containsIgnoreCase(title)) + .fetchInto(s) + .map { it.toDomain() } + + override fun getLibraryId(seriesId: String): String? = + dslRO + .select(s.LIBRARY_ID) + .from(s) + .where(s.ID.eq(seriesId)) + .fetchOne(0, String::class.java) + + override fun findAllIdsByLibraryId(libraryId: String): Collection = + dslRO + .select(s.ID) + .from(s) + .where(s.LIBRARY_ID.eq(libraryId)) + .fetch(s.ID) + + override fun findAll( + searchCondition: SearchCondition.Series?, + searchContext: SearchContext, + pageable: Pageable, + ): Page { + val (conditions, joins) = SeriesSearchHelper(searchContext).toCondition(searchCondition) + + val query = + dslRO + .selectDistinct(*s.fields()) + .from(s) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.Collection -> { + val csAlias = csAlias(join.collectionId) + leftJoin(csAlias).on(s.ID.eq(csAlias.SERIES_ID).and(csAlias.COLLECTION_ID.eq(join.collectionId))) + } + RequiredJoin.BookMetadataAggregation -> leftJoin(bma).on(s.ID.eq(bma.SERIES_ID)) + RequiredJoin.SeriesMetadata -> innerJoin(d).on(s.ID.eq(d.SERIES_ID)) + is RequiredJoin.ReadProgress -> leftJoin(rs).on(rs.SERIES_ID.eq(s.ID)).and(rs.USER_ID.eq(join.userId)) + // Book joins - not needed + RequiredJoin.BookMetadata -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadList -> Unit + } + } + }.where(conditions) + + val count = dslRO.fetchCount(query) + + val items = + query + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(s) + .map { it.toDomain() } + + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, unsorted()) + else + PageRequest.of(0, maxOf(count, 20), unsorted()), + count.toLong(), + ) + } + + override fun insert(series: Series) { + dslRW + .insertInto(s) + .set(s.ID, series.id) + .set(s.NAME, series.name) + .set(s.URL, series.url.toString()) + .set(s.FILE_LAST_MODIFIED, series.fileLastModified) + .set(s.LIBRARY_ID, series.libraryId) + .set(s.DELETED_DATE, series.deletedDate) + .set(s.ONESHOT, series.oneshot) + .execute() + } + + override fun update( + series: Series, + updateModifiedTime: Boolean, + ) { + dslRW + .update(s) + .set(s.NAME, series.name) + .set(s.URL, series.url.toString()) + .set(s.FILE_LAST_MODIFIED, series.fileLastModified) + .set(s.LIBRARY_ID, series.libraryId) + .set(s.BOOK_COUNT, series.bookCount) + .set(s.DELETED_DATE, series.deletedDate) + .set(s.ONESHOT, series.oneshot) + .apply { if (updateModifiedTime) set(s.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) } + .where(s.ID.eq(series.id)) + .execute() + } + + override fun delete(seriesId: String) { + dslRW.deleteFrom(s).where(s.ID.eq(seriesId)).execute() + } + + override fun deleteAll() { + dslRW.deleteFrom(s).execute() + } + + @Transactional + override fun delete(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW.deleteFrom(s).where(s.ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun count(): Long = dslRO.fetchCount(s).toLong() + + override fun countGroupedByLibraryId(): Map = + dslRO + .select(s.LIBRARY_ID, DSL.count(s.ID)) + .from(s) + .groupBy(s.LIBRARY_ID) + .fetchMap(s.LIBRARY_ID, DSL.count(s.ID)) + + private fun SeriesRecord.toDomain() = + Series( + name = name, + url = URL(url), + fileLastModified = fileLastModified, + id = id, + libraryId = libraryId, + bookCount = bookCount, + deletedDate = deletedDate, + oneshot = oneshot, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt new file mode 100644 index 000000000..c5df5ef4d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt @@ -0,0 +1,463 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchField +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SeriesSearchHelper +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.infrastructure.jooq.csAlias +import org.gotson.komga.infrastructure.jooq.inOrNoCondition +import org.gotson.komga.infrastructure.jooq.sortByValues +import org.gotson.komga.infrastructure.jooq.toSortField +import org.gotson.komga.infrastructure.jooq.unicode3 +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.gotson.komga.infrastructure.search.LuceneHelper +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.AlternateTitleDto +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.BookMetadataAggregationDto +import org.gotson.komga.interfaces.api.rest.dto.GroupCountDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesMetadataDto +import org.gotson.komga.interfaces.api.rest.dto.WebLinkDto +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.BookMetadataAggregationRecord +import org.gotson.komga.jooq.main.tables.records.SeriesMetadataRecord +import org.gotson.komga.jooq.main.tables.records.SeriesRecord +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.jooq.SelectOnConditionStep +import org.jooq.impl.DSL +import org.jooq.impl.DSL.count +import org.jooq.impl.DSL.countDistinct +import org.jooq.impl.DSL.lower +import org.jooq.impl.DSL.substring +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import java.net.URL + +const val BOOKS_UNREAD_COUNT = "booksUnreadCount" +const val BOOKS_IN_PROGRESS_COUNT = "booksInProgressCount" +const val BOOKS_READ_COUNT = "booksReadCount" + +@Component +class SeriesDtoDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val luceneHelper: LuceneHelper, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + SeriesDtoRepository { + private val s = Tables.SERIES + private val d = Tables.SERIES_METADATA + private val rs = Tables.READ_PROGRESS_SERIES + private val cs = Tables.COLLECTION_SERIES + private val g = Tables.SERIES_METADATA_GENRE + private val st = Tables.SERIES_METADATA_TAG + private val sl = Tables.SERIES_METADATA_SHARING + private val slk = Tables.SERIES_METADATA_LINK + private val sat = Tables.SERIES_METADATA_ALTERNATE_TITLE + private val bma = Tables.BOOK_METADATA_AGGREGATION + private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR + private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG + + private val groupFields = + arrayOf( + *s.fields(), + *d.fields(), + *bma.fields(), + *rs.fields(), + ) + + private val sorts = + mapOf( + "metadata.titleSort" to d.TITLE_SORT.unicode3(), + "createdDate" to s.CREATED_DATE, + "created" to s.CREATED_DATE, + "lastModifiedDate" to s.LAST_MODIFIED_DATE, + "lastModified" to s.LAST_MODIFIED_DATE, + "booksMetadata.releaseDate" to bma.RELEASE_DATE, + "readDate" to rs.MOST_RECENT_READ_DATE, + "collection.number" to cs.NUMBER, + "name" to s.NAME.unicode3(), + "booksCount" to s.BOOK_COUNT, + "random" to DSL.rand(), + ) + + override fun findAll(pageable: Pageable): Page = findAll(SeriesSearch(), SearchContext.ofAnonymousUser(), pageable) + + override fun findAll( + context: SearchContext, + pageable: Pageable, + ): Page = findAll(SeriesSearch(), context, pageable) + + override fun findAll( + search: SeriesSearch, + context: SearchContext, + pageable: Pageable, + ): Page { + requireNotNull(context.userId) { "Missing userId in search context" } + + val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition) + val conditionsRefined = conditions.and(search.regexSearch?.let { it.second.toColumn().likeRegex(it.first) } ?: DSL.noCondition()) + + return findAll(conditionsRefined, context.userId, pageable, joins, search.fullTextSearch) + } + + override fun findAllRecentlyUpdated( + search: SeriesSearch, + context: SearchContext, + pageable: Pageable, + ): Page { + requireNotNull(context.userId) { "Missing userId in search context" } + + val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition) + val conditionsRefined = conditions.and(s.CREATED_DATE.notEqual(s.LAST_MODIFIED_DATE)) + + return findAll(conditionsRefined, context.userId, pageable, joins, search.fullTextSearch) + } + + override fun countByFirstCharacter( + search: SeriesSearch, + context: SearchContext, + ): List { + requireNotNull(context.userId) { "Missing userId in search context" } + + val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition) + val conditionsRefined = conditions.and(search.regexSearch?.let { it.second.toColumn().likeRegex(it.first) } ?: DSL.noCondition()) + + val seriesIds = luceneHelper.searchEntitiesIds(search.fullTextSearch, LuceneEntity.Series) + val searchCondition = s.ID.inOrNoCondition(seriesIds) + + val firstChar = lower(substring(d.TITLE_SORT, 1, 1)) + return dslRO + .select(firstChar, count()) + .from(s) + .leftJoin(d) + .on(s.ID.eq(d.SERIES_ID)) + .leftJoin(bma) + .on(s.ID.eq(bma.SERIES_ID)) + .leftJoin(rs) + .on(s.ID.eq(rs.SERIES_ID)) + .and(readProgressConditionSeries(context.userId)) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.Collection -> { + val csAlias = csAlias(join.collectionId) + leftJoin(csAlias).on(s.ID.eq(csAlias.SERIES_ID).and(csAlias.COLLECTION_ID.eq(join.collectionId))) + } + // always joined + is RequiredJoin.ReadProgress -> Unit + RequiredJoin.SeriesMetadata -> Unit + // Book joins - not needed + RequiredJoin.Media -> Unit + RequiredJoin.BookMetadata -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.ReadList -> Unit + } + } + }.where(conditionsRefined) + .and(searchCondition) + .groupBy(firstChar) + .map { + GroupCountDto(it.value1(), it.value2()) + } + } + + override fun findByIdOrNull( + seriesId: String, + userId: String, + ): SeriesDto? = + dslRO + .selectBase(userId) + .where(s.ID.eq(seriesId)) + .groupBy(*groupFields) + .fetchAndMap(dslRO) + .firstOrNull() + + private fun DSLContext.selectBase( + userId: String, + joins: Set = emptySet(), + ): SelectOnConditionStep = + this + .select(*groupFields) + .from(s) + .leftJoin(d) + .on(s.ID.eq(d.SERIES_ID)) + .leftJoin(bma) + .on(s.ID.eq(bma.SERIES_ID)) + .leftJoin(rs) + .on(s.ID.eq(rs.SERIES_ID)) + .and(readProgressConditionSeries(userId)) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.Collection -> { + val csAlias = csAlias(join.collectionId) + leftJoin(csAlias).on(s.ID.eq(csAlias.SERIES_ID).and(csAlias.COLLECTION_ID.eq(join.collectionId))) + } + // always joined + is RequiredJoin.ReadProgress -> Unit + RequiredJoin.SeriesMetadata -> Unit + // Book joins - not needed + RequiredJoin.BookMetadata -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadList -> Unit + } + } + } + + private fun findAll( + conditions: Condition, + userId: String, + pageable: Pageable, + joins: Set = emptySet(), + searchTerm: String? = null, + ): Page { + val seriesIds = luceneHelper.searchEntitiesIds(searchTerm, LuceneEntity.Series) + val searchCondition = s.ID.inOrNoCondition(seriesIds) + + val count = + dslRO + .select(countDistinct(s.ID)) + .from(s) + .leftJoin(d) + .on(s.ID.eq(d.SERIES_ID)) + .leftJoin(bma) + .on(s.ID.eq(bma.SERIES_ID)) + .leftJoin(rs) + .on(s.ID.eq(rs.SERIES_ID)) + .and(readProgressConditionSeries(userId)) + .apply { + joins.forEach { join -> + when (join) { + is RequiredJoin.Collection -> { + val csAlias = csAlias(join.collectionId) + leftJoin(csAlias).on(s.ID.eq(csAlias.SERIES_ID).and(csAlias.COLLECTION_ID.eq(join.collectionId))) + } + // always joined + is RequiredJoin.ReadProgress -> Unit + RequiredJoin.SeriesMetadata -> Unit + // Book joins - not needed + RequiredJoin.BookMetadata -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadList -> Unit + } + } + }.where(conditions) + .and(searchCondition) + .fetchOne(countDistinct(s.ID)) ?: 0 + + val orderBy = + pageable.sort.mapNotNull { + if (it.property == "relevance" && !seriesIds.isNullOrEmpty()) { + s.ID.sortByValues(seriesIds, it.isAscending) + } else { + if (it.property == "collection.number") { + val collectionId = joins.filterIsInstance().firstOrNull()?.collectionId ?: return@mapNotNull null + val f = csAlias(collectionId).NUMBER + if (it.isAscending) f.asc() else f.desc() + } else { + it.toSortField(sorts) + } + } + } + + val dtos = + dslRO + .selectBase(userId, joins) + .where(conditions) + .and(searchCondition) + .orderBy(orderBy) + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchAndMap(dslRO) + + val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() + return PageImpl( + dtos, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) + else + PageRequest.of(0, maxOf(count, 20), pageSort), + count.toLong(), + ) + } + + private fun readProgressConditionSeries(userId: String): Condition = rs.USER_ID.eq(userId).or(rs.USER_ID.isNull) + + private fun ResultQuery.fetchAndMap(dsl: DSLContext): MutableList { + val records = fetch() + val seriesIds = records.getValues(s.ID) + + lateinit var genres: Map> + lateinit var tags: Map> + lateinit var sharingLabels: Map> + lateinit var links: Map> + lateinit var alternateTitles: Map> + lateinit var aggregatedAuthors: Map> + lateinit var aggregatedTags: Map> + + dsl.withTempTable(batchSize, seriesIds).use { tempTable -> + genres = + dsl + .selectFrom(g) + .where(g.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { it.genre }) + + tags = + dsl + .selectFrom(st) + .where(st.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { it.tag }) + + sharingLabels = + dsl + .selectFrom(sl) + .where(sl.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { it.label }) + + links = + dsl + .selectFrom(slk) + .where(slk.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { WebLinkDto(it.label, it.url) }) + + alternateTitles = + dsl + .selectFrom(sat) + .where(sat.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { AlternateTitleDto(it.label, it.title) }) + + aggregatedAuthors = + dsl + .selectFrom(bmaa) + .where(bmaa.SERIES_ID.`in`(tempTable.selectTempStrings())) + .filter { it.name != null } + .groupBy({ it.seriesId }, { AuthorDto(it.name, it.role) }) + + aggregatedTags = + dsl + .selectFrom(bmat) + .where(bmat.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { it.tag }) + } + + return records + .map { rec -> + val sr = rec.into(s) + val dr = rec.into(d) + val bmar = rec.into(bma) + val rsr = rec.into(rs) + val booksReadCount = rsr.readCount ?: 0 + val booksInProgressCount = rsr.inProgressCount ?: 0 + val booksUnreadCount = sr.bookCount - booksReadCount - booksInProgressCount + + sr.toDto( + sr.bookCount, + booksReadCount, + booksUnreadCount, + booksInProgressCount, + dr.toDto(genres[sr.id].orEmpty().toSet(), tags[sr.id].orEmpty().toSet(), sharingLabels[sr.id].orEmpty().toSet(), links[sr.id].orEmpty(), alternateTitles[sr.id].orEmpty()), + bmar.toDto(aggregatedAuthors[sr.id].orEmpty(), aggregatedTags[sr.id].orEmpty().toSet()), + ) + } + } + + private fun SearchField.toColumn() = + when (this) { + SearchField.TITLE -> d.TITLE + SearchField.TITLE_SORT -> d.TITLE_SORT + } + + private fun SeriesRecord.toDto( + booksCount: Int, + booksReadCount: Int, + booksUnreadCount: Int, + booksInProgressCount: Int, + metadata: SeriesMetadataDto, + booksMetadata: BookMetadataAggregationDto, + ) = SeriesDto( + id = id, + libraryId = libraryId, + name = name, + url = URL(url).toFilePath(), + created = createdDate, + lastModified = lastModifiedDate, + fileLastModified = fileLastModified, + booksCount = booksCount, + booksReadCount = booksReadCount, + booksUnreadCount = booksUnreadCount, + booksInProgressCount = booksInProgressCount, + metadata = metadata, + booksMetadata = booksMetadata, + deleted = deletedDate != null, + oneshot = oneshot, + ) + + private fun SeriesMetadataRecord.toDto( + genres: Set, + tags: Set, + sharingLabels: Set, + links: List, + alternateTitles: List, + ) = SeriesMetadataDto( + status = status, + statusLock = statusLock, + created = createdDate, + lastModified = lastModifiedDate, + title = title, + titleLock = titleLock, + titleSort = titleSort, + titleSortLock = titleSortLock, + summary = summary, + summaryLock = summaryLock, + readingDirection = readingDirection ?: "", + readingDirectionLock = readingDirectionLock, + publisher = publisher, + publisherLock = publisherLock, + ageRating = ageRating, + ageRatingLock = ageRatingLock, + language = language, + languageLock = languageLock, + genres = genres, + genresLock = genresLock, + tags = tags, + tagsLock = tagsLock, + totalBookCount = totalBookCount, + totalBookCountLock = totalBookCountLock, + sharingLabels = sharingLabels, + sharingLabelsLock = sharingLabelsLock, + links = links, + linksLock = linksLock, + alternateTitles = alternateTitles, + alternateTitlesLock = alternateTitlesLock, + ) + + private fun BookMetadataAggregationRecord.toDto( + authors: List, + tags: Set, + ) = BookMetadataAggregationDto( + authors = authors, + tags = tags, + releaseDate = releaseDate, + summary = summary, + summaryNumber = summaryNumber, + created = createdDate, + lastModified = lastModifiedDate, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDao.kt new file mode 100644 index 000000000..e0c041a0d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDao.kt @@ -0,0 +1,333 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.AlternateTitle +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.SeriesMetadataRecord +import org.gotson.komga.language.toCurrentTimeZone +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URI +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SeriesMetadataDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + SeriesMetadataRepository { + private val d = Tables.SERIES_METADATA + private val g = Tables.SERIES_METADATA_GENRE + private val st = Tables.SERIES_METADATA_TAG + private val sl = Tables.SERIES_METADATA_SHARING + private val slk = Tables.SERIES_METADATA_LINK + private val sat = Tables.SERIES_METADATA_ALTERNATE_TITLE + + override fun findById(seriesId: String): SeriesMetadata = dslRO.findOne(seriesId)!!.toDomain(dslRO.findGenres(seriesId), dslRO.findTags(seriesId), dslRO.findSharingLabels(seriesId), dslRO.findLinks(seriesId), dslRO.findAlternateTitles(seriesId)) + + override fun findByIdOrNull(seriesId: String): SeriesMetadata? = dslRO.findOne(seriesId)?.toDomain(dslRO.findGenres(seriesId), dslRO.findTags(seriesId), dslRO.findSharingLabels(seriesId), dslRO.findLinks(seriesId), dslRO.findAlternateTitles(seriesId)) + + private fun DSLContext.findOne(seriesId: String) = + this + .selectFrom(d) + .where(d.SERIES_ID.eq(seriesId)) + .fetchOneInto(d) + + private fun DSLContext.findGenres(seriesId: String) = + this + .select(g.GENRE) + .from(g) + .where(g.SERIES_ID.eq(seriesId)) + .fetchSet(g.GENRE) + + private fun DSLContext.findTags(seriesId: String) = + this + .select(st.TAG) + .from(st) + .where(st.SERIES_ID.eq(seriesId)) + .fetchSet(st.TAG) + + private fun DSLContext.findSharingLabels(seriesId: String) = + this + .select(sl.LABEL) + .from(sl) + .where(sl.SERIES_ID.eq(seriesId)) + .fetchSet(sl.LABEL) + + private fun DSLContext.findLinks(seriesId: String) = + this + .select(slk.LABEL, slk.URL) + .from(slk) + .where(slk.SERIES_ID.eq(seriesId)) + .fetchInto(slk) + .map { WebLink(it.label, URI(it.url)) } + + private fun DSLContext.findAlternateTitles(seriesId: String) = + this + .select(sat.LABEL, sat.TITLE) + .from(sat) + .where(sat.SERIES_ID.eq(seriesId)) + .fetchInto(sat) + .map { AlternateTitle(it.label, it.title) } + + @Transactional + override fun insert(metadata: SeriesMetadata) { + dslRW + .insertInto(d) + .set(d.SERIES_ID, metadata.seriesId) + .set(d.STATUS, metadata.status.toString()) + .set(d.TITLE, metadata.title) + .set(d.TITLE_SORT, metadata.titleSort) + .set(d.SUMMARY, metadata.summary) + .set(d.READING_DIRECTION, metadata.readingDirection?.toString()) + .set(d.PUBLISHER, metadata.publisher) + .set(d.AGE_RATING, metadata.ageRating) + .set(d.LANGUAGE, metadata.language) + .set(d.STATUS_LOCK, metadata.statusLock) + .set(d.TITLE_LOCK, metadata.titleLock) + .set(d.TITLE_SORT_LOCK, metadata.titleSortLock) + .set(d.SUMMARY_LOCK, metadata.summaryLock) + .set(d.READING_DIRECTION_LOCK, metadata.readingDirectionLock) + .set(d.PUBLISHER_LOCK, metadata.publisherLock) + .set(d.AGE_RATING_LOCK, metadata.ageRatingLock) + .set(d.LANGUAGE_LOCK, metadata.languageLock) + .set(d.GENRES_LOCK, metadata.genresLock) + .set(d.TAGS_LOCK, metadata.tagsLock) + .set(d.TOTAL_BOOK_COUNT, metadata.totalBookCount) + .set(d.TOTAL_BOOK_COUNT_LOCK, metadata.totalBookCountLock) + .set(d.SHARING_LABELS_LOCK, metadata.sharingLabelsLock) + .set(d.LINKS_LOCK, metadata.linksLock) + .set(d.ALTERNATE_TITLES_LOCK, metadata.alternateTitlesLock) + .execute() + + dslRW.insertGenres(metadata) + dslRW.insertTags(metadata) + dslRW.insertSharingLabels(metadata) + dslRW.insertLinks(metadata) + dslRW.insertAlternateTitles(metadata) + } + + @Transactional + override fun update(metadata: SeriesMetadata) { + dslRW + .update(d) + .set(d.STATUS, metadata.status.toString()) + .set(d.TITLE, metadata.title) + .set(d.TITLE_SORT, metadata.titleSort) + .set(d.SUMMARY, metadata.summary) + .set(d.READING_DIRECTION, metadata.readingDirection?.toString()) + .set(d.PUBLISHER, metadata.publisher) + .set(d.AGE_RATING, metadata.ageRating) + .set(d.LANGUAGE, metadata.language) + .set(d.STATUS_LOCK, metadata.statusLock) + .set(d.TITLE_LOCK, metadata.titleLock) + .set(d.TITLE_SORT_LOCK, metadata.titleSortLock) + .set(d.SUMMARY_LOCK, metadata.summaryLock) + .set(d.READING_DIRECTION_LOCK, metadata.readingDirectionLock) + .set(d.PUBLISHER_LOCK, metadata.publisherLock) + .set(d.AGE_RATING_LOCK, metadata.ageRatingLock) + .set(d.LANGUAGE_LOCK, metadata.languageLock) + .set(d.GENRES_LOCK, metadata.genresLock) + .set(d.TAGS_LOCK, metadata.tagsLock) + .set(d.TOTAL_BOOK_COUNT, metadata.totalBookCount) + .set(d.TOTAL_BOOK_COUNT_LOCK, metadata.totalBookCountLock) + .set(d.SHARING_LABELS_LOCK, metadata.sharingLabelsLock) + .set(d.LINKS_LOCK, metadata.linksLock) + .set(d.ALTERNATE_TITLES_LOCK, metadata.alternateTitlesLock) + .set(d.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .where(d.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(g) + .where(g.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(st) + .where(st.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(sl) + .where(sl.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(slk) + .where(slk.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW + .deleteFrom(sat) + .where(sat.SERIES_ID.eq(metadata.seriesId)) + .execute() + + dslRW.insertGenres(metadata) + dslRW.insertTags(metadata) + dslRW.insertSharingLabels(metadata) + dslRW.insertLinks(metadata) + dslRW.insertAlternateTitles(metadata) + } + + private fun DSLContext.insertGenres(metadata: SeriesMetadata) { + if (metadata.genres.isNotEmpty()) { + metadata.genres.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(g, g.SERIES_ID, g.GENRE) + .values(null as String?, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it) + } + }.execute() + } + } + } + + private fun DSLContext.insertTags(metadata: SeriesMetadata) { + if (metadata.tags.isNotEmpty()) { + metadata.tags.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(st, st.SERIES_ID, st.TAG) + .values(null as String?, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it) + } + }.execute() + } + } + } + + private fun DSLContext.insertSharingLabels(metadata: SeriesMetadata) { + if (metadata.sharingLabels.isNotEmpty()) { + metadata.sharingLabels.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(sl, sl.SERIES_ID, sl.LABEL) + .values(null as String?, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it) + } + }.execute() + } + } + } + + private fun DSLContext.insertLinks(metadata: SeriesMetadata) { + if (metadata.links.isNotEmpty()) { + metadata.links.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(slk, slk.SERIES_ID, slk.LABEL, slk.URL) + .values(null as String?, null, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it.label, it.url.toString()) + } + }.execute() + } + } + } + + private fun DSLContext.insertAlternateTitles(metadata: SeriesMetadata) { + if (metadata.alternateTitles.isNotEmpty()) { + metadata.alternateTitles.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(sat, sat.SERIES_ID, sat.LABEL, sat.TITLE) + .values(null as String?, null, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it.label, it.title) + } + }.execute() + } + } + } + + @Transactional + override fun delete(seriesId: String) { + dslRW.deleteFrom(g).where(g.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(st).where(st.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(sl).where(sl.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(slk).where(slk.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(sat).where(sat.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(d).where(d.SERIES_ID.eq(seriesId)).execute() + } + + @Transactional + override fun delete(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW.deleteFrom(g).where(g.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(st).where(st.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(sl).where(sl.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(slk).where(slk.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(sat).where(sat.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(d).where(d.SERIES_ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun count(): Long = dslRO.fetchCount(d).toLong() + + private fun SeriesMetadataRecord.toDomain( + genres: Set, + tags: Set, + sharingLabels: Set, + links: List, + alternateTitles: List, + ) = SeriesMetadata( + status = SeriesMetadata.Status.valueOf(status), + title = title, + titleSort = titleSort, + summary = summary, + readingDirection = + readingDirection?.let { + SeriesMetadata.ReadingDirection.valueOf(readingDirection) + }, + publisher = publisher, + ageRating = ageRating, + language = language, + genres = genres, + tags = tags, + totalBookCount = totalBookCount, + sharingLabels = sharingLabels, + links = links, + alternateTitles = alternateTitles, + statusLock = statusLock, + titleLock = titleLock, + titleSortLock = titleSortLock, + summaryLock = summaryLock, + readingDirectionLock = readingDirectionLock, + publisherLock = publisherLock, + ageRatingLock = ageRatingLock, + languageLock = languageLock, + genresLock = genresLock, + tagsLock = tagsLock, + totalBookCountLock = totalBookCountLock, + sharingLabelsLock = sharingLabelsLock, + linksLock = linksLock, + alternateTitlesLock = alternateTitlesLock, + seriesId = seriesId, + createdDate = createdDate.toCurrentTimeZone(), + lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDao.kt new file mode 100644 index 000000000..b9a347882 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDao.kt @@ -0,0 +1,59 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component + +@Component +class ServerSettingsDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO) { + private val s = Tables.SERVER_SETTINGS + + fun getSettingByKey( + key: String, + clazz: Class, + ): T? = + dslRO + .select(s.VALUE) + .from(s) + .where(s.KEY.eq(key)) + .fetchOneInto(clazz) + + fun saveSetting( + key: String, + value: String, + ) { + dslRW + .insertInto(s) + .values(key, value) + .onDuplicateKeyUpdate() + .set(s.VALUE, value) + .execute() + } + + fun saveSetting( + key: String, + value: Boolean, + ) { + saveSetting(key, value.toString()) + } + + fun saveSetting( + key: String, + value: Int, + ) { + saveSetting(key, value.toString()) + } + + fun deleteSetting(key: String) { + dslRW.deleteFrom(s).where(s.KEY.eq(key)).execute() + } + + fun deleteAll() { + dslRW.deleteFrom(s).execute() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SidecarDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SidecarDao.kt new file mode 100644 index 000000000..61bafcb52 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SidecarDao.kt @@ -0,0 +1,82 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.SidecarStored +import org.gotson.komga.domain.persistence.SidecarRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.SidecarRecord +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL + +@Component +class SidecarDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + SidecarRepository { + private val sc = Tables.SIDECAR + + override fun findAll(): Collection = dslRO.selectFrom(sc).fetch().map { it.toDomain() } + + override fun save( + libraryId: String, + sidecar: Sidecar, + ) { + dslRW + .insertInto(sc) + .values( + sidecar.url.toString(), + sidecar.parentUrl.toString(), + sidecar.lastModifiedTime, + libraryId, + ).onDuplicateKeyUpdate() + .set(sc.LAST_MODIFIED_TIME, sidecar.lastModifiedTime) + .set(sc.PARENT_URL, sidecar.parentUrl.toString()) + .set(sc.LIBRARY_ID, libraryId) + .execute() + } + + @Transactional + override fun deleteByLibraryIdAndUrls( + libraryId: String, + urls: Collection, + ) { + dslRW.withTempTable(batchSize, urls.map { it.toString() }).use { + dslRW + .deleteFrom(sc) + .where(sc.LIBRARY_ID.eq(libraryId)) + .and(sc.URL.`in`(it.selectTempStrings())) + .execute() + } + } + + override fun deleteByLibraryId(libraryId: String) { + dslRW + .deleteFrom(sc) + .where(sc.LIBRARY_ID.eq(libraryId)) + .execute() + } + + override fun countGroupedByLibraryId(): Map = + dslRO + .select(sc.LIBRARY_ID, DSL.count(sc.URL)) + .from(sc) + .groupBy(sc.LIBRARY_ID) + .fetchMap(sc.LIBRARY_ID, DSL.count(sc.URL)) + + private fun SidecarRecord.toDomain() = + SidecarStored( + url = URL(url), + parentUrl = URL(parentUrl), + lastModifiedTime = lastModifiedTime, + libraryId = libraryId, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt new file mode 100644 index 000000000..cc8f91ab9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt @@ -0,0 +1,585 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.github.f4b6a3.tsid.TsidCreator +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.model.SyncPoint.ReadList.Companion.ON_DECK_ID +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.infrastructure.jooq.BookSearchHelper +import org.gotson.komga.infrastructure.jooq.RequiredJoin +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.language.toZonedDateTime +import org.jooq.DSLContext +import org.jooq.Field +import org.jooq.Record1 +import org.jooq.SelectConditionStep +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SyncPointDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + private val bookCommonDao: BookCommonDao, +) : SplitDslDaoBase(dslRW, dslRO), + SyncPointRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val bt = Tables.THUMBNAIL_BOOK + private val r = Tables.READ_PROGRESS + private val sd = Tables.SERIES_METADATA + private val sp = Tables.SYNC_POINT + private val spb = Tables.SYNC_POINT_BOOK + private val spbs = Tables.SYNC_POINT_BOOK_REMOVED_SYNCED + private val sprl = Tables.SYNC_POINT_READLIST + private val sprlb = Tables.SYNC_POINT_READLIST_BOOK + private val sprls = Tables.SYNC_POINT_READLIST_REMOVED_SYNCED + + @Transactional + override fun create( + apiKeyId: String?, + search: BookSearch, + context: SearchContext, + ): SyncPoint { + requireNotNull(context.userId) { "userId is required to create a SyncPoint" } + + val (condition, joins) = BookSearchHelper(context).toCondition(search.condition) + + val syncPointId = TsidCreator.getTsid256().toString() + val createdAt = LocalDateTime.now(ZoneId.of("Z")) + + dslRW + .insertInto( + sp, + sp.ID, + sp.USER_ID, + sp.API_KEY_ID, + sp.CREATED_DATE, + ).values( + syncPointId, + context.userId, + apiKeyId, + createdAt, + ).execute() + + dslRW + .insertInto( + spb, + spb.SYNC_POINT_ID, + spb.BOOK_ID, + spb.BOOK_CREATED_DATE, + spb.BOOK_LAST_MODIFIED_DATE, + spb.BOOK_FILE_LAST_MODIFIED, + spb.BOOK_FILE_SIZE, + spb.BOOK_FILE_HASH, + spb.BOOK_METADATA_LAST_MODIFIED_DATE, + spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE, + spb.BOOK_THUMBNAIL_ID, + ).select( + dslRW + .select( + DSL.`val`(syncPointId), + b.ID, + b.CREATED_DATE, + b.LAST_MODIFIED_DATE, + b.FILE_LAST_MODIFIED, + b.FILE_SIZE, + b.FILE_HASH, + d.LAST_MODIFIED_DATE, + r.LAST_MODIFIED_DATE, + bt.ID, + ).from(b) + .apply { + joins.forEach { + when (it) { + // for future work + is RequiredJoin.ReadList -> Unit + // we don't have to handle those since we already join on those tables anyway, the 'when' is here for future proofing + RequiredJoin.BookMetadata -> Unit + RequiredJoin.SeriesMetadata -> Unit + RequiredJoin.Media -> Unit + is RequiredJoin.ReadProgress -> Unit + RequiredJoin.BookMetadataAggregation -> Unit + is RequiredJoin.Collection -> Unit + } + } + }.join(m) + .on(b.ID.eq(m.BOOK_ID)) + .join(d) + .on(b.ID.eq(d.BOOK_ID)) + .join(sd) + .on(b.SERIES_ID.eq(sd.SERIES_ID)) + .leftJoin(r) + .on(b.ID.eq(r.BOOK_ID)) + .and(r.USER_ID.eq(context.userId)) + .leftJoin(bt) + .on(b.ID.eq(bt.BOOK_ID)) + .and(bt.SELECTED.isTrue) + .where(condition), + ).execute() + + return findByIdOrNull(syncPointId)!! + } + + @Transactional + override fun addOnDeck( + syncPointId: String, + context: SearchContext, + filterOnLibraryIds: List?, + ) { + requireNotNull(context.userId) { "Missing userId in search context" } + + val createdAt = LocalDateTime.now(ZoneId.of("Z")) + val onDeckFields: Array> = arrayOf(DSL.`val`(syncPointId), DSL.`val`(ON_DECK_ID), b.ID) + + val (query, _, queryMostRecentDate) = bookCommonDao.getBooksOnDeckQuery(context.userId, context.restrictions, filterOnLibraryIds, onDeckFields) + + val count = + dslRW + .insertInto(sprlb) + .select(query) + .execute() + + // only add the read list entry if some books were added + if (count > 0) { + val mostRecentDate = dslRW.fetch(queryMostRecentDate).into(LocalDateTime::class.java).firstOrNull() ?: createdAt + + dslRW + .insertInto( + sprl, + sprl.SYNC_POINT_ID, + sprl.READLIST_ID, + sprl.READLIST_NAME, + sprl.READLIST_CREATED_DATE, + sprl.READLIST_LAST_MODIFIED_DATE, + ).values( + syncPointId, + ON_DECK_ID, + "On Deck", + createdAt, + mostRecentDate, + ).execute() + } + } + + override fun findByIdOrNull(syncPointId: String): SyncPoint? = + dslRO + .selectFrom(sp) + .where(sp.ID.eq(syncPointId)) + .fetchInto(sp) + .map { + SyncPoint( + id = it.id, + userId = it.userId, + apiKeyId = it.apiKeyId, + createdDate = it.createdDate.toZonedDateTime(), + ) + }.firstOrNull() + + override fun findBooksById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dslRO + .selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(syncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + } + + return dslRO.queryToPageBook(query, pageable) + } + + override fun findBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dslRO + .selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + }.and( + spb.BOOK_ID.notIn( + dslRO.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(fromSyncPointId)), + ), + ) + + return dslRO.queryToPageBook(query, pageable) + } + + override fun findBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dslRO + .selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(fromSyncPointId)) + .and( + spb.BOOK_ID.notIn( + dslRO.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(toSyncPointId)), + ), + ).apply { + if (onlyNotSynced) + and( + spb.BOOK_ID.notIn( + dslRO.select(spbs.BOOK_ID).from(spbs).where(spbs.SYNC_POINT_ID.eq(toSyncPointId)), + ), + ) + } + + return dslRO.queryToPageBook(query, pageable) + } + + override fun findBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val spbFrom = spb.`as`("spbFrom") + val query = + dslRO + .select(*spb.fields()) + .from(spb) + .join(spbFrom) + .on(spb.BOOK_ID.eq(spbFrom.BOOK_ID)) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + }.and( + spb.BOOK_FILE_LAST_MODIFIED + .ne(spbFrom.BOOK_FILE_LAST_MODIFIED) + .or(spb.BOOK_FILE_SIZE.ne(spbFrom.BOOK_FILE_SIZE)) + .or(spb.BOOK_FILE_HASH.ne(spbFrom.BOOK_FILE_HASH).and(spbFrom.BOOK_FILE_HASH.isNotNull)) + .or(spb.BOOK_METADATA_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)) + .or(spb.BOOK_THUMBNAIL_ID.ne(spbFrom.BOOK_THUMBNAIL_ID)), + ) + + return dslRO.queryToPageBook(query, pageable) + } + + override fun findBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val spbFrom = spb.`as`("spbFrom") + val query = + dslRO + .select(*spb.fields()) + .from(spb) + .join(spbFrom) + .on(spb.BOOK_ID.eq(spbFrom.BOOK_ID)) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + }.and( + // unchanged book + spb.BOOK_FILE_LAST_MODIFIED + .eq(spbFrom.BOOK_FILE_LAST_MODIFIED) + .and(spb.BOOK_FILE_SIZE.eq(spbFrom.BOOK_FILE_SIZE)) + .and(spb.BOOK_FILE_HASH.eq(spbFrom.BOOK_FILE_HASH).or(spbFrom.BOOK_FILE_HASH.isNull)) + .and(spb.BOOK_METADATA_LAST_MODIFIED_DATE.eq(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)) + .and(spb.BOOK_THUMBNAIL_ID.eq(spbFrom.BOOK_THUMBNAIL_ID)) + // with changed read progress + .and( + spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE + .ne(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE) + .or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull)) + .or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull)), + ), + ) + + return dslRO.queryToPageBook(query, pageable) + } + + override fun findReadListsById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dslRO + .selectFrom(sprl) + .where(sprl.SYNC_POINT_ID.eq(syncPointId)) + .apply { + if (onlyNotSynced) { + and(sprl.SYNCED.isFalse) + } + } + + return dslRO.queryToPageReadList(query, pageable) + } + + override fun findReadListsAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val to = sprl.`as`("to") + val from = sprl.`as`("from") + val query = + dslRO + .select(*to.fields()) + .from(to) + .leftOuterJoin(from) + .on(to.READLIST_ID.eq(from.READLIST_ID).and(from.SYNC_POINT_ID.eq(fromSyncPointId))) + .where(to.SYNC_POINT_ID.eq(toSyncPointId)) + .apply { if (onlyNotSynced) and(to.SYNCED.isFalse) } + .and(from.READLIST_ID.isNull) + + return dslRO.queryToPageReadList(query, pageable) + } + + override fun findReadListsChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val from = sprl.`as`("from") + val query = + dslRO + .select(*sprl.fields()) + .from(sprl) + .join(from) + .on(sprl.READLIST_ID.eq(from.READLIST_ID)) + .where(sprl.SYNC_POINT_ID.eq(toSyncPointId)) + .and(from.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { if (onlyNotSynced) and(sprl.SYNCED.isFalse) } + .and( + sprl.READLIST_LAST_MODIFIED_DATE + .ne(from.READLIST_LAST_MODIFIED_DATE) + .or(sprl.READLIST_NAME.ne(from.READLIST_NAME)), + ) + + return dslRO.queryToPageReadList(query, pageable) + } + + override fun findReadListsRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val from = sprl.`as`("from") + val to = sprl.`as`("to") + val query = + dslRO + .select(*from.fields()) + .from(from) + .leftOuterJoin(to) + .on(from.READLIST_ID.eq(to.READLIST_ID).and(to.SYNC_POINT_ID.eq(toSyncPointId))) + .where(from.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { + if (onlyNotSynced) + and( + from.READLIST_ID.notIn( + dslRO.select(sprls.READLIST_ID).from(sprls).where(sprls.SYNC_POINT_ID.eq(toSyncPointId)), + ), + ) + }.and(to.READLIST_ID.isNull) + + return dslRO.queryToPageReadList(query, pageable) + } + + override fun findBookIdsByReadListIds( + syncPointId: String, + readListIds: Collection, + ): List = + dslRO + .select(*sprlb.fields()) + .from(sprlb) + .where(sprlb.SYNC_POINT_ID.eq(syncPointId)) + .and(sprlb.READLIST_ID.`in`(readListIds)) + .fetchInto(sprlb) + .map { SyncPoint.ReadList.Book(it.syncPointId, it.readlistId, it.bookId) } + + override fun markBooksSynced( + syncPointId: String, + forRemovedBooks: Boolean, + bookIds: Collection, + ) { + // removed books are not present in the 'to' SyncPoint, only in the 'from' SyncPoint + // we store status in a separate table + if (bookIds.isNotEmpty()) { + if (forRemovedBooks) + dslRW + .batch( + dslRW.insertInto(spbs, spbs.SYNC_POINT_ID, spbs.BOOK_ID).values(null as String?, null).onDuplicateKeyIgnore(), + ).also { step -> + bookIds.map { step.bind(syncPointId, it) } + }.execute() + else + dslRW + .update(spb) + .set(spb.SYNCED, true) + .where(spb.SYNC_POINT_ID.eq(syncPointId)) + .and(spb.BOOK_ID.`in`(bookIds)) + .execute() + } + } + + override fun markReadListsSynced( + syncPointId: String, + forRemovedReadLists: Boolean, + readListIds: Collection, + ) { + // removed read lists are not present in the 'to' SyncPoint, only in the 'from' SyncPoint + // we store status in a separate table + if (readListIds.isNotEmpty()) { + if (forRemovedReadLists) + dslRW + .batch( + dslRW.insertInto(sprls, sprls.SYNC_POINT_ID, sprls.READLIST_ID).values(null as String?, null).onDuplicateKeyIgnore(), + ).also { step -> + readListIds.map { step.bind(syncPointId, it) } + }.execute() + else + dslRW + .update(sprl) + .set(sprl.SYNCED, true) + .where(sprl.SYNC_POINT_ID.eq(syncPointId)) + .and(sprl.READLIST_ID.`in`(readListIds)) + .execute() + } + } + + override fun deleteByUserId(userId: String) { + dslRW.deleteSubEntities(dslRW.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId))) + dslRW.deleteFrom(sp).where(sp.USER_ID.eq(userId)).execute() + } + + override fun deleteByUserIdAndApiKeyIds( + userId: String, + apiKeyIds: Collection, + ) { + dslRW.deleteSubEntities(dslRW.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds)))) + dslRW.deleteFrom(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))).execute() + } + + private fun DSLContext.deleteSubEntities(condition: SelectConditionStep>) { + this.deleteFrom(sprls).where(sprls.SYNC_POINT_ID.`in`(condition)).execute() + this.deleteFrom(sprlb).where(sprlb.SYNC_POINT_ID.`in`(condition)).execute() + this.deleteFrom(sprl).where(sprl.SYNC_POINT_ID.`in`(condition)).execute() + this.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.`in`(condition)).execute() + this.deleteFrom(spb).where(spb.SYNC_POINT_ID.`in`(condition)).execute() + } + + override fun deleteOne(syncPointId: String) { + dslRW.deleteFrom(sprls).where(sprls.SYNC_POINT_ID.eq(syncPointId)).execute() + dslRW.deleteFrom(sprlb).where(sprlb.SYNC_POINT_ID.eq(syncPointId)).execute() + dslRW.deleteFrom(sprl).where(sprl.SYNC_POINT_ID.eq(syncPointId)).execute() + dslRW.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.eq(syncPointId)).execute() + dslRW.deleteFrom(spb).where(spb.SYNC_POINT_ID.eq(syncPointId)).execute() + dslRW.deleteFrom(sp).where(sp.ID.eq(syncPointId)).execute() + } + + override fun deleteAll() { + dslRW.deleteFrom(sprls).execute() + dslRW.deleteFrom(sprlb).execute() + dslRW.deleteFrom(sprl).execute() + dslRW.deleteFrom(spbs).execute() + dslRW.deleteFrom(spb).execute() + dslRW.deleteFrom(sp).execute() + } + + private fun DSLContext.queryToPageBook( + query: SelectConditionStep<*>, + pageable: Pageable, + ): Page { + val count = this.fetchCount(query) + + val items = + query + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(spb) + .map { + SyncPoint.Book( + syncPointId = it.syncPointId, + bookId = it.bookId, + createdDate = it.bookCreatedDate.atZone(ZoneId.of("Z")), + lastModifiedDate = it.bookLastModifiedDate.atZone(ZoneId.of("Z")), + fileLastModified = it.bookFileLastModified.atZone(ZoneId.of("Z")), + fileSize = it.bookFileSize, + fileHash = it.bookFileHash, + metadataLastModifiedDate = it.bookMetadataLastModifiedDate.atZone(ZoneId.of("Z")), + thumbnailId = it.bookThumbnailId, + synced = it.synced, + ) + } + + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted()) + else + PageRequest.of(0, maxOf(count, 20), Sort.unsorted()), + count.toLong(), + ) + } + + private fun DSLContext.queryToPageReadList( + query: SelectConditionStep<*>, + pageable: Pageable, + ): Page { + val count = this.fetchCount(query) + + val items = + query + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(sprl) + .map { + SyncPoint.ReadList( + syncPointId = it.syncPointId, + readListId = it.readlistId, + readListName = it.readlistName, + createdDate = it.readlistCreatedDate.atZone(ZoneId.of("Z")), + lastModifiedDate = it.readlistLastModifiedDate.atZone(ZoneId.of("Z")), + synced = it.synced, + ) + } + + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted()) + else + PageRequest.of(0, maxOf(count, 20), Sort.unsorted()), + count.toLong(), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailBookDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailBookDao.kt new file mode 100644 index 000000000..e5fba456e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailBookDao.kt @@ -0,0 +1,183 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ThumbnailBookRecord +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL + +@Component +class ThumbnailBookDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + ThumbnailBookRepository { + private val tb = Tables.THUMBNAIL_BOOK + private val b = Tables.BOOK + + override fun findAllByBookId(bookId: String): Collection = + dslRO + .selectFrom(tb) + .where(tb.BOOK_ID.eq(bookId)) + .fetchInto(tb) + .map { it.toDomain() } + + override fun findAllByBookIdAndType( + bookId: String, + type: Set, + ): Collection = + dslRO + .selectFrom(tb) + .where(tb.BOOK_ID.eq(bookId)) + .and(tb.TYPE.`in`(type.map { it.name })) + .fetchInto(tb) + .map { it.toDomain() } + + override fun findByIdOrNull(thumbnailId: String): ThumbnailBook? = + dslRO + .selectFrom(tb) + .where(tb.ID.eq(thumbnailId)) + .fetchOneInto(tb) + ?.toDomain() + + override fun findSelectedByBookIdOrNull(bookId: String): ThumbnailBook? = + dslRO + .selectFrom(tb) + .where(tb.BOOK_ID.eq(bookId)) + .and(tb.SELECTED.isTrue) + .limit(1) + .fetchInto(tb) + .map { it.toDomain() } + .firstOrNull() + + override fun findAllBookIdsByThumbnailTypeAndDimensionSmallerThan( + type: ThumbnailBook.Type, + size: Int, + ): Collection = + dslRO + .select(tb.BOOK_ID) + .from(tb) + .where(tb.TYPE.eq(type.toString())) + .and(tb.WIDTH.lt(size)) + .and(tb.HEIGHT.lt(size)) + .fetch(tb.BOOK_ID) + + override fun existsById(thumbnailId: String): Boolean = dslRO.fetchExists(tb, tb.ID.eq(thumbnailId)) + + override fun getLibraryIdOrNull(thumbnailId: String): String? = + dslRO + .select(b.LIBRARY_ID) + .from(tb) + .leftJoin(b) + .on(tb.BOOK_ID.eq(b.ID)) + .where(tb.ID.eq(thumbnailId)) + .fetchOne(b.LIBRARY_ID) + + override fun getSeriesIdOrNull(thumbnailId: String): String? = + dslRO + .select(b.SERIES_ID) + .from(tb) + .leftJoin(b) + .on(tb.BOOK_ID.eq(b.ID)) + .where(tb.ID.eq(thumbnailId)) + .fetchOne(b.SERIES_ID) + + override fun insert(thumbnail: ThumbnailBook) { + dslRW + .insertInto(tb) + .set(tb.ID, thumbnail.id) + .set(tb.BOOK_ID, thumbnail.bookId) + .set(tb.THUMBNAIL, thumbnail.thumbnail) + .set(tb.URL, thumbnail.url?.toString()) + .set(tb.SELECTED, thumbnail.selected) + .set(tb.TYPE, thumbnail.type.toString()) + .set(tb.MEDIA_TYPE, thumbnail.mediaType) + .set(tb.WIDTH, thumbnail.dimension.width) + .set(tb.HEIGHT, thumbnail.dimension.height) + .set(tb.FILE_SIZE, thumbnail.fileSize) + .execute() + } + + override fun update(thumbnail: ThumbnailBook) { + dslRW + .update(tb) + .set(tb.BOOK_ID, thumbnail.bookId) + .set(tb.THUMBNAIL, thumbnail.thumbnail) + .set(tb.URL, thumbnail.url?.toString()) + .set(tb.SELECTED, thumbnail.selected) + .set(tb.TYPE, thumbnail.type.toString()) + .set(tb.MEDIA_TYPE, thumbnail.mediaType) + .set(tb.WIDTH, thumbnail.dimension.width) + .set(tb.HEIGHT, thumbnail.dimension.height) + .set(tb.FILE_SIZE, thumbnail.fileSize) + .where(tb.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailBook) { + dslRW + .update(tb) + .set(tb.SELECTED, false) + .where(tb.BOOK_ID.eq(thumbnail.bookId)) + .and(tb.ID.ne(thumbnail.id)) + .execute() + + dslRW + .update(tb) + .set(tb.SELECTED, true) + .where(tb.BOOK_ID.eq(thumbnail.bookId)) + .and(tb.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailBookId: String) { + dslRW.deleteFrom(tb).where(tb.ID.eq(thumbnailBookId)).execute() + } + + override fun deleteByBookId(bookId: String) { + dslRW.deleteFrom(tb).where(tb.BOOK_ID.eq(bookId)).execute() + } + + @Transactional + override fun deleteByBookIds(bookIds: Collection) { + dslRW.withTempTable(batchSize, bookIds).use { + dslRW.deleteFrom(tb).where(tb.BOOK_ID.`in`(it.selectTempStrings())).execute() + } + } + + override fun deleteByBookIdAndType( + bookId: String, + type: ThumbnailBook.Type, + ) { + dslRW + .deleteFrom(tb) + .where(tb.BOOK_ID.eq(bookId)) + .and(tb.TYPE.eq(type.toString())) + .execute() + } + + private fun ThumbnailBookRecord.toDomain() = + ThumbnailBook( + thumbnail = thumbnail, + url = url?.let { URL(it) }, + selected = selected, + type = ThumbnailBook.Type.valueOf(type), + mediaType = mediaType, + fileSize = fileSize, + dimension = Dimension(width, height), + id = id, + bookId = bookId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailReadListDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailReadListDao.kt new file mode 100644 index 000000000..713ee0b8c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailReadListDao.kt @@ -0,0 +1,118 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.ThumbnailReadList +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ThumbnailReadlistRecord +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ThumbnailReadListDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + ThumbnailReadListRepository { + private val tr = Tables.THUMBNAIL_READLIST + + override fun findAllByReadListId(readListId: String): Collection = + dslRO + .selectFrom(tr) + .where(tr.READLIST_ID.eq(readListId)) + .fetchInto(tr) + .map { it.toDomain() } + + override fun findByIdOrNull(thumbnailId: String): ThumbnailReadList? = + dslRO + .selectFrom(tr) + .where(tr.ID.eq(thumbnailId)) + .fetchOneInto(tr) + ?.toDomain() + + override fun findSelectedByReadListIdOrNull(readListId: String): ThumbnailReadList? = + dslRO + .selectFrom(tr) + .where(tr.READLIST_ID.eq(readListId)) + .and(tr.SELECTED.isTrue) + .limit(1) + .fetchInto(tr) + .map { it.toDomain() } + .firstOrNull() + + override fun insert(thumbnail: ThumbnailReadList) { + dslRW + .insertInto(tr) + .set(tr.ID, thumbnail.id) + .set(tr.READLIST_ID, thumbnail.readListId) + .set(tr.THUMBNAIL, thumbnail.thumbnail) + .set(tr.SELECTED, thumbnail.selected) + .set(tr.TYPE, thumbnail.type.toString()) + .set(tr.MEDIA_TYPE, thumbnail.mediaType) + .set(tr.WIDTH, thumbnail.dimension.width) + .set(tr.HEIGHT, thumbnail.dimension.height) + .set(tr.FILE_SIZE, thumbnail.fileSize) + .execute() + } + + override fun update(thumbnail: ThumbnailReadList) { + dslRW + .update(tr) + .set(tr.READLIST_ID, thumbnail.readListId) + .set(tr.THUMBNAIL, thumbnail.thumbnail) + .set(tr.SELECTED, thumbnail.selected) + .set(tr.TYPE, thumbnail.type.toString()) + .set(tr.MEDIA_TYPE, thumbnail.mediaType) + .set(tr.WIDTH, thumbnail.dimension.width) + .set(tr.HEIGHT, thumbnail.dimension.height) + .set(tr.FILE_SIZE, thumbnail.fileSize) + .where(tr.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailReadList) { + dslRW + .update(tr) + .set(tr.SELECTED, false) + .where(tr.READLIST_ID.eq(thumbnail.readListId)) + .and(tr.ID.ne(thumbnail.id)) + .execute() + + dslRW + .update(tr) + .set(tr.SELECTED, true) + .where(tr.READLIST_ID.eq(thumbnail.readListId)) + .and(tr.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailReadListId: String) { + dslRW.deleteFrom(tr).where(tr.ID.eq(thumbnailReadListId)).execute() + } + + override fun deleteByReadListId(readListId: String) { + dslRW.deleteFrom(tr).where(tr.READLIST_ID.eq(readListId)).execute() + } + + override fun deleteByReadListIds(readListIds: Collection) { + dslRW.deleteFrom(tr).where(tr.READLIST_ID.`in`(readListIds)).execute() + } + + private fun ThumbnailReadlistRecord.toDomain() = + ThumbnailReadList( + thumbnail = thumbnail, + selected = selected, + type = ThumbnailReadList.Type.valueOf(type), + mediaType = mediaType, + fileSize = fileSize, + dimension = Dimension(width, height), + id = id, + readListId = readlistId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesCollectionDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesCollectionDao.kt new file mode 100644 index 000000000..bba0361c2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesCollectionDao.kt @@ -0,0 +1,118 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.ThumbnailSeriesCollection +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ThumbnailCollectionRecord +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ThumbnailSeriesCollectionDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, +) : SplitDslDaoBase(dslRW, dslRO), + ThumbnailSeriesCollectionRepository { + private val tc = Tables.THUMBNAIL_COLLECTION + + override fun findByIdOrNull(thumbnailId: String): ThumbnailSeriesCollection? = + dslRO + .selectFrom(tc) + .where(tc.ID.eq(thumbnailId)) + .fetchOneInto(tc) + ?.toDomain() + + override fun findSelectedByCollectionIdOrNull(collectionId: String): ThumbnailSeriesCollection? = + dslRO + .selectFrom(tc) + .where(tc.COLLECTION_ID.eq(collectionId)) + .and(tc.SELECTED.isTrue) + .limit(1) + .fetchInto(tc) + .map { it.toDomain() } + .firstOrNull() + + override fun findAllByCollectionId(collectionId: String): Collection = + dslRO + .selectFrom(tc) + .where(tc.COLLECTION_ID.eq(collectionId)) + .fetchInto(tc) + .map { it.toDomain() } + + override fun insert(thumbnail: ThumbnailSeriesCollection) { + dslRW + .insertInto(tc) + .set(tc.ID, thumbnail.id) + .set(tc.COLLECTION_ID, thumbnail.collectionId) + .set(tc.THUMBNAIL, thumbnail.thumbnail) + .set(tc.SELECTED, thumbnail.selected) + .set(tc.TYPE, thumbnail.type.toString()) + .set(tc.MEDIA_TYPE, thumbnail.mediaType) + .set(tc.WIDTH, thumbnail.dimension.width) + .set(tc.HEIGHT, thumbnail.dimension.height) + .set(tc.FILE_SIZE, thumbnail.fileSize) + .execute() + } + + override fun update(thumbnail: ThumbnailSeriesCollection) { + dslRW + .update(tc) + .set(tc.COLLECTION_ID, thumbnail.collectionId) + .set(tc.THUMBNAIL, thumbnail.thumbnail) + .set(tc.SELECTED, thumbnail.selected) + .set(tc.TYPE, thumbnail.type.toString()) + .set(tc.MEDIA_TYPE, thumbnail.mediaType) + .set(tc.WIDTH, thumbnail.dimension.width) + .set(tc.HEIGHT, thumbnail.dimension.height) + .set(tc.FILE_SIZE, thumbnail.fileSize) + .where(tc.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailSeriesCollection) { + dslRW + .update(tc) + .set(tc.SELECTED, false) + .where(tc.COLLECTION_ID.eq(thumbnail.collectionId)) + .and(tc.ID.ne(thumbnail.id)) + .execute() + + dslRW + .update(tc) + .set(tc.SELECTED, true) + .where(tc.COLLECTION_ID.eq(thumbnail.collectionId)) + .and(tc.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailCollectionId: String) { + dslRW.deleteFrom(tc).where(tc.ID.eq(thumbnailCollectionId)).execute() + } + + override fun deleteByCollectionId(collectionId: String) { + dslRW.deleteFrom(tc).where(tc.COLLECTION_ID.eq(collectionId)).execute() + } + + override fun deleteByCollectionIds(collectionIds: Collection) { + dslRW.deleteFrom(tc).where(tc.COLLECTION_ID.`in`(collectionIds)).execute() + } + + private fun ThumbnailCollectionRecord.toDomain() = + ThumbnailSeriesCollection( + thumbnail = thumbnail, + selected = selected, + type = ThumbnailSeriesCollection.Type.valueOf(type), + mediaType = mediaType, + fileSize = fileSize, + dimension = Dimension(width, height), + id = id, + collectionId = collectionId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesDao.kt new file mode 100644 index 000000000..88bf1abaa --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ThumbnailSeriesDao.kt @@ -0,0 +1,156 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.infrastructure.jooq.TempTable.Companion.withTempTable +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.jooq.main.tables.records.ThumbnailSeriesRecord +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.net.URL + +@Component +class ThumbnailSeriesDao( + dslRW: DSLContext, + @Qualifier("dslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, +) : SplitDslDaoBase(dslRW, dslRO), + ThumbnailSeriesRepository { + private val ts = Tables.THUMBNAIL_SERIES + private val s = Tables.SERIES + + override fun findByIdOrNull(thumbnailId: String): ThumbnailSeries? = + dslRO + .selectFrom(ts) + .where(ts.ID.eq(thumbnailId)) + .fetchOneInto(ts) + ?.toDomain() + + override fun findAllBySeriesId(seriesId: String): Collection = + dslRO + .selectFrom(ts) + .where(ts.SERIES_ID.eq(seriesId)) + .fetchInto(ts) + .map { it.toDomain() } + + override fun findAllBySeriesIdIdAndType( + seriesId: String, + type: ThumbnailSeries.Type, + ): Collection = + dslRO + .selectFrom(ts) + .where(ts.SERIES_ID.eq(seriesId)) + .and(ts.TYPE.eq(type.toString())) + .fetchInto(ts) + .map { it.toDomain() } + + override fun getLibraryIdOrNull(thumbnailId: String): String? = + dslRO + .select(s.LIBRARY_ID) + .from(ts) + .leftJoin(s) + .on(ts.SERIES_ID.eq(s.ID)) + .where(ts.ID.eq(thumbnailId)) + .fetchOne(s.LIBRARY_ID) + + override fun getSeriesIdOrNull(thumbnailId: String): String? = + dslRO + .select(ts.SERIES_ID) + .from(ts) + .where(ts.ID.eq(thumbnailId)) + .fetchOne(ts.SERIES_ID) + + override fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries? = + dslRO + .selectFrom(ts) + .where(ts.SERIES_ID.eq(seriesId)) + .and(ts.SELECTED.isTrue) + .limit(1) + .fetchInto(ts) + .map { it.toDomain() } + .firstOrNull() + + override fun insert(thumbnail: ThumbnailSeries) { + dslRW + .insertInto(ts) + .set(ts.ID, thumbnail.id) + .set(ts.SERIES_ID, thumbnail.seriesId) + .set(ts.URL, thumbnail.url?.toString()) + .set(ts.THUMBNAIL, thumbnail.thumbnail) + .set(ts.TYPE, thumbnail.type.toString()) + .set(ts.MEDIA_TYPE, thumbnail.mediaType) + .set(ts.WIDTH, thumbnail.dimension.width) + .set(ts.HEIGHT, thumbnail.dimension.height) + .set(ts.FILE_SIZE, thumbnail.fileSize) + .set(ts.SELECTED, thumbnail.selected) + .execute() + } + + override fun update(thumbnail: ThumbnailSeries) { + dslRW + .update(ts) + .set(ts.SERIES_ID, thumbnail.seriesId) + .set(ts.THUMBNAIL, thumbnail.thumbnail) + .set(ts.URL, thumbnail.url?.toString()) + .set(ts.SELECTED, thumbnail.selected) + .set(ts.TYPE, thumbnail.type.toString()) + .set(ts.MEDIA_TYPE, thumbnail.mediaType) + .set(ts.WIDTH, thumbnail.dimension.width) + .set(ts.HEIGHT, thumbnail.dimension.height) + .set(ts.FILE_SIZE, thumbnail.fileSize) + .where(ts.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailSeries) { + dslRW + .update(ts) + .set(ts.SELECTED, false) + .where(ts.SERIES_ID.eq(thumbnail.seriesId)) + .and(ts.ID.ne(thumbnail.id)) + .execute() + + dslRW + .update(ts) + .set(ts.SELECTED, true) + .where(ts.SERIES_ID.eq(thumbnail.seriesId)) + .and(ts.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailSeriesId: String) { + dslRW.deleteFrom(ts).where(ts.ID.eq(thumbnailSeriesId)).execute() + } + + override fun deleteBySeriesId(seriesId: String) { + dslRW.deleteFrom(ts).where(ts.SERIES_ID.eq(seriesId)).execute() + } + + @Transactional + override fun deleteBySeriesIds(seriesIds: Collection) { + dslRW.withTempTable(batchSize, seriesIds).use { + dslRW.deleteFrom(ts).where(ts.SERIES_ID.`in`(it.selectTempStrings())).execute() + } + } + + private fun ThumbnailSeriesRecord.toDomain() = + ThumbnailSeries( + thumbnail = thumbnail, + url = url?.let { URL(it) }, + selected = selected, + type = ThumbnailSeries.Type.valueOf(type), + mediaType = mediaType, + fileSize = fileSize, + dimension = Dimension(width, height), + id = id, + seriesId = seriesId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDao.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDao.kt new file mode 100644 index 000000000..2b851ecfa --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDao.kt @@ -0,0 +1,170 @@ +package org.gotson.komga.infrastructure.jooq.tasks + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.Task +import org.gotson.komga.application.tasks.TasksRepository +import org.gotson.komga.infrastructure.jooq.SplitDslDaoBase +import org.gotson.komga.jooq.tasks.Tables +import org.jooq.DSLContext +import org.jooq.Query +import org.jooq.Record2 +import org.jooq.impl.DSL +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.DependsOn +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +private val logger = KotlinLogging.logger {} + +@Component +@DependsOn("flywaySecondaryMigrationInitializer") +class TasksDao( + @Qualifier("tasksDslContextRW") dslRW: DSLContext, + @Qualifier("tasksDslContextRO") dslRO: DSLContext, + @param:Value("#{@komgaProperties.tasksDb.batchChunkSize}") private val batchSize: Int, + private val objectMapper: ObjectMapper, +) : SplitDslDaoBase(dslRW, dslRO), + TasksRepository { + private val t = Tables.TASK + + private val tasksAvailableCondition = + t.OWNER.isNull + .and( + t.GROUP_ID + .notIn( + DSL + .select(t.GROUP_ID) + .from(t) + .where(t.OWNER.isNotNull) + .and(t.GROUP_ID.isNotNull), + ).or(t.GROUP_ID.isNull), + ) + + override fun hasAvailable(): Boolean = + dslRO.fetchExists( + t, + tasksAvailableCondition, + ) + + @Transactional + override fun takeFirst(owner: String): Task? { + val task = + dslRW + .selectBase() + .where(tasksAvailableCondition) + .orderBy(t.PRIORITY.desc(), t.LAST_MODIFIED_DATE) + .limit(1) + .fetchOne() + ?.let { + try { + objectMapper.readValue(it.value2(), Class.forName(it.value1())) as Task + } catch (e: Exception) { + logger.error(e) { "Could not deserialize object of type: ${it.value1()}" } + null + } + } ?: return null + + dslRW + .update(t) + .set(t.OWNER, owner) + .where(t.ID.eq(task.uniqueId)) + .execute() + + return task + } + + override fun findAll(): List = + dslRO + .selectBase() + .fetch() + .mapNotNull { it.toDomain() } + + override fun findAllGroupedByOwner(): Map> = + dslRO + .select(t.OWNER, t.CLASS, t.PAYLOAD) + .from(t) + .fetch() + .mapNotNull { + it.into(t.CLASS, t.PAYLOAD).toDomain()?.let { task -> it.value1() to task } + }.groupBy({ it.first }, { it.second }) + + private fun DSLContext.selectBase() = + this + .select(t.CLASS, t.PAYLOAD) + .from(t) + + private fun Record2.toDomain(): Task? = + try { + objectMapper.readValue(value2(), Class.forName(value1())) as Task + } catch (e: Exception) { + logger.error(e) { "Could not deserialize object of type: ${value1()}" } + null + } + + override fun count(): Int = dslRO.fetchCount(t) + + override fun countBySimpleType(): Map = + dslRO + .select(t.SIMPLE_TYPE, DSL.count(t.SIMPLE_TYPE)) + .from(t) + .groupBy(t.SIMPLE_TYPE) + .fetch() + .associate { it.value1() to it.value2() } + + override fun save(task: Task) { + task.toQuery(dslRW).execute() + } + + override fun save(tasks: Collection) { + tasks + .map { it.toQuery(dslRW) } + .chunked(batchSize) + .forEach { chunk -> dslRW.batch(chunk).execute() } + } + + override fun disown(): Int = + dslRW + .update(t) + .set(t.OWNER, null as String?) + .where(t.OWNER.isNotNull) + .execute() + + override fun delete(taskId: String) { + dslRW.deleteFrom(t).where(t.ID.eq(taskId)).execute() + } + + override fun deleteAll() { + dslRW.deleteFrom(t).execute() + } + + override fun deleteAllWithoutOwner(): Int = dslRW.deleteFrom(t).where(t.OWNER.isNull).execute() + + private fun Task.toQuery(dsl: DSLContext): Query = + dsl + .insertInto( + t, + t.ID, + t.PRIORITY, + t.GROUP_ID, + t.CLASS, + t.SIMPLE_TYPE, + t.PAYLOAD, + ).values( + uniqueId, + priority, + groupId, + javaClass.typeName, + javaClass.simpleName, + objectMapper.writeValueAsString(this), + ).onDuplicateKeyUpdate() + .set(t.GROUP_ID, groupId) + .set(t.PRIORITY, priority) + .set(t.CLASS, javaClass.typeName) + .set(t.SIMPLE_TYPE, javaClass.simpleName) + .set(t.PAYLOAD, objectMapper.writeValueAsString(this)) + .set(t.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverter.kt new file mode 100644 index 000000000..3aeb8f017 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverter.kt @@ -0,0 +1,169 @@ +package org.gotson.komga.infrastructure.kobo + +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PostConstruct +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.configuration.SettingChangedEvent +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.nameWithoutExtension + +private val logger = KotlinLogging.logger {} + +@Component +class KepubConverter( + private val settingsProvider: KomgaSettingsProvider, + @param:Value($$"${komga.kobo.kepubify-path:#{null}}") val kepubifyConfigurationPath: String?, +) { + final var kepubifyPath: Path? = null + private set + final var isAvailable = false + private set + + private val tmpDir by lazy { Path(System.getProperty("java.io.tmpdir")) } + + @PostConstruct + private fun configureKepubifyOnStartup() { + if (!settingsProvider.kepubifyPath.isNullOrBlank()) + configureKepubify(settingsProvider.kepubifyPath, true) + else if (!kepubifyConfigurationPath.isNullOrBlank()) + configureKepubify(kepubifyConfigurationPath) + else + logger.info { "Kepub conversion unavailable. kepubify path is not set" } + } + + @EventListener(SettingChangedEvent.KepubifyPath::class) + private fun configureKepubifyOnSettingsChange() { + configureKepubify(settingsProvider.kepubifyPath, true) + } + + /** + * Configure the path for kepubify + * @param newValue path to kepubify + * @param fallback whether to fallback to configuration properties in case [newValue] is invalid + */ + fun configureKepubify( + newValue: String?, + fallback: Boolean = false, + ) { + if (newValue.isNullOrBlank()) { + isAvailable = false + kepubifyPath = null + if (fallback && !kepubifyConfigurationPath.isNullOrBlank()) { + configureKepubify(kepubifyConfigurationPath) + } + } else { + val newPath = Path(newValue) + if (!isExecutable(newPath)) { + logger.warn { "kepubify path is not executable, not found or not valid: $newPath" } + isAvailable = false + if (fallback && !kepubifyConfigurationPath.isNullOrBlank()) { + configureKepubify(kepubifyConfigurationPath) + } + } else { + logger.info { "Kepub conversion available. kepubify path: $newPath" } + isAvailable = true + } + kepubifyPath = newPath + } + } + + private fun isExecutable(path: Path): Boolean { + try { + if (Files.isExecutable(path)) return true + // path may be an executable in the PATH, try running it + val process = Runtime.getRuntime().exec(path.toString()) + process.waitFor(3, TimeUnit.SECONDS) + + return process.exitValue() == 0 + } catch (e: Exception) { + logger.warn(e) { "Error while verifying executable: $path" } + return false + } + } + + /** + * Converts an EPUB book to KEPUB. The destination filename will be built from the original book file name. + * + * @param bookWithMedia the source book + * @param destinationDir the destination directory in which to save the converted file, else the default temporary directory is used + * @throws IllegalArgumentException if the source book is not an EPUB, or is already a KEPUB + * @return the [Path] of the converted file in case of success, else null + */ + fun convertEpubToKepub( + bookWithMedia: BookWithMedia, + destinationDir: Path? = null, + ): Path? { + require(bookWithMedia.media.mediaType == MediaType.EPUB.type) { "Cannot convert, not an EPUB: ${bookWithMedia.book.path}" } + require(!bookWithMedia.media.epubIsKepub) { "Cannot convert, EPUB is already a KEPUB: ${bookWithMedia.book.path}" } + require(bookWithMedia.book.path.exists()) { "Source file does not exist: ${bookWithMedia.book.path}" } + + return convertEpubToKepubWithoutChecks(bookWithMedia.book.path, destinationDir) + } + + /** + * Converts an EPUB book to KEPUB. The destination filename will be built from the original file name. + * This function does not check whether the file is an EPUB, or is already a KEPUB, or if the source file exists. + * + * This is intended for internal use in the EpubExtractor + */ + fun convertEpubToKepubWithoutChecks( + epub: Path, + destinationDir: Path? = null, + ): Path? { + check(isAvailable) { "Kepub conversion is not available, kepubify path may not be set, or may be invalid" } + + if (destinationDir != null) require(destinationDir.isDirectory()) { "Destination directory does not exist: $destinationDir" } + + // kepubify will only convert when the destination name has the .kepub.epub extension, so we have to force it + val destinationPath = (destinationDir ?: tmpDir).resolve(epub.nameWithoutExtension + ".kepub.epub") + destinationPath.deleteIfExists() + + val command = + arrayOf( + kepubifyPath.toString(), + epub.toString(), + "-o", + destinationPath.toString(), + ) + logger.debug { "Starting conversion with: ${command.joinToString(" ")}" } + val process = + try { + Runtime.getRuntime().exec(command) + } catch (e: Exception) { + logger.error(e) { "Failed to create process" } + return null + } + + if (!process.waitFor(10, TimeUnit.SECONDS)) { + logger.error { "Kepub conversion timeout. Command: ${command.joinToString(" ")}" } + return null + } + + if (process.exitValue() != 0) { + val error = process.errorReader().useLines { it.joinToString(" ") } + logger.error { "Kepub conversion failed. Command: ${command.joinToString(" ")}. Error: $error" } + return null + } + logger.debug { + "kepubify output: " + process.inputReader().useLines { it.joinToString("\n") } + } + + if (!destinationPath.exists()) { + logger.error { "Converted file not found: $destinationPath" } + return null + } + + return destinationPath + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt new file mode 100644 index 000000000..675d0aaa3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.kobo + +object KoboHeaders { + const val X_KOBO_SYNCTOKEN = "x-kobo-synctoken" + const val X_KOBO_USERKEY = "X-Kobo-userkey" + const val X_KOBO_SYNC = "X-Kobo-sync" + const val X_KOBO_DEVICEID = "X-Kobo-deviceid" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt new file mode 100644 index 000000000..dd93877ef --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt @@ -0,0 +1,342 @@ +package org.gotson.komga.infrastructure.kobo + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.gotson.komga.infrastructure.web.getCurrentRequest +import org.gotson.komga.language.contains +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatusCode +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.toEntity +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.util.DefaultUriBuilderFactory +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +private val logger = KotlinLogging.logger {} + +@Component +class KoboProxy( + private val objectMapper: ObjectMapper, + private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + private val komgaSettingsProvider: KomgaSettingsProvider, +) { + private val koboApiClient: RestClient = + RestClient + .builder() + .uriBuilderFactory( + DefaultUriBuilderFactory("https://storeapi.kobo.com") + .apply { + this.encodingMode = DefaultUriBuilderFactory.EncodingMode.NONE + }, + ).requestFactory( + ClientHttpRequestFactoryBuilder.reactor().build( + ClientHttpRequestFactorySettings + .defaults() + .withReadTimeout(1.minutes.toJavaDuration()) + .withConnectTimeout(1.minutes.toJavaDuration()), + ), + ).build() + + private val pathRegex = """/kobo/[-\w]*(.*)""".toRegex() + + private val headersOutInclude = + setOf( + HttpHeaders.AUTHORIZATION, + HttpHeaders.USER_AGENT, + HttpHeaders.ACCEPT, + HttpHeaders.ACCEPT_LANGUAGE, + HttpHeaders.CONTENT_TYPE, + ) + + private val headersOutExclude = + setOf( + X_KOBO_SYNCTOKEN, + ) + + private fun isKoboHeader(headerName: String) = headerName.startsWith("x-kobo-", true) + + fun isEnabled() = komgaSettingsProvider.koboProxy + + /** + * Proxy the current request to the Kobo store, if enabled. + * If [includeSyncToken] is set, the raw sync token will be extracted from the current request and sent to the store. + * If a X_KOBO_SYNCTOKEN header is present in the response, the original Komga sync token will be updated with the + * raw Kobo sync token returned, and added to the response headers. + */ + fun proxyCurrentRequest( + body: ByteArray? = null, + includeSyncToken: Boolean = false, + ): ResponseEntity { + if (!komgaSettingsProvider.koboProxy) throw IllegalStateException("kobo proxying is disabled") + + val request = getCurrentRequest() + val (path) = pathRegex.find(request.requestURI)?.destructured ?: throw IllegalStateException("Could not get path from current request") + + val syncToken = + if (includeSyncToken) + komgaSyncTokenGenerator.fromRequestHeaders(request) + else + null + + val response = + koboApiClient + .method(HttpMethod.valueOf(request.method)) + .uri { uriBuilder -> + uriBuilder + .path(path) + .query(request.queryString) + .build() + .also { logger.debug { "Proxy URL: $it" } } + }.headers { headersOut -> + request.headerNames + .toList() + .filterNot { headersOutExclude.contains(it, true) } + .filter { headersOutInclude.contains(it, true) || isKoboHeader(it) } + .forEach { + headersOut.addAll(it, request.getHeaders(it)?.toList() ?: emptyList()) + } + if (includeSyncToken) { + if (syncToken != null && syncToken.rawKoboSyncToken.isNotBlank()) { + headersOut.add(X_KOBO_SYNCTOKEN, syncToken.rawKoboSyncToken) + } else { + throw IllegalStateException("request must include sync token, but no raw Kobo sync token found") + } + } + logger.debug { "Headers out: $headersOut" } + }.apply { if (body != null) body(body) } + .retrieve() + .onStatus(HttpStatusCode::isError) { _, response -> + logger.debug { "Kobo response: ${response.statusCode}: ${response.body.bufferedReader().use { it.readText() }}" } + throw ResponseStatusException(response.statusCode, response.statusText) + }.toEntity() + + logger.debug { "Kobo response: $response" } + + val headersToReturn = + response.headers + .filterKeys { isKoboHeader(it) } + .toMutableMap() + .apply { + if (keys.contains(X_KOBO_SYNCTOKEN, true)) { + val koboSyncToken = this[X_KOBO_SYNCTOKEN]?.firstOrNull() + if (koboSyncToken != null && includeSyncToken && syncToken != null) { + val komgaSyncToken = syncToken.copy(rawKoboSyncToken = koboSyncToken) + this[X_KOBO_SYNCTOKEN] = listOf(komgaSyncTokenGenerator.toBase64(komgaSyncToken)) + } + } + } + + return ResponseEntity( + response.body, + LinkedMultiValueMap(headersToReturn), + response.statusCode, + ) + } + + val imageHostUrl = "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg" + + val nativeKoboResources: JsonNode by lazy { + objectMapper.readTree( + // language=JSON + """ + { + "account_page": "https://www.kobo.com/account/settings", + "account_page_rakuten": "https://my.rakuten.co.jp/", + "add_device": "https://storeapi.kobo.com/v1/user/add-device", + "add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}", + "affiliaterequest": "https://storeapi.kobo.com/v1/affiliate", + "assets": "https://storeapi.kobo.com/v1/assets", + "audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}", + "audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}", + "audiobook_get_credits": "https://www.kobo.com/{region}/{language}/audiobooks/plans", + "audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks", + "audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview", + "audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}", + "audiobook_subscription_management": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion", + "audiobook_subscription_purchase": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "audiobook_subscription_tiers": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations", + "autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete", + "bam": "https://storeapi.kobo.com/v2/activity/bam/success", + "blackstone_header": { + "key": "x-amz-request-payer", + "value": "requester" + }, + "book": "https://storeapi.kobo.com/v1/products/books/{ProductId}", + "book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}", + "book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}", + "book_landing_page": "https://www.kobo.com/ebooks", + "book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions", + "browse_history": "https://storeapi.kobo.com/v1/user/browsehistory", + "categories": "https://storeapi.kobo.com/v1/categories", + "categories_page": "https://www.kobo.com/ebooks/categories", + "categoriesv2": "https://storeapi.kobo.com/api/v2/Categories/Top", + "category": "https://storeapi.kobo.com/v1/categories/{CategoryId}", + "category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured", + "category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products", + "checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow", + "client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink", + "configuration_data": "https://storeapi.kobo.com/v1/configuration", + "content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access", + "contributorsv2": "https://storeapi.kobo.com/v2/contributors/author", + "customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO", + "daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal", + "deals": "https://storeapi.kobo.com/v1/deals", + "delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}", + "delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete", + "device_auth": "https://storeapi.kobo.com/v1/auth/device", + "device_refresh": "https://storeapi.kobo.com/v1/auth/refresh", + "dictionary_host": "https://ereaderfiles.kobo.com", + "discovery_host": "https://discovery.kobobooks.com", + "display_accessibility_enabled": "False", + "display_parental_controls_enabled": "True", + "dropbox_link_account_poll": "https://authorize.kobo.com/{region}/{language}/LinkDropbox", + "dropbox_link_account_start": "https://authorize.kobo.com/LinkDropbox/start", + "ereaderdevices": "https://storeapi.kobo.com/v2/products/EReaderDeviceFeeds", + "eula_page": "https://www.kobo.com/termsofuse?style=onestore", + "exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange", + "external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}", + "facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=https://kobo.com/", + "featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}", + "featured_lists": "https://storeapi.kobo.com/v1/products/featured", + "featuredlist2": "https://storeapi.kobo.com/v2/products/list/featured", + "fixed_layout_page_cache_enabled": "True", + "free_books_page": { + "EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks", + "FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits", + "IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti", + "NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg", + "PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis" + }, + "fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback", + "funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics", + "geography_data": "https://storeapi.kobo.com/v2/configuration/geography/country", + "get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys", + "get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink", + "get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests", + "giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader", + "giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem", + "googledrive_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive", + "gpb_flow_enabled": "False", + "help_page": "https://www.kobo.com/help", + "image_host": "//cdn.kobo.com/book-images/", + "image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", + "image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg", + "instapaper_enabled": "True", + "instapaper_env_url": "https://www.instapaper.com/api/kobo", + "instapaper_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkinstapaper", + "kobo_audiobooks_credit_redemption": "True", + "kobo_audiobooks_enabled": "True", + "kobo_audiobooks_orange_deal_enabled": "True", + "kobo_audiobooks_subscriptions_enabled": "True", + "kobo_display_price": "True", + "kobo_dropbox_link_account_enabled": "True", + "kobo_google_tax": "False", + "kobo_googledrive_link_account_enabled": "True", + "kobo_nativeborrow_enabled": "False", + "kobo_onedrive_link_account_enabled": "False", + "kobo_onestorelibrary_enabled": "False", + "kobo_privacyCentre_url": "https://www.kobo.com/privacy", + "kobo_redeem_enabled": "True", + "kobo_shelfie_enabled": "False", + "kobo_subscriptions_enabled": "True", + "kobo_superpoints_enabled": "False", + "kobo_wishlist_enabled": "True", + "library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}", + "library_items": "https://storeapi.kobo.com/v1/user/library", + "library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata", + "library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices", + "library_search": "https://storeapi.kobo.com/v1/library/search", + "library_sync": "https://storeapi.kobo.com/v1/library/sync", + "love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints", + "love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}", + "magazine_landing_page": "https://www.kobo.com/emagazines", + "more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=https://kobo.com/#allProviders", + "morebyauthor": "https://storeapi.kobo.com/v2/products/recommendations/morebyauthor", + "notebooks": "https://storeapi.kobo.com/api/internal/notebooks", + "notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration", + "oauth_host": "https://oauth.kobo.com", + "password_retrieval_page": "https://www.kobo.com/passwordretrieval.html", + "personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations", + "pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket", + "post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event", + "ppx_purchasing_url": "https://purchasing.kobo.com", + "privacy_page": "https://www.kobo.com/privacypolicy?style=onestore", + "product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread", + "product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices", + "product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations", + "product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews", + "productbyid": "https://storeapi.kobo.com/v2/products/itemDetailById/{ProductType}/{Id}", + "productbyslug": "https://storeapi.kobo.com/v2/products/itemDetail/{ProductType}/{Slug}", + "products": "https://storeapi.kobo.com/v1/products", + "productstatebyid": "https://storeapi.kobo.com/v2/products/itemStateById/{ProductType}/{Id}", + "productstatebyslug": "https://storeapi.kobo.com/v2/products/itemState/{ProductType}/{Slug}", + "productsv2": "https://storeapi.kobo.com/v2/products", + "provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=https://kobo.com/", + "purchase_buy": "https://www.kobo.com/checkoutoption/", + "purchase_buy_templated": "https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}", + "quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout", + "quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase", + "rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange", + "rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}", + "reading_services_host": "https://readingservices.kobo.com", + "reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state", + "redeem_interstitial_page": "https://www.kobo.com", + "reflowable_page_cache_enabled": "True", + "registration_page": "https://authorize.kobo.com/signup?returnUrl=https://kobo.com/", + "related": "https://storeapi.kobo.com/v2/products/recommendations/related", + "related_items": "https://storeapi.kobo.com/v1/products/{Id}/related", + "remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}", + "rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}", + "review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}", + "shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie", + "sign_in_page": "https://auth.kobobooks.com/ActivateOnWeb", + "social_authorization_host": "https://social.kobobooks.com:8443", + "social_host": "https://social.kobobooks.com", + "store_home": "www.kobo.com/{region}/{language}", + "store_host": "www.kobo.com", + "store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA", + "store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}", + "store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top", + "subs_landing_page": "https://www.kobo.com/{region}/{language}/plus", + "subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}", + "subscription_publisher_price_page": "https://www.kobo.com/{region}/{language}/subscriptionpublisherprice", + "tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items", + "tags": "https://storeapi.kobo.com/v1/library/tags", + "taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile", + "terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale", + "topproducts": "https://storeapi.kobo.com/v2/products/list/topproducts", + "tracking": "https://storeapi.kobo.com/v2/tracking/searchperformed", + "update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview", + "use_one_store": "True", + "user_currencyconversion": "https://storeapi.kobo.com/v1/user/currency/convert", + "user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits", + "user_platform": "https://storeapi.kobo.com/v1/user/platform", + "user_profile": "https://storeapi.kobo.com/v1/user/profile", + "user_ratings": "https://storeapi.kobo.com/v1/user/ratings", + "user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations", + "user_reviews": "https://storeapi.kobo.com/v1/user/reviews", + "user_tasteprofile_complete": "https://storeapi.kobo.com/v2/user/tasteprofile/complete", + "user_tasteprofile_genre": "https://storeapi.kobo.com/v2/user/tasteprofile/genre", + "user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist", + "userguide_host": "https://ereaderfiles.kobo.com", + "wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist" + } + """.trimIndent(), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt new file mode 100644 index 000000000..cf74dfacd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt @@ -0,0 +1,65 @@ +package org.gotson.komga.infrastructure.kobo + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.gotson.komga.domain.model.KomgaSyncToken +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.springframework.stereotype.Component +import java.util.Base64 + +private val logger = KotlinLogging.logger {} + +private const val KOMGA_TOKEN_PREFIX = "KOMGA." + +@Component +class KomgaSyncTokenGenerator( + private val objectMapper: ObjectMapper, +) { + private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() } + private val base64Decoder by lazy { Base64.getDecoder() } + + /** + * Convert any SyncToken to a [KomgaSyncToken]. + * The input SyncToken type depends on the String format: + * - the official Kobo store token is of the form `base64.base64` + * - the Calibre Web token is a single base64 string + * - the Komga token is a base64 string prefixed by `KOMGA.` + */ + fun fromBase64(base64Token: String): KomgaSyncToken { + try { + // check for a Komga token + if (base64Token.startsWith(KOMGA_TOKEN_PREFIX)) { + return objectMapper.readValue(base64Decoder.decode(base64Token.removePrefix(KOMGA_TOKEN_PREFIX))) + } + + // check for a Calibre Web token + if (!base64Token.contains('.')) { + try { + val json = objectMapper.readTree(base64Decoder.decode(base64Token)) + val koboToken = json.get("data").get("raw_kobo_store_token").asText() + return KomgaSyncToken(rawKoboSyncToken = koboToken) + } catch (e: Exception) { + logger.warn { "Failed to parse potential CalibreWeb token" } + } + } + + // check for a Kobo store token + if (base64Token.contains('.')) { + return KomgaSyncToken(rawKoboSyncToken = base64Token) + } + } catch (_: Exception) { + } + + // in last resort return a default token + return KomgaSyncToken() + } + + fun toBase64(token: KomgaSyncToken): String = KOMGA_TOKEN_PREFIX + base64Encoder.encodeToString(objectMapper.writeValueAsString(token).toByteArray()) + + fun fromRequestHeaders(request: HttpServletRequest): KomgaSyncToken? { + val syncTokenB64 = request.getHeader(X_KOBO_SYNCTOKEN) + return if (syncTokenB64 != null) fromBase64(syncTokenB64) else null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt new file mode 100644 index 000000000..e175ebc99 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.infrastructure.mediacontainer + +import org.apache.tika.config.TikaConfig +import org.apache.tika.io.TikaInputStream +import org.apache.tika.metadata.Metadata +import org.springframework.stereotype.Service +import java.io.InputStream +import java.nio.file.Path +import kotlin.io.path.name + +@Service +class ContentDetector( + private val tika: TikaConfig, +) { + fun detectMediaType(path: Path): String { + val metadata = + Metadata().also { + it[Metadata.TIKA_MIME_FILE] = path.name + } + + return TikaInputStream.get(path).use { + val mediaType = tika.detector.detect(it, metadata) + mediaType.toString() + } + } + + /** + * Detects the media type of the content of the stream. + * The stream will not be closed. + */ + fun detectMediaType(stream: InputStream): String = tika.detector.detect(stream, Metadata()).toString() + + fun isImage(mediaType: String): Boolean = mediaType.startsWith("image/") + + fun mediaTypeToExtension(mediaType: String): String? = + try { + tika.mimeRepository.forName(mediaType).extension + } catch (e: Exception) { + null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/TikaConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/TikaConfiguration.kt new file mode 100644 index 000000000..a31e208b4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/TikaConfiguration.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.infrastructure.mediacontainer + +import org.apache.tika.config.TikaConfig +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class TikaConfiguration { + @Bean + fun tika() = TikaConfig() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/DivinaExtractor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/DivinaExtractor.kt new file mode 100644 index 000000000..e987017c0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/DivinaExtractor.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.mediacontainer.divina + +import org.gotson.komga.domain.model.MediaContainerEntry +import org.gotson.komga.domain.model.MediaUnsupportedException +import java.nio.file.Path + +interface DivinaExtractor { + fun mediaTypes(): List + + @Throws(MediaUnsupportedException::class) + fun getEntries( + path: Path, + analyzeDimensions: Boolean, + ): List + + fun getEntryStream( + path: Path, + entryName: String, + ): ByteArray +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractor.kt new file mode 100644 index 000000000..b48af4c26 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractor.kt @@ -0,0 +1,65 @@ +package org.gotson.komga.infrastructure.mediacontainer.divina + +import com.github.junrar.Archive +import com.github.junrar.exception.WrongPasswordException +import io.github.oshai.kotlinlogging.KotlinLogging +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.gotson.komga.domain.model.MediaContainerEntry +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.springframework.stereotype.Service +import java.nio.file.Path + +private val logger = KotlinLogging.logger {} + +@Service +class RarExtractor( + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, +) : DivinaExtractor { + private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + + override fun mediaTypes(): List = listOf(MediaType.RAR_GENERIC.type, MediaType.RAR_4.type, MediaType.RAR_5.type) + + override fun getEntries( + path: Path, + analyzeDimensions: Boolean, + ): List = + try { + Archive(path.toFile()).use { rar -> + if (rar.isPasswordProtected) throw MediaUnsupportedException("Encrypted RAR archives are not supported", "ERR_1002") + if (rar.mainHeader?.isMultiVolume == true) throw MediaUnsupportedException("Multi-Volume RAR archives are not supported", "ERR_1004") + rar.fileHeaders + .filter { !it.isDirectory } + .map { entry -> + try { + val buffer = rar.getInputStream(entry).use { it.readBytes() } + val mediaType = buffer.inputStream().use { contentDetector.detectMediaType(it) } + val dimension = + if (analyzeDimensions && contentDetector.isImage(mediaType)) + buffer.inputStream().use { imageAnalyzer.getDimension(it) } + else + null + val fileSize = entry.fullUnpackSize + MediaContainerEntry(name = entry.fileName, mediaType = mediaType, dimension = dimension, fileSize = fileSize) + } catch (e: Exception) { + logger.warn(e) { "Could not analyze entry: ${entry.fileName}" } + MediaContainerEntry(name = entry.fileName, comment = e.message) + } + }.sortedWith(compareBy(natSortComparator) { it.name }) + } + } catch (_: WrongPasswordException) { + throw MediaUnsupportedException("Encrypted RAR archives are not supported", "ERR_1002") + } + + override fun getEntryStream( + path: Path, + entryName: String, + ): ByteArray = + Archive(path.toFile()).use { rar -> + val header = rar.fileHeaders.find { it.fileName == entryName } + rar.getInputStream(header).use { it.readBytes() } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractor.kt new file mode 100644 index 000000000..a49951213 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractor.kt @@ -0,0 +1,58 @@ +package org.gotson.komga.infrastructure.mediacontainer.divina + +import io.github.oshai.kotlinlogging.KotlinLogging +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipFile +import org.gotson.komga.domain.model.MediaContainerEntry +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.util.getZipEntryBytes +import org.gotson.komga.infrastructure.util.use +import org.springframework.stereotype.Service +import java.nio.file.Path + +private val logger = KotlinLogging.logger {} + +@Service +class ZipExtractor( + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, +) : DivinaExtractor { + private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + + override fun mediaTypes(): List = listOf(MediaType.ZIP.type) + + override fun getEntries( + path: Path, + analyzeDimensions: Boolean, + ): List = + ZipFile.builder().setPath(path).use { zip -> + zip.entries + .toList() + .filter { !it.isDirectory } + .map { entry -> + try { + zip.getInputStream(entry).buffered().use { stream -> + val mediaType = contentDetector.detectMediaType(stream) + val dimension = + if (analyzeDimensions && contentDetector.isImage(mediaType)) + imageAnalyzer.getDimension(stream) + else + null + val fileSize = if (entry.size == ArchiveEntry.SIZE_UNKNOWN) null else entry.size + MediaContainerEntry(name = entry.name, mediaType = mediaType, dimension = dimension, fileSize = fileSize) + } + } catch (e: Exception) { + logger.warn(e) { "Could not analyze entry: ${entry.name}" } + MediaContainerEntry(name = entry.name, comment = e.message) + } + }.sortedWith(compareBy(natSortComparator) { it.name }) + } + + override fun getEntryStream( + path: Path, + entryName: String, + ): ByteArray = getZipEntryBytes(path, entryName) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt new file mode 100644 index 000000000..da55f0339 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.apache.commons.compress.archivers.zip.ZipFile +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.infrastructure.util.getEntryInputStream +import org.gotson.komga.infrastructure.util.use +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.parser.Parser +import java.nio.file.Path +import java.nio.file.Paths + +data class EpubPackage( + val zip: ZipFile, + val opfDoc: Document, + val opfDir: Path?, + val manifest: Map, +) + +inline fun Path.epub(block: (EpubPackage) -> R): R = + ZipFile.builder().setPath(this).use { zip -> + val opfFile = zip.getPackagePath() + val opfDoc = zip.getEntryInputStream(opfFile)?.use { Jsoup.parse(it, null, "", Parser.xmlParser()) } ?: throw MediaUnsupportedException("Could not open OPF resource") + val opfDir = Paths.get(opfFile).parent + block(EpubPackage(zip, opfDoc, opfDir, opfDoc.getManifest())) + } + +/** + * Returns the zip entry path of the Epub package file + */ +fun ZipFile.getPackagePath(): String = + getEntryInputStream("META-INF/container.xml") + ?.use { Jsoup.parse(it, null, "", Parser.xmlParser()) } + ?.getElementsByTag("rootfile") + ?.first() + ?.attr("full-path") ?: throw MediaUnsupportedException("META-INF/container.xml does not contain rootfile tag") + +/** + * Returns the content of the Epub package file as a [String] + */ +fun getPackageFileContent(path: Path): String? = + ZipFile.builder().setPath(path).use { zip -> + try { + zip.getEntryInputStream(zip.getPackagePath())?.reader()?.use { it.readText() } + } catch (e: Exception) { + null + } + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub2Nav.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub2Nav.kt new file mode 100644 index 000000000..d12e86212 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub2Nav.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +enum class Epub2Nav( + val level1: String, + val level2: String, +) { + TOC("navMap", "navPoint"), + PAGELIST("pageList", "pageTarget"), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub3Nav.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub3Nav.kt new file mode 100644 index 000000000..6d4e85b65 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub3Nav.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +enum class Epub3Nav( + val value: String, +) { + TOC("toc"), + LANDMARKS("landmarks"), + PAGELIST("page-list"), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt new file mode 100644 index 000000000..0957dd88a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt @@ -0,0 +1,363 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.compress.archivers.ArchiveEntry +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.EpubTocEntry +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.kobo.KepubConverter +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.util.getEntryBytes +import org.gotson.komga.infrastructure.util.getEntryInputStream +import org.gotson.komga.infrastructure.util.getZipEntryBytes +import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.net.URLDecoder +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.invariantSeparatorsPathString +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.roundToInt + +private val logger = KotlinLogging.logger {} + +@Service +class EpubExtractor( + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, + private val kepubConverter: KepubConverter, + @param:Value("#{@komgaProperties.epubDivinaLetterCountThreshold}") private val letterCountThreshold: Int, +) { + /** + * Retrieves a specific entry by name from the zip archive + */ + fun getEntryStream( + path: Path, + entryName: String, + ): ByteArray = getZipEntryBytes(path, entryName) + + fun isEpub(path: Path): Boolean = + try { + getEntryStream(path, "mimetype").decodeToString().trim() == "application/epub+zip" + } catch (_: Exception) { + false + } + + /** + * Retrieves the book cover along with its mediaType from the epub 2/3 manifest + */ + fun getCover(path: Path): TypedBytes? = + path.epub { (zip, opfDoc, opfDir, manifest) -> + val coverManifestItem = + // EPUB 3 - try to get cover from manifest properties 'cover-image' + manifest.values.firstOrNull { it.properties.contains("cover-image") } + ?: // EPUB 2 - get cover from meta element with name="cover" + opfDoc + .selectFirst("*|metadata > *|meta[name=cover]") + ?.attr("content") + ?.ifBlank { null } + ?.let { manifest[it] } + ?: // try id="cover-image" + manifest.values.firstOrNull { it.id == "cover-image" } + if (coverManifestItem != null) { + val href = URLDecoder.decode(coverManifestItem.href, Charsets.UTF_8) + val mediaType = coverManifestItem.mediaType + val coverPath = normalizeHref(opfDir, href) + zip.getEntryBytes(coverPath)?.let { coverBytes -> + TypedBytes( + coverBytes, + mediaType, + ) + } + } else { + null + } + } + + fun getResources(epub: EpubPackage): List { + val spine = + epub.opfDoc + .select("*|spine > *|itemref") + .map { it.attr("idref") } + .mapNotNull { epub.manifest[it] } + + val pages = + spine.map { page -> + MediaFile( + normalizeHref(epub.opfDir, URLDecoder.decode(page.href, Charsets.UTF_8)), + page.mediaType, + MediaFile.SubType.EPUB_PAGE, + ) + } + + val assets = + epub.manifest.values.filterNot { spine.contains(it) }.map { + MediaFile( + normalizeHref(epub.opfDir, URLDecoder.decode(it.href, Charsets.UTF_8)), + it.mediaType, + MediaFile.SubType.EPUB_ASSET, + ) + } + + val zipEntries = epub.zip.entries.toList() + return (pages + assets).map { resource -> + resource.copy(fileSize = zipEntries.firstOrNull { it.name == resource.fileName }?.let { if (it.size == ArchiveEntry.SIZE_UNKNOWN) null else it.size }) + } + } + + fun getDivinaPages( + epub: EpubPackage, + analyzeDimensions: Boolean, + ): List { + val pageCount = + run { + val spine = + epub.opfDoc + .select("*|spine > *|itemref") + .map { it.attr("idref") } + .mapNotNull { idref -> epub.manifest[idref]?.href?.let { normalizeHref(epub.opfDir, it) } } + + epub.zip.entries + .toList() + .count { it.name in spine } + } + + val pagesWithImages = + epub.opfDoc + .select("*|spine > *|itemref") + .map { it.attr("idref") } + .mapNotNull { idref -> + val manifestItem = epub.manifest[idref] ?: return@mapNotNull null + normalizeHref(epub.opfDir, manifestItem.href) to manifestItem.mediaType + }.map { (pagePath, mediaType) -> + if (mediaType.startsWith("image", true)) { + // image in spine + listOf(Path(pagePath).normalize().invariantSeparatorsPathString) + } else { + val doc = epub.zip.getEntryInputStream(pagePath)?.use { Jsoup.parse(it, null, "", Parser.xmlParser()) } ?: return@map emptyList() + + // if a page has text over the threshold then the book is not divina compatible + if (doc.body().text().length > letterCountThreshold) return emptyList() + + val img = + doc + .getElementsByTag("img") + .map { it.attr("src") } // get the src, which can be a relative path + + val svg = + doc + .select("svg > image[xlink:href]") + .map { it.attr("xlink:href") } // get the source, which can be a relative path + + (img + svg).map { (Path(pagePath).parent ?: Path("")).resolve(it).normalize().invariantSeparatorsPathString } // resolve it against the page folder + } + } + + if (pagesWithImages.size != pageCount) { + logger.info { "Epub Divina detection failed: book has ${pagesWithImages.size} pages with images, but $pageCount total pages" } + return emptyList() + } + // Only keep unique image path for each page. KCC sometimes generates HTML pages with 5 times the same image. + val imagesPath = pagesWithImages.flatMap { it.distinct() } + if (imagesPath.size != pageCount) { + logger.info { "Epub Divina detection failed: book has ${imagesPath.size} detected images, but $pageCount total pages" } + return emptyList() + } + + val divinaPages = + imagesPath.mapNotNull { imagePath -> + val mediaType = + epub.manifest.values + .firstOrNull { normalizeHref(epub.opfDir, it.href) == imagePath } + ?.mediaType ?: return@mapNotNull null + val zipEntry = epub.zip.getEntry(imagePath) + if (!contentDetector.isImage(mediaType)) return@mapNotNull null + + val dimension = + if (analyzeDimensions) + epub.zip.getInputStream(zipEntry).use { imageAnalyzer.getDimension(it) } + else + null + val fileSize = if (zipEntry.size == ArchiveEntry.SIZE_UNKNOWN) null else zipEntry.size + BookPage(fileName = imagePath, mediaType = mediaType, dimension = dimension, fileSize = fileSize) + } + + if (divinaPages.size != pageCount) { + logger.info { "Epub Divina detection failed: book has ${divinaPages.size} detected divina pages, but $pageCount total pages" } + return emptyList() + } + return divinaPages + } + + fun isKepub( + epub: EpubPackage, + resources: List, + ): Boolean { + try { + val readingOrder = resources.filter { it.subType == MediaFile.SubType.EPUB_PAGE } + + readingOrder.forEach { mediaFile -> + val doc = epub.zip.getEntryInputStream(mediaFile.fileName)?.use { Jsoup.parse(it, null, "") } + if (!doc?.getElementsByClass("koboSpan").isNullOrEmpty()) return true + } + } catch (e: Exception) { + logger.warn(e) { "Error while checking if EPUB is KEPUB" } + } + return false + } + + /** + * Computes an approximate page count using the Readium method, + * which counts 1 page for every 1024 bytes of compressed data for each resource. + */ + fun computePageCount(epub: EpubPackage): Int { + val spine = + epub.opfDoc + .select("*|spine > *|itemref") + .map { it.attr("idref") } + .mapNotNull { idref -> epub.manifest[idref]?.href?.let { normalizeHref(epub.opfDir, it) } } + + return epub.zip.entries + .toList() + .filter { it.name in spine } + .sumOf { ceil(it.compressedSize / 1024.0).toInt() } + } + + fun isFixedLayout(epub: EpubPackage) = + epub.opfDoc.selectFirst("*|metadata > *|meta[property=rendition:layout]")?.text() == "pre-paginated" || + epub.opfDoc.selectFirst("*|metadata > *|meta[name=fixed-layout]")?.attr("content") == "true" + + fun computePositions( + epub: EpubPackage, + path: Path, + resources: List, + isFixedLayout: Boolean, + isKepub: Boolean, + ): List { + val readingOrder = resources.filter { it.subType == MediaFile.SubType.EPUB_PAGE } + + var startPosition = 1 + + val koboPositions = + when { + isFixedLayout -> emptyMap() + isKepub -> computePositionsFromKoboSpan(readingOrder) { filename -> epub.zip.getEntryInputStream(filename).use { it?.readBytes()?.decodeToString() } } + kepubConverter.isAvailable -> { + try { + val kepub = + kepubConverter + .convertEpubToKepubWithoutChecks(path) + ?.also { it.toFile().deleteOnExit() } + // if the conversion failed, throw an exception that will be caught in the catch block + ?: throw IllegalStateException() + val positions = computePositionsFromKoboSpan(readingOrder) { filename -> getZipEntryBytes(kepub, filename).decodeToString() } + kepub.deleteIfExists() + positions + } catch (_: Exception) { + logger.warn { "Could not convert to Kepub to compute positions: $path" } + emptyMap() + } + } + + else -> emptyMap() + } + + val positions = + if (isFixedLayout) { + // for fixed-layout book we create 1 position per page + readingOrder.map { + R2Locator( + href = it.fileName, + type = it.mediaType ?: "application/octet-stream", + koboSpan = "kobo.1.1", + locations = R2Locator.Location(progression = 0F, position = startPosition++), + ) + } + } else { + // this is the Readium algorithm + // we create 1 position every 1024 bytes + readingOrder.flatMap { file -> + val positionCount = maxOf(1, ceil((file.fileSize ?: 0) / 1024.0).roundToInt()) + (0 until positionCount).map { p -> + val progression = p.toFloat() / positionCount + val koboSpan = + if (positionCount == 1 || p == 0) + "kobo.1.1" + else + koboPositions[file.fileName] + ?.minByOrNull { (progression - it.second).absoluteValue } + ?.first + + R2Locator( + href = file.fileName, + type = file.mediaType ?: "application/octet-stream", + locations = R2Locator.Location(progression = progression, position = startPosition++), + koboSpan = koboSpan, + ) + } + } + } + + // finally we compute the total progression for each position + return positions.map { locator -> + val totalProgression = locator.locations?.position?.let { it.toFloat() / positions.size } + locator.copy(locations = locator.locations?.copy(totalProgression = totalProgression)) + } + } + + /** + * Builds the positions for a KEPUB book, based on koboSpan tags. + * @return a [Map] where the key is the resource name, and the value is a [List] of [Pair] containing the koboSpan ID and the progression as a Float between 0 and 1. + */ + private fun computePositionsFromKoboSpan( + readingOrder: List, + resourceSupplier: (String) -> String?, + ): Map>?> = + readingOrder.associate { file -> + val doc = resourceSupplier(file.fileName)?.let { resource -> Jsoup.parse(resource, Parser.htmlParser().setTrackPosition(true)) } + file.fileName to + doc?.select("span.koboSpan")?.mapNotNull { koboSpan -> + val id = koboSpan.id() + if (id.isNotBlank()) { + // progression is built from the position in the file of each koboSpan, divided by the file size + val progression = koboSpan.sourceRange().endPos().toFloat() / file.fileSize!!.toFloat() + Pair(id, progression) + } else { + null + } + } + } + + fun getToc(epub: EpubPackage): List { + // Epub 3 + epub.getNavResource()?.let { processNav(it, Epub3Nav.TOC) }?.let { if (it.isNotEmpty()) return it } + + // Epub 2 + epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.TOC) } + return emptyList() + } + + fun getPageList(epub: EpubPackage): List { + // Epub 3 + epub.getNavResource()?.let { processNav(it, Epub3Nav.PAGELIST) }?.let { if (it.isNotEmpty()) return it } + + // Epub 2 + epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.PAGELIST) } + return emptyList() + } + + fun getLandmarks(epub: EpubPackage): List { + // Epub 3 + epub.getNavResource()?.let { processNav(it, Epub3Nav.LANDMARKS) }?.let { if (it.isNotEmpty()) return it } + + // Epub 2 + return processOpfGuide(epub.opfDoc, epub.opfDir) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ManifestItem.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ManifestItem.kt new file mode 100644 index 000000000..10120cf0f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ManifestItem.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +data class ManifestItem( + val id: String, + val href: String, + val mediaType: String, + val properties: Set = emptySet(), +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Nav.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Nav.kt new file mode 100644 index 000000000..55942639a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Nav.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.gotson.komga.domain.model.EpubTocEntry +import org.gotson.komga.infrastructure.util.getEntryBytes +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import java.net.URLDecoder +import java.nio.file.Path +import kotlin.io.path.Path + +fun EpubPackage.getNavResource(): ResourceContent? = + manifest.values.firstOrNull { it.properties.contains("nav") }?.let { nav -> + val href = normalizeHref(opfDir, nav.href) + zip.getEntryBytes(href)?.decodeToString()?.let { navContent -> + ResourceContent(Path(href), navContent) + } + } + +fun processNav( + document: ResourceContent, + navElement: Epub3Nav, +): List { + val doc = Jsoup.parse(document.content, "", Parser.xmlParser()) + val nav = + doc + .select("nav") + // Jsoup selectors cannot find an attribute with namespace + .firstOrNull { it.attributes().any { attr -> attr.key.endsWith("type") && attr.value == navElement.value } } + return nav?.select(":root > ol > li")?.toList()?.mapNotNull { navLiElementToTocEntry(it, document.path.parent) } ?: emptyList() +} + +private fun navLiElementToTocEntry( + element: Element, + navDir: Path?, +): EpubTocEntry? { + val title = element.selectFirst(":root > a, span")?.text() + val href = element.selectFirst(":root > a")?.attr("href")?.let { URLDecoder.decode(it, Charsets.UTF_8) } + val children = element.select(":root > ol > li").mapNotNull { navLiElementToTocEntry(it, navDir) } + if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(navDir, it) }, children) + return null +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Ncx.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Ncx.kt new file mode 100644 index 000000000..047547997 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Ncx.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.gotson.komga.domain.model.EpubTocEntry +import org.gotson.komga.infrastructure.util.getEntryBytes +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import java.net.URLDecoder +import java.nio.file.Path +import kotlin.io.path.Path + +private val possibleNcxItemIds = listOf("toc", "ncx", "ncxtoc") + +fun EpubPackage.getNcxResource(): ResourceContent? = + (manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" } ?: manifest.values.firstOrNull { possibleNcxItemIds.contains(it.id) })?.let { ncx -> + val href = normalizeHref(opfDir, ncx.href) + zip.getEntryBytes(href)?.decodeToString()?.let { ncxContent -> + ResourceContent(Path(href), ncxContent) + } + } + +fun processNcx( + document: ResourceContent, + navType: Epub2Nav, +): List = + Jsoup + .parse(document.content, "", Parser.xmlParser()) + .select("${navType.level1} > ${navType.level2}") + .toList() + .mapNotNull { ncxElementToTocEntry(navType, it, document.path.parent) } + +private fun ncxElementToTocEntry( + navType: Epub2Nav, + element: Element, + ncxDir: Path?, +): EpubTocEntry? { + val title = element.selectFirst("navLabel > text")?.text() + val href = element.selectFirst("content")?.attr("src")?.let { URLDecoder.decode(it, Charsets.UTF_8) } + val children = element.select(":root > ${navType.level2}").toList().mapNotNull { ncxElementToTocEntry(navType, it, ncxDir) } + if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(ncxDir, it) }, children) + return null +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Opf.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Opf.kt new file mode 100644 index 000000000..be7f004d8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Opf.kt @@ -0,0 +1,46 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.gotson.komga.domain.model.EpubTocEntry +import org.jsoup.nodes.Document +import java.net.URLDecoder +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.invariantSeparatorsPathString + +fun Document.getManifest() = + select("*|manifest > *|item").associate { + it.attr("id") to + ManifestItem( + it.attr("id"), + it.attr("href"), + it.attr("media-type"), + it.attr("properties").split(" ").toSet(), + ) + } + +fun normalizeHref( + opfDir: Path?, + href: String, +): String { + val anchor = href.substringAfterLast("#", "") + val base = href.substringBeforeLast("#") + val resolvedPath = (opfDir?.resolve(base)?.normalize() ?: Paths.get(base)).invariantSeparatorsPathString + return resolvedPath + if (anchor.isNotBlank()) "#$anchor" else "" +} + +/** + * Process an OPF document and extracts TOC entries + * from the section. + */ +fun processOpfGuide( + opf: Document, + opfDir: Path?, +): List { + val guide = opf.selectFirst("*|guide") ?: return emptyList() + return guide.select("*|reference").map { ref -> + EpubTocEntry( + ref.attr("title"), + ref.attr("href").ifBlank { null }?.let { normalizeHref(opfDir, URLDecoder.decode(it, Charsets.UTF_8)) }, + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ResourceContent.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ResourceContent.kt new file mode 100644 index 000000000..7423a046d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/ResourceContent.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import java.nio.file.Path + +data class ResourceContent( + val path: Path, + val content: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractor.kt new file mode 100644 index 000000000..ced2649b2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractor.kt @@ -0,0 +1,80 @@ +package org.gotson.komga.infrastructure.mediacontainer.pdf + +import org.apache.pdfbox.Loader +import org.apache.pdfbox.multipdf.PageExtractor +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.rendering.ImageType.RGB +import org.apache.pdfbox.rendering.PDFRenderer +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.MediaContainerEntry +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import java.io.ByteArrayOutputStream +import java.nio.file.Path +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +@Service +class PdfExtractor( + @Qualifier("pdfImageType") + private val imageType: ImageType, + @Qualifier("pdfResolution") + private val resolution: Float, +) { + fun getPages( + path: Path, + analyzeDimensions: Boolean, + ): List = + Loader.loadPDF(path.toFile()).use { pdf -> + (0 until pdf.numberOfPages).map { index -> + val page = pdf.getPage(index) + val dimension = if (analyzeDimensions) Dimension(page.cropBox.width.roundToInt(), page.cropBox.height.roundToInt()) else null + MediaContainerEntry(name = "${index + 1}", dimension = dimension) + } + } + + fun getPageContentAsImage( + path: Path, + pageNumber: Int, + ): TypedBytes { + Loader.loadPDF(path.toFile()).use { pdf -> + val page = pdf.getPage(pageNumber - 1) + val image = PDFRenderer(pdf).renderImage(pageNumber - 1, page.getScale(), RGB) + val bytes = + ByteArrayOutputStream().use { out -> + ImageIO.write(image, imageType.imageIOFormat, out) + out.toByteArray() + } + return TypedBytes(bytes, imageType.mediaType) + } + } + + fun getPageContentAsPdf( + path: Path, + pageNumber: Int, + ): TypedBytes { + Loader.loadPDF(path.toFile()).use { pdf -> + val bytes = + ByteArrayOutputStream().use { out -> + PageExtractor(pdf, pageNumber, pageNumber).extract().save(out) + out.toByteArray() + } + return TypedBytes(bytes, MediaType.PDF.type) + } + } + + private fun PDPage.getScale() = getScale(cropBox.width, cropBox.height) + + private fun getScale( + width: Float, + height: Float, + ) = resolution / minOf(width, height) + + fun scaleDimension(dimension: Dimension): Dimension { + val scale = getScale(dimension.width.toFloat(), dimension.height.toFloat()) + return Dimension((dimension.width * scale).roundToInt(), (dimension.height * scale).roundToInt()) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt new file mode 100644 index 000000000..97f75f5f8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia + +interface BookMetadataProvider : MetadataProvider { + val capabilities: Set + + fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/MetadataProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/MetadataProvider.kt new file mode 100644 index 000000000..898ba6c9c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/MetadataProvider.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MetadataPatchTarget + +interface MetadataProvider { + fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataFromBookProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataFromBookProvider.kt new file mode 100644 index 000000000..e75e682a3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataFromBookProvider.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.SeriesMetadataPatch + +interface SeriesMetadataFromBookProvider : MetadataProvider { + val supportsAppendVolume: Boolean + + fun getSeriesMetadataFromBook( + book: BookWithMedia, + appendVolumeToTitle: Boolean, + ): SeriesMetadataPatch? +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt new file mode 100644 index 000000000..89a593c80 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadataPatch + +interface SeriesMetadataProvider : MetadataProvider { + fun getSeriesMetadata(series: Series): SeriesMetadataPatch? +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt new file mode 100644 index 000000000..dfd4d15a6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt @@ -0,0 +1,91 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.validator.routines.ISBNValidator +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.springframework.stereotype.Service +import java.util.EnumSet +import javax.imageio.ImageIO + +private val logger = KotlinLogging.logger {} + +private const val PAGES_LAST = 3 +private const val PAGES_FIRST = 3 + +@Service +class IsbnBarcodeProvider( + private val bookAnalyzer: BookAnalyzer, + private val validator: ISBNValidator, +) : BookMetadataProvider { + private val hints = + mapOf( + DecodeHintType.POSSIBLE_FORMATS to EnumSet.of(BarcodeFormat.EAN_13), + DecodeHintType.TRY_HARDER to true, + ) + + override val capabilities = setOf(BookMetadataPatchCapability.ISBN) + + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + if (book.media.profile == MediaProfile.EPUB) return null + + val pagesToTry = + (1..book.media.pageCount).toList().let { + (it.takeLast(PAGES_LAST).reversed() + it.take(PAGES_FIRST)).distinct() + } + + for (p in pagesToTry) { + try { + val imageBytes = bookAnalyzer.getPageContent(book, p) + ImageIO.read(imageBytes.inputStream())?.let { image -> + val pixels = image.getRGB(0, 0, image.width, image.height, null, 0, image.width) + val source = RGBLuminanceSource(image.width, image.height, pixels) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + + val result = + try { + MultiFormatReader().decode(bitmap, hints) + } catch (e: Exception) { + null + } + + if (result == null || result.text == null) { + logger.debug { "Book page $p does not contain a barcode: $book" } + } else { + if (validator.isValid(result.text)) { + logger.debug { "Book page $p contains barcode which is valid ISBN: '${result.text}'. $book" } + return BookMetadataPatch(isbn = validator.validate(result.text)) + } else { + logger.debug { "Book page $p contains barcode which is invalid ISBN: '${result.text}'. $book" } + } + } + } + } catch (e: Exception) { + logger.error(e) { "Error while processing page" } + } + } + + return null + } + + override fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean = + when (target) { + MetadataPatchTarget.BOOK -> library.importBarcodeIsbn + else -> false + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt new file mode 100644 index 000000000..2fbc89f0b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import org.apache.commons.validator.routines.ISBNValidator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class IsbnConfiguration { + @Bean + fun isbnValidator() = ISBNValidator(true) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt new file mode 100644 index 000000000..1262a1958 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt @@ -0,0 +1,201 @@ +package org.gotson.komga.infrastructure.metadata.comicrack + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.validator.routines.ISBNValidator +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BCP47TagValidator +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider +import org.gotson.komga.infrastructure.metadata.comicrack.dto.ComicInfo +import org.gotson.komga.infrastructure.metadata.comicrack.dto.Manga +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.net.URI +import java.time.LocalDate + +private val logger = KotlinLogging.logger {} + +private const val COMIC_INFO = "ComicInfo.xml" + +@Service +class ComicInfoProvider( + @param:Autowired(required = false) private val mapper: XmlMapper = XmlMapper(), + private val bookAnalyzer: BookAnalyzer, + private val isbnValidator: ISBNValidator, +) : BookMetadataProvider, + SeriesMetadataFromBookProvider { + override val capabilities = + setOf( + BookMetadataPatchCapability.TITLE, + BookMetadataPatchCapability.SUMMARY, + BookMetadataPatchCapability.NUMBER, + BookMetadataPatchCapability.NUMBER_SORT, + BookMetadataPatchCapability.RELEASE_DATE, + BookMetadataPatchCapability.AUTHORS, + BookMetadataPatchCapability.READ_LISTS, + BookMetadataPatchCapability.LINKS, + ) + + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> + val releaseDate = + comicInfo.year?.let { + LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, comicInfo.day ?: 1) + } + + val authors = mutableListOf() + comicInfo.writer?.splitWithRole("writer")?.let { authors += it } + comicInfo.penciller?.splitWithRole("penciller")?.let { authors += it } + comicInfo.inker?.splitWithRole("inker")?.let { authors += it } + comicInfo.colorist?.splitWithRole("colorist")?.let { authors += it } + comicInfo.letterer?.splitWithRole("letterer")?.let { authors += it } + comicInfo.coverArtist?.splitWithRole("cover")?.let { authors += it } + comicInfo.editor?.splitWithRole("editor")?.let { authors += it } + comicInfo.translator?.splitWithRole("translator")?.let { authors += it } + + val readLists = mutableListOf() + if (!comicInfo.alternateSeries.isNullOrBlank()) { + readLists.add( + BookMetadataPatch.ReadListEntry( + comicInfo.alternateSeries!!, + comicInfo.alternateNumber?.toIntOrNull(), + ), + ) + } + + comicInfo.storyArc?.let { value -> + // get list of arcs and corresponding number, split by `,` + val arcs = value.split(",").map { it.trim().ifBlank { null } } + val numbers = comicInfo.storyArcNumber?.split(",")?.map { it.trim().toIntOrNull() } + + if (!numbers.isNullOrEmpty()) { + // if there is associated numbers, add each valid association as a read list entry + (arcs zip numbers).forEach { (arc, number) -> + if (arc != null && number != null) readLists.add(BookMetadataPatch.ReadListEntry(arc, number)) + } + } else { + // if there is no numbers, only use the arcs name + readLists.addAll(arcs.filterNotNull().map { BookMetadataPatch.ReadListEntry(it) }) + } + } + + val links = + comicInfo.web + ?.split(" ") + ?.filter { it.isNotBlank() } + ?.mapNotNull { + try { + URI(it.trim()).let { uri -> WebLink(uri.host, uri) } + } catch (e: Exception) { + logger.error(e) { "Could not parse Web element as valid URI: $it" } + null + } + } + + val tags = comicInfo.tags?.split(',')?.mapNotNull { it.trim().lowercase().ifBlank { null } } + + val isbn = comicInfo.gtin?.let { isbnValidator.validate(it) } + + return BookMetadataPatch( + title = comicInfo.title?.ifBlank { null }, + summary = comicInfo.summary?.ifBlank { null }, + number = comicInfo.number?.ifBlank { null }, + numberSort = comicInfo.number?.toFloatOrNull(), + releaseDate = releaseDate, + authors = authors.ifEmpty { null }, + readLists = readLists, + links = links?.ifEmpty { null }, + tags = if (!tags.isNullOrEmpty()) tags.toSet() else null, + isbn = isbn, + ) + } + return null + } + + override val supportsAppendVolume = true + + override fun getSeriesMetadataFromBook( + book: BookWithMedia, + appendVolumeToTitle: Boolean, + ): SeriesMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> + val readingDirection = + when (comicInfo.manga) { + Manga.NO -> SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT + Manga.YES_AND_RIGHT_TO_LEFT -> SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT + else -> null + } + + val genres = comicInfo.genre?.split(',')?.mapNotNull { it.trim().ifBlank { null } } + val series = if (appendVolumeToTitle) computeSeriesFromSeriesAndVolume(comicInfo.series, comicInfo.volume) else comicInfo.series + + return SeriesMetadataPatch( + title = series, + titleSort = series, + status = null, + summary = null, + readingDirection = readingDirection, + publisher = comicInfo.publisher?.ifBlank { null }, + ageRating = comicInfo.ageRating?.ageRating, + language = if (comicInfo.languageISO != null && BCP47TagValidator.isValid(comicInfo.languageISO!!)) BCP47TagValidator.normalize(comicInfo.languageISO!!) else null, + genres = if (!genres.isNullOrEmpty()) genres.toSet() else null, + totalBookCount = comicInfo.count, + collections = + comicInfo.seriesGroup + ?.split(',') + ?.mapNotNull { it.trim().ifBlank { null } } + ?.toSet() ?: emptySet(), + ) + } + return null + } + + override fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean = + when (target) { + MetadataPatchTarget.BOOK -> library.importComicInfoBook + MetadataPatchTarget.SERIES -> library.importComicInfoSeries + MetadataPatchTarget.READLIST -> library.importComicInfoReadList + MetadataPatchTarget.COLLECTION -> library.importComicInfoCollection + } + + private fun getComicInfo(book: BookWithMedia): ComicInfo? { + try { + if (book.media.files.none { it.fileName == COMIC_INFO }) { + logger.debug { "Book does not contain any $COMIC_INFO file: $book" } + return null + } + + val fileContent = bookAnalyzer.getFileContent(book, COMIC_INFO) + return mapper.readValue(fileContent, ComicInfo::class.java) + } catch (e: Exception) { + logger.error(e) { "Error while retrieving metadata from $COMIC_INFO" } + return null + } + } + + private fun String.splitWithRole(role: String) = + split(',').mapNotNull { it.trim().ifBlank { null } }.let { list -> + if (list.isNotEmpty()) list.map { Author(it, role) } else null + } +} + +fun computeSeriesFromSeriesAndVolume( + series: String?, + volume: Int?, +): String? = + series?.ifBlank { null }?.let { s -> + s + (volume?.let { if (it != 1) " ($it)" else "" } ?: "") + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt new file mode 100644 index 000000000..fed9aecc5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.infrastructure.metadata.comicrack + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.ComicRackListException +import org.gotson.komga.domain.model.ReadListRequest +import org.gotson.komga.domain.model.ReadListRequestBook +import org.gotson.komga.infrastructure.metadata.comicrack.dto.ReadingList +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class ReadListProvider( + @param:Autowired(required = false) private val mapper: XmlMapper = XmlMapper(), +) { + @Throws(ComicRackListException::class) + fun importFromCbl(cbl: ByteArray): ReadListRequest { + val readingList = + try { + mapper.readValue(cbl, ReadingList::class.java) + } catch (e: Exception) { + logger.error(e) { "Error while trying to parse ComicRack ReadingList" } + throw ComicRackListException("Error while trying to parse ComicRack ReadingList", "ERR_1015") + } + logger.debug { "Trying to convert ComicRack ReadingList to ReadListRequest: $readingList" } + + if (readingList.name.isNullOrBlank()) throw ComicRackListException("ReadingList has no Name element", "ERR_1030") + if (readingList.books.isEmpty()) throw ComicRackListException("ReadingList does not contain any Book element", "ERR_1029") + + val books = + readingList.books.map { + if (it.series.isNullOrBlank() || it.number == null) throw ComicRackListException("Book is missing series or number: $it", "ERR_1031") + val series = setOfNotNull(computeSeriesFromSeriesAndVolume(it.series, it.volume), it.series?.ifBlank { null }) + ReadListRequestBook(series, it.number!!.trim()) + } + + return ReadListRequest(name = readingList.name!!, books = books) + .also { logger.debug { "Converted request: $it" } } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/AgeRating.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/AgeRating.kt new file mode 100644 index 000000000..ac82cd306 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/AgeRating.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonCreator + +enum class AgeRating( + val value: String, + val ageRating: Int? = null, +) { + UNKNOWN("Unknown"), + ADULTS_ONLY_18("Adults Only 18+", 18), + EARLY_CHILDHOOD("Early Childhood", 3), + EVERYONE("Everyone", 0), + EVERYONE_10("Everyone 10+", 10), + G("G", 0), + KIDS_TO_ADULTS("Kids to Adults", 6), + M("M", 17), + MA_15("MA 15+", 15), + MATURE_17("Mature 17+", 17), + PG("PG", 8), + R_18("R18+", 18), + RATING_PENDING("Rating Pending"), + TEEN("Teen", 13), + X_18("X18+", 18), + ; + + companion object { + private val map = entries.associateBy { it.value.toLowerNoSpace() } + + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value.toLowerNoSpace()] + + private fun String.toLowerNoSpace() = lowercase().replace(" ", "") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt new file mode 100644 index 000000000..568005723 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt @@ -0,0 +1,24 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +class Book { + @JsonProperty(value = "Series") + var series: String? = null + + @JsonProperty(value = "Number") + var number: String? = null + + @JsonProperty(value = "Volume") + var volume: Int? = null + + @JsonProperty(value = "Year") + var year: Int? = null + + @JsonProperty(value = "FileName") + var fileName: String? = null + + override fun toString(): String = "Book(series=$series, number=$number, volume=$volume, year=$year, fileName=$fileName)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfo.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfo.kt new file mode 100644 index 000000000..0d47dccbe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfo.kt @@ -0,0 +1,130 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.xml.bind.annotation.XmlSchemaType + +@JsonIgnoreProperties(ignoreUnknown = true) +class ComicInfo { + @JsonProperty(value = "Title") + var title: String? = null + + @JsonProperty(value = "Series") + var series: String? = null + + @JsonProperty(value = "Number") + var number: String? = null + + @JsonProperty(value = "Count") + var count: Int? = null + + @JsonProperty(value = "Volume") + var volume: Int? = null + + @JsonProperty(value = "AlternateSeries") + var alternateSeries: String? = null + + @JsonProperty(value = "AlternateNumber") + var alternateNumber: String? = null + + @JsonProperty(value = "AlternateCount") + var alternateCount: Int? = null + + @JsonProperty(value = "Summary") + var summary: String? = null + + @JsonProperty(value = "Notes") + var notes: String? = null + + @JsonProperty(value = "Year") + var year: Int? = null + + @JsonProperty(value = "Month") + var month: Int? = null + + @JsonProperty(value = "Day") + var day: Int? = null + + @JsonProperty(value = "Writer") + var writer: String? = null + + @JsonProperty(value = "Penciller") + var penciller: String? = null + + @JsonProperty(value = "Inker") + var inker: String? = null + + @JsonProperty(value = "Colorist") + var colorist: String? = null + + @JsonProperty(value = "Letterer") + var letterer: String? = null + + @JsonProperty(value = "CoverArtist") + var coverArtist: String? = null + + @JsonProperty(value = "Editor") + var editor: String? = null + + @JsonProperty(value = "Translator") + var translator: String? = null + + @JsonProperty(value = "Publisher") + var publisher: String? = null + + @JsonProperty(value = "Imprint") + var imprint: String? = null + + @JsonProperty(value = "Genre") + var genre: String? = null + + @JsonProperty(value = "Tags") + var tags: String? = null + + @JsonProperty(value = "Web") + var web: String? = null + + @JsonProperty(value = "PageCount") + var pageCount: Int? = null + + @JsonProperty(value = "LanguageISO") + var languageISO: String? = null + + @JsonProperty(value = "Format") + var format: String? = null + + @JsonProperty(value = "BlackAndWhite", defaultValue = "Unknown") + @XmlSchemaType(name = "string") + var blackAndWhite: YesNo? = null + + @JsonProperty(value = "Manga", defaultValue = "Unknown") + @XmlSchemaType(name = "string") + var manga: Manga? = null + + @JsonProperty(value = "Characters") + var characters: String? = null + + @JsonProperty(value = "Teams") + var teams: String? = null + + @JsonProperty(value = "Locations") + var locations: String? = null + + @JsonProperty(value = "ScanInformation") + var scanInformation: String? = null + + @JsonProperty(value = "StoryArc") + var storyArc: String? = null + + @JsonProperty(value = "StoryArcNumber") + var storyArcNumber: String? = null + + @JsonProperty(value = "SeriesGroup") + var seriesGroup: String? = null + + @JsonProperty(value = "AgeRating", defaultValue = "Unknown") + var ageRating: AgeRating? = null + + @JsonProperty(value = "GTIN") + var gtin: String? = null +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Manga.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Manga.kt new file mode 100644 index 000000000..19f3dcca9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Manga.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonCreator + +enum class Manga( + private val value: String, +) { + UNKNOWN("Unknown"), + NO("No"), + YES("Yes"), + YES_AND_RIGHT_TO_LEFT("YesAndRightToLeft"), + ; + + companion object { + private val map = entries.associateBy(Manga::value) + + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value] + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingList.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingList.kt new file mode 100644 index 000000000..575e0d173 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingList.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +class ReadingList { + @JsonProperty(value = "Name") + var name: String? = null + + @JacksonXmlElementWrapper(useWrapping = true) + @JacksonXmlProperty(localName = "Books") + @JsonSetter(nulls = Nulls.AS_EMPTY) + var books: List = emptyList() + + override fun toString(): String = "ReadingList(name=$name, books=$books)" +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/YesNo.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/YesNo.kt new file mode 100644 index 000000000..f90726a97 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/YesNo.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.annotation.JsonCreator + +enum class YesNo( + val value: String, +) { + UNKNOWN("Unknown"), + NO("No"), + YES("Yes"), + ; + + companion object { + private val map = entries.associateBy(YesNo::value) + + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value] + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt new file mode 100644 index 000000000..4dacba0cc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt @@ -0,0 +1,179 @@ +package org.gotson.komga.infrastructure.metadata.epub + +import org.apache.commons.validator.routines.ISBNValidator +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BCP47TagValidator +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFileContent +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider +import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.jsoup.safety.Safelist +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Service +class EpubMetadataProvider( + private val isbnValidator: ISBNValidator, +) : BookMetadataProvider, + SeriesMetadataFromBookProvider { + private val relators = + mapOf( + "aut" to "writer", + "clr" to "colorist", + "cov" to "cover", + "edt" to "editor", + "art" to "penciller", + "ill" to "penciller", + "trl" to "translator", + ) + + override val capabilities = + setOf( + BookMetadataPatchCapability.TITLE, + BookMetadataPatchCapability.SUMMARY, + BookMetadataPatchCapability.RELEASE_DATE, + BookMetadataPatchCapability.AUTHORS, + BookMetadataPatchCapability.ISBN, + ) + + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + if (book.media.mediaType != MediaType.EPUB.type) return null + getPackageFileContent(book.book.path)?.let { packageFile -> + val opf = Jsoup.parse(packageFile, "", Parser.xmlParser()) + + val title = opf.selectFirst("*|metadata > *|title")?.text()?.ifBlank { null } + val description = + opf + .selectFirst("*|metadata > *|description") + ?.text() + ?.let { Jsoup.clean(it, Safelist.none()) } + ?.ifBlank { null } + val date = opf.selectFirst("*|metadata > *|date")?.text()?.let { parseDate(it) } + + val authorRoles = + opf + .select("*|metadata > *|meta[property=role][scheme=marc:relators]") + .associate { it.attr("refines").removePrefix("#") to it.text() } + val authors = + opf + .select("*|metadata > *|creator") + .mapNotNull { el -> + val name = el.text().trim() + if (name.isBlank()) { + null + } else { + val opfRole = el.attr("opf:role").ifBlank { null } + val id = el.attr("id").ifBlank { null } + val refineRole = authorRoles[id]?.ifBlank { null } + Author(name, relators[opfRole ?: refineRole] ?: "writer") + } + }.ifEmpty { null } + + val isbn = + opf + .select("*|metadata > *|identifier") + .map { it.text().lowercase().removePrefix("isbn:") } + .firstNotNullOfOrNull { isbnValidator.validate(it) } + + val seriesIndex = + opf + .selectFirst("*|metadata > *|meta[property=belongs-to-collection]") + ?.attr("id") + ?.let { id -> + opf.selectFirst("*|metadata > *|meta[refines=#$id][property=group-position]") + }?.text() + + return BookMetadataPatch( + title = title, + summary = description, + releaseDate = date, + authors = authors, + isbn = isbn, + number = seriesIndex?.ifBlank { null }, + numberSort = seriesIndex?.toFloatOrNull(), + ) + } + return null + } + + override val supportsAppendVolume = false + + override fun getSeriesMetadataFromBook( + book: BookWithMedia, + appendVolumeToTitle: Boolean, + ): SeriesMetadataPatch? { + if (book.media.mediaType != MediaType.EPUB.type) return null + getPackageFileContent(book.book.path)?.let { packageFile -> + val opf = Jsoup.parse(packageFile, "", Parser.xmlParser()) + + val series = opf.selectFirst("*|metadata > *|meta[property=belongs-to-collection]")?.text()?.ifBlank { null } + val publisher = opf.selectFirst("*|metadata > *|publisher")?.text()?.ifBlank { null } + val language = opf.selectFirst("*|metadata > *|language")?.text()?.ifBlank { null } + val genres = + opf + .select("*|metadata > *|subject") + .mapNotNull { it.text().trim().ifBlank { null } } + .toSet() + .ifEmpty { null } + + val direction = + opf.selectFirst("*|spine")?.attr("page-progression-direction")?.let { + when (it) { + "rtl" -> SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT + "ltr" -> SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT + else -> null + } + } + + return SeriesMetadataPatch( + title = series, + titleSort = series, + status = null, + readingDirection = direction, + publisher = publisher, + ageRating = null, + summary = null, + language = if (language != null && BCP47TagValidator.isValid(language)) BCP47TagValidator.normalize(language) else null, + genres = genres, + totalBookCount = null, + collections = emptySet(), + ) + } + return null + } + + override fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean = + when (target) { + MetadataPatchTarget.BOOK -> library.importEpubBook + MetadataPatchTarget.SERIES -> library.importEpubSeries + else -> false + } + + private fun parseDate(date: String): LocalDate? = + try { + LocalDate.parse(date, DateTimeFormatter.ISO_DATE) + } catch (e: Exception) { + try { + LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + try { + LocalDate.parse(date, DateTimeFormatter.ISO_DATE_TIME) + } catch (e: Exception) { + null + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt new file mode 100644 index 000000000..edba913d9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt @@ -0,0 +1,108 @@ +package org.gotson.komga.infrastructure.metadata.localartwork + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.sidecar.SidecarBookConsumer +import org.gotson.komga.infrastructure.sidecar.SidecarSeriesConsumer +import org.springframework.stereotype.Service +import java.nio.file.Files +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlin.io.path.inputStream +import kotlin.io.path.nameWithoutExtension +import kotlin.streams.asSequence + +private val logger = KotlinLogging.logger {} + +@Service +class LocalArtworkProvider( + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, +) : SidecarSeriesConsumer, + SidecarBookConsumer { + val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn", "webp", "gif") + val supportedSeriesFiles = listOf("cover", "default", "folder", "poster", "series") + + fun getBookThumbnails(book: Book): List { + logger.info { "Looking for local thumbnails for book: $book" } + val bookPath = book.path + val baseName = bookPath.nameWithoutExtension + + val regex = "${Regex.escape(baseName)}(-\\d+)?".toRegex(RegexOption.IGNORE_CASE) + + return Files.list(bookPath.parent).use { dirStream -> + dirStream + .asSequence() + .filter { Files.isRegularFile(it) } + .filter { regex.matches(it.nameWithoutExtension) } + .filter { supportedExtensions.contains(it.extension.lowercase()) } + .filter { contentDetector.isImage(contentDetector.detectMediaType(it)) } + .mapIndexed { index, path -> + logger.info { "Found file: $path" } + ThumbnailBook( + url = path.toUri().toURL(), + type = ThumbnailBook.Type.SIDECAR, + bookId = book.id, + selected = index == 0, + fileSize = path.fileSize(), + mediaType = contentDetector.detectMediaType(path), + dimension = imageAnalyzer.getDimension(path.inputStream()) ?: Dimension(0, 0), + ) + }.toList() + } + } + + fun getSeriesThumbnails(series: Series): List { + if (series.oneshot) { + logger.debug { "Disabled for oneshot series, skipping" } + return emptyList() + } + + logger.info { "Looking for local thumbnails for series: $series" } + + return Files.list(series.path).use { dirStream -> + dirStream + .asSequence() + .filter { Files.isRegularFile(it) } + .filter { supportedSeriesFiles.contains(it.nameWithoutExtension.lowercase()) } + .filter { supportedExtensions.contains(it.extension.lowercase()) } + .filter { contentDetector.isImage(contentDetector.detectMediaType(it)) } + .mapIndexed { index, path -> + logger.info { "Found file: $path" } + ThumbnailSeries( + url = path.toUri().toURL(), + seriesId = series.id, + selected = index == 0, + type = ThumbnailSeries.Type.SIDECAR, + fileSize = path.fileSize(), + mediaType = contentDetector.detectMediaType(path), + dimension = imageAnalyzer.getDimension(path.inputStream()) ?: Dimension(0, 0), + ) + }.toList() + } + } + + override fun getSidecarBookType(): Sidecar.Type = Sidecar.Type.ARTWORK + + override fun getSidecarBookPrefilter(): List = supportedExtensions.map { ext -> ".*(-\\d+)?\\.$ext".toRegex(RegexOption.IGNORE_CASE) } + + override fun isSidecarBookMatch( + basename: String, + sidecar: String, + ): Boolean = "${Regex.escape(basename)}(-\\d+)?".toRegex(RegexOption.IGNORE_CASE).matches(FilenameUtils.getBaseName(sidecar)) + + override fun getSidecarSeriesType(): Sidecar.Type = Sidecar.Type.ARTWORK + + override fun getSidecarSeriesFilenames(): List = + supportedSeriesFiles.flatMap { filename -> + supportedExtensions.map { ext -> "$filename.$ext" } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProvider.kt new file mode 100644 index 000000000..f67742f4c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProvider.kt @@ -0,0 +1,82 @@ +package org.gotson.komga.infrastructure.metadata.mylar + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider +import org.gotson.komga.infrastructure.metadata.mylar.dto.Status +import org.gotson.komga.infrastructure.sidecar.SidecarSeriesConsumer +import org.springframework.stereotype.Service +import kotlin.io.path.notExists +import org.gotson.komga.infrastructure.metadata.mylar.dto.Series as MylarSeries + +private val logger = KotlinLogging.logger {} + +private const val SERIES_JSON = "series.json" + +@Service +class MylarSeriesProvider( + private val mapper: ObjectMapper, +) : SeriesMetadataProvider, + SidecarSeriesConsumer { + override fun getSeriesMetadata(series: Series): SeriesMetadataPatch? { + if (series.oneshot) { + logger.debug { "Disabled for oneshot series, skipping" } + return null + } + + try { + val seriesJsonPath = series.path.resolve(SERIES_JSON) + if (seriesJsonPath.notExists()) { + logger.debug { "Series folder does not contain any $SERIES_JSON file: $series" } + return null + } + val metadata = mapper.readValue(seriesJsonPath.toFile(), MylarSeries::class.java).metadata + + val title = + if (metadata.volume == null || metadata.volume == 1) + metadata.name + else + "${metadata.name} (${metadata.year})" + + return SeriesMetadataPatch( + title = title, + titleSort = title, + status = + when (metadata.status) { + Status.Ended -> SeriesMetadata.Status.ENDED + Status.Continuing -> SeriesMetadata.Status.ONGOING + }, + summary = metadata.descriptionFormatted ?: metadata.descriptionText, + readingDirection = null, + publisher = metadata.publisher, + ageRating = metadata.ageRating?.ageRating, + language = null, + genres = null, + totalBookCount = metadata.totalIssues, + collections = emptySet(), + ) + } catch (e: Exception) { + logger.error(e) { "Error while retrieving metadata from $SERIES_JSON" } + return null + } + } + + override fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean = + when (target) { + MetadataPatchTarget.SERIES -> library.importMylarSeries + else -> false + } + + override fun getSidecarSeriesType(): Sidecar.Type = Sidecar.Type.METADATA + + override fun getSidecarSeriesFilenames(): List = listOf(SERIES_JSON) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/AgeRating.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/AgeRating.kt new file mode 100644 index 000000000..eba4801f9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/AgeRating.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.infrastructure.metadata.mylar.dto + +import com.fasterxml.jackson.annotation.JsonValue + +enum class AgeRating( + @get:JsonValue val value: String, + val ageRating: Int? = null, +) { + ALL("All", 0), + NINE("9+", 9), + TWELVE("12+", 12), + FIFTEEN("15+", 15), + SEVENTEEN("17+", 17), + ADULT("Adult", 18), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/MylarMetadata.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/MylarMetadata.kt new file mode 100644 index 000000000..493fa2669 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/MylarMetadata.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.infrastructure.metadata.mylar.dto + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class MylarMetadata( + val type: String, + val publisher: String, + val imprint: String?, + val name: String, + @field:JsonAlias("cid") + val comicid: String, + val year: Int, + @field:JsonProperty("description_text") + val descriptionText: String?, + @field:JsonProperty("description_formatted") + val descriptionFormatted: String?, + val volume: Int?, + @field:JsonProperty("booktype") + val bookType: String, + @field:JsonProperty("age_rating") + val ageRating: AgeRating?, + @field:JsonProperty("comic_image") + @field:JsonAlias("ComicImage") + val comicImage: String, + @field:JsonProperty("total_issues") + val totalIssues: Int, + @field:JsonProperty("publication_run") + val publicationRun: String, + val status: Status, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Series.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Series.kt new file mode 100644 index 000000000..99d80ba48 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Series.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.metadata.mylar.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Series( + val metadata: MylarMetadata, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Status.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Status.kt new file mode 100644 index 000000000..e40fa09a9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/Status.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.infrastructure.metadata.mylar.dto + +enum class Status { + Ended, + Continuing, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/oneshot/OneShotSeriesProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/oneshot/OneShotSeriesProvider.kt new file mode 100644 index 000000000..4a26e9518 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/oneshot/OneShotSeriesProvider.kt @@ -0,0 +1,40 @@ +package org.gotson.komga.infrastructure.metadata.oneshot + +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.MetadataPatchTarget +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider +import org.springframework.stereotype.Service + +@Service +class OneShotSeriesProvider( + private val bookRepository: BookRepository, + private val bookMetadataRepository: BookMetadataRepository, +) : SeriesMetadataProvider { + override fun getSeriesMetadata(series: Series): SeriesMetadataPatch? { + if (!series.oneshot) return null + val bookMetadata = bookMetadataRepository.findById(bookRepository.findAllIdsBySeriesId(series.id).first()) + return SeriesMetadataPatch( + bookMetadata.title, + bookMetadata.title, + SeriesMetadata.Status.ENDED, + bookMetadata.summary, + null, + null, + null, + null, + null, + 1, + emptySet(), + ) + } + + override fun shouldLibraryHandlePatch( + library: Library, + target: MetadataPatchTarget, + ): Boolean = target == MetadataPatchTarget.SERIES +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/AuthorsAsQueryParam.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/AuthorsAsQueryParam.kt new file mode 100644 index 000000000..dc9b964f8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/AuthorsAsQueryParam.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.infrastructure.openapi + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema + +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) +@Parameters( + Parameter( + description = "Author criteria in the format: name,role. Multiple author criteria are supported.", + `in` = ParameterIn.QUERY, + name = "author", + array = ArraySchema(schema = Schema(type = "string")), + ), +) +annotation class AuthorsAsQueryParam diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/InheritanceFlattenerConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/InheritanceFlattenerConfiguration.kt new file mode 100644 index 000000000..7afc4d775 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/InheritanceFlattenerConfiguration.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.infrastructure.openapi + +import org.springdoc.core.customizers.OpenApiCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order + +/** + * The generated schema for sealed classes is somehow wrong. + * This customizer will correct the issues. + */ +@Configuration +@Order(Ordered.LOWEST_PRECEDENCE) +class InheritanceFlattenerConfiguration { + private val schemaPrefix = listOf("SearchOperator", "SearchCondition") + + @Bean + fun flattenInheritedSchemasCustomizer(): OpenApiCustomizer = + OpenApiCustomizer { openApi -> + openApi.components + ?.schemas + ?.values + ?.filter { schema -> schemaPrefix.any { prefix -> schema.name.startsWith(prefix, false) } } + ?.forEach { schema -> + // Swagger models inheritance as an allOf list with exactly two items: + // 1. The $ref to the parent interface + // 2. An inline schema containing the child's actual properties + if (schema.allOf != null) { + val refSchema = schema.allOf.filter { it.`$ref` != null } + val inlineSchema = schema.allOf.find { it.properties != null } + + // If both are found, we know this is an inherited schema wrapper + if (refSchema.isNotEmpty() && inlineSchema != null) { + // Move the properties up to the root schema + schema.properties = inlineSchema.properties + + // Delete the allOf array entirely + schema.allOf = null + } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/OpenApiConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/OpenApiConfiguration.kt new file mode 100644 index 000000000..3a34529d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/OpenApiConfiguration.kt @@ -0,0 +1,458 @@ +package org.gotson.komga.infrastructure.openapi + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.ExternalDocumentation +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import io.swagger.v3.oas.models.servers.ServerVariable +import io.swagger.v3.oas.models.servers.ServerVariables +import io.swagger.v3.oas.models.tags.Tag +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.ANNOUNCEMENTS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.API_KEYS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOKS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOK_FONTS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOK_IMPORT +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOK_PAGES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOK_POSTER +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.BOOK_WEBPUB +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.CLAIM +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.CLIENT_SETTINGS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.COLLECTIONS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.COLLECTION_POSTER +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.COLLECTION_SERIES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.COMICRACK +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.CURRENT_USER +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.DEPRECATED +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.DUPLICATE_PAGES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.FILE_SYSTEM +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.HISTORY +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.LIBRARIES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.MANAGEMENT +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.MIHON +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.OAUTH2 +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.READLISTS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.READLIST_BOOKS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.READLIST_POSTER +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.REFERENTIAL +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.RELEASES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.SERIES +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.SERIES_POSTER +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.SERVER_SETTINGS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.SYNCPOINTS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.TASKS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.USERS +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames.USER_SESSION +import org.springdoc.core.customizers.OperationCustomizer +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.env.Environment +import org.springframework.security.access.prepost.PreAuthorize + +@Configuration +class OpenApiConfiguration( + @param:Value($$"${application.version}") private val appVersion: String, + env: Environment, +) { + private val generateOpenApi = env.activeProfiles.contains("generate-openapi") + + @Bean + fun openApi(): OpenAPI { + val logoutOperation = + Operation() + .tags(listOf(USER_SESSION)) + .summary("Logout") + .description("Invalidates the current session and clean up any remember-me authentication.") + .responses(ApiResponses().addApiResponse("204", ApiResponse().description("No Content"))) + + return OpenAPI() + .info( + Info() + .title("Komga API") + .version(appVersion) + .description( + """ + Komga REST API. + + ## Reference + + Check the API reference: + - on the [Komga website](https://komga.org/docs/openapi/komga-api) + - on any running Komga instance at `/swagger-ui.html` + - on [GitHub](https://raw.githubusercontent.com/gotson/komga/refs/heads/master/komga/docs/openapi.json) + + ## Authentication + + Most endpoints require authentication. Authentication is done using either: + - Basic Authentication + - Passing an API Key in the `X-API-Key` header + + ## Sessions + + Upon successful authentication, a session is created, and can be reused. + + - By default, a `KOMGA-SESSION` cookie is set via `Set-Cookie` response header. This works well for browsers and clients that can handle cookies. + - If you specify a header `X-Auth-Token` during authentication, the session ID will be returned via this same header. You can then pass that header again for subsequent requests to reuse the session. + + If you need to set the session cookie later on, you can call `/api/v1/login/set-cookie` with `X-Auth-Token`. The response will contain the `Set-Cookie` header. + + ## Remember Me + + During authentication, if a request parameter `remember-me` is passed and set to `true`, the server will also return a `komga-remember-me` cookie. This cookie will be used to login automatically even if the session has expired. + + ## Logout + + You can explicitly logout an existing session by calling `/api/logout`. This would return a `204`. + + ## Deprecation + + API endpoints marked as deprecated will be removed in the next major version. + """.trimIndent(), + ).license(License().name("MIT").url("https://github.com/gotson/komga/blob/master/LICENSE")), + ).externalDocs( + ExternalDocumentation() + .description("Komga documentation") + .url("https://komga.org"), + ).components( + Components() + .addSecuritySchemes( + SecuritySchemes.BASIC_AUTH, + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("basic"), + ).addSecuritySchemes( + SecuritySchemes.API_KEY, + SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .`in`(SecurityScheme.In.HEADER) + .name("X-API-Key"), + ), + ).security( + listOf( + SecurityRequirement().addList(SecuritySchemes.BASIC_AUTH), + SecurityRequirement().addList(SecuritySchemes.API_KEY), + ), + ).tags(tags) + .extensions(mapOf("x-tagGroups" to tagGroups)) + .apply { + if (generateOpenApi) + servers( + listOf( + Server() + .url("https://demo.komga.org") + .description("Demo server"), + Server() + .url("http://localhost:{port}") + .description("Local development server") + .variables( + ServerVariables() + .addServerVariable( + "port", + ServerVariable() + .addEnumItem("8080") + .addEnumItem("25600") + ._default("25600"), + ), + ), + ), + ) + }.path( + "/api/logout", + PathItem() + .summary("Logout current session") + .get(logoutOperation.operationId("getLogout")) + .post(logoutOperation.operationId("postLogout")), + ).path( + "/actuator/info", + PathItem() + .summary("Get general information about the server") + .get( + Operation() + .operationId("getActuatorInfo") + .tags(listOf(MANAGEMENT)) + .description("Required role: **ADMIN**") + .summary("Get server information") + .responses( + ApiResponses().addApiResponse( + "200", + ApiResponse() + .description("OK") + .content( + Content() + .addMediaType( + "application/json", + MediaType() + .schema( + Schema>().apply { + type = "object" + additionalProperties = true + }, + ).addExamples( + "Example", + Example() + .value( + //language=JSON + """ +{ + "git": { + "branch": "master", + "commit": { + "id": "9be980d", + "time": "2025-03-12T03:40:38Z" + } + }, + "build": { + "artifact": "komga", + "name": "komga", + "version": "1.21.2", + "group": "komga" + }, + "java": { + "version": "23.0.2", + "vendor": { + "name": "Eclipse Adoptium", + "version": "Temurin-23.0.2+7" + }, + "runtime": { + "name": "OpenJDK Runtime Environment", + "version": "23.0.2+7" + }, + "jvm": { + "name": "OpenJDK 64-Bit Server VM", + "vendor": "Eclipse Adoptium", + "version": "23.0.2+7" + } + }, + "os": { + "name": "Linux", + "version": "6.8.0-57-generic", + "arch": "amd64" + } +} + """.trimIndent(), + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Bean + fun roleDescriptionCustomizer(): OperationCustomizer { + val hasRoleRegex = Regex("""hasRole\('(?\w+)'\)""") + + return OperationCustomizer { operation, handlerMethod -> + val preAuthorize = + handlerMethod.getMethodAnnotation(PreAuthorize::class.java) + ?: handlerMethod.beanType.getAnnotation(PreAuthorize::class.java) + if (preAuthorize != null) { + val roles = hasRoleRegex.findAll(preAuthorize.value).mapNotNull { it.groups["role"]?.value }.toList() + if (roles.isNotEmpty()) { + val description = if (operation.description == null) "" else (operation.description + "\n\n") + operation.description = description + "Required role: **${roles.joinToString()}**" + } + } + operation + } + } + + data class TagGroup( + val name: String, + val tags: List, + ) + + private val tagGroups = + listOf( + TagGroup( + "Deprecation", + listOf( + DEPRECATED, + ), + ), + TagGroup( + "Libraries", + listOf( + LIBRARIES, + ), + ), + TagGroup( + "Series", + listOf( + SERIES, + SERIES_POSTER, + ), + ), + TagGroup( + "Books", + listOf( + BOOKS, + BOOK_PAGES, + BOOK_POSTER, + BOOK_IMPORT, + DUPLICATE_PAGES, + BOOK_WEBPUB, + BOOK_FONTS, + ), + ), + TagGroup( + "Collections", + listOf( + COLLECTIONS, + COLLECTION_SERIES, + COLLECTION_POSTER, + ), + ), + TagGroup( + "Readlists", + listOf( + READLISTS, + READLIST_BOOKS, + READLIST_POSTER, + ), + ), + TagGroup( + "Referential", + listOf( + REFERENTIAL, + ), + ), + TagGroup( + "Users", + listOf( + CURRENT_USER, + USERS, + API_KEYS, + USER_SESSION, + OAUTH2, + SYNCPOINTS, + ), + ), + TagGroup( + "Server", + listOf( + CLAIM, + SERVER_SETTINGS, + TASKS, + HISTORY, + FILE_SYSTEM, + RELEASES, + ANNOUNCEMENTS, + MANAGEMENT, + ), + ), + TagGroup( + "Integrations", + listOf( + CLIENT_SETTINGS, + MIHON, + COMICRACK, + ), + ), + ) + + object SecuritySchemes { + const val BASIC_AUTH = "basicAuth" + const val API_KEY = "apiKey" + } + + object TagNames { + const val DEPRECATED = "Deprecated" + + const val LIBRARIES = "Libraries" + const val SERIES = "Series" + + const val SERIES_POSTER = "Series Poster" + const val BOOKS = "Books" + const val BOOK_POSTER = "Book Poster" + const val BOOK_PAGES = "Book Pages" + const val BOOK_WEBPUB = "WebPub Manifest" + const val BOOK_IMPORT = "Import" + const val BOOK_FONTS = "Fonts" + + const val DUPLICATE_PAGES = "Duplicate Pages" + const val COLLECTIONS = "Collections" + const val COLLECTION_SERIES = "Collection Series" + + const val COLLECTION_POSTER = "Collection Poster" + const val READLISTS = "Readlists" + const val READLIST_BOOKS = "Readlist Books" + + const val READLIST_POSTER = "Readlist Poster" + + const val REFERENTIAL = "Referential metadata" + const val CURRENT_USER = "Current user" + const val USERS = "Users" + const val API_KEYS = "API Keys" + const val USER_SESSION = "User session" + const val OAUTH2 = "OAuth2" + + const val SYNCPOINTS = "Sync points" + const val CLAIM = "Claim" + const val TASKS = "Tasks" + const val HISTORY = "History" + const val FILE_SYSTEM = "File system" + const val SERVER_SETTINGS = "Server settings" + const val RELEASES = "Releases" + const val MANAGEMENT = "Management" + + const val ANNOUNCEMENTS = "Announcements" + const val MIHON = "Mihon" + const val COMICRACK = "ComicRack" + + const val CLIENT_SETTINGS = "Client settings" + } + + private val tags = + listOf( + Tag().name(DEPRECATED), + Tag().name(LIBRARIES).description("Manage libraries."), + Tag().name(SERIES).description("Manage series."), + Tag().name(SERIES_POSTER).description("Manage posters for series."), + Tag().name(BOOKS).description("Manage books."), + Tag().name(BOOK_POSTER).description("Manage posters for books."), + Tag().name(BOOK_PAGES), + Tag().name(BOOK_WEBPUB), + Tag().name(BOOK_IMPORT), + Tag().name(BOOK_FONTS).description("Provide font files and CSS for the Epub Reader."), + Tag().name(DUPLICATE_PAGES).description("Manage duplicate pages. Duplicate pages are identified by a page hash."), + Tag().name(COLLECTIONS).description("Manage collections."), + Tag().name(COLLECTION_POSTER).description("Manage posters for collections."), + Tag().name(COLLECTION_SERIES), + Tag().name(READLISTS).description("Manage readlists."), + Tag().name(READLIST_POSTER).description("Manage posters for readlists."), + Tag().name(READLIST_BOOKS), + Tag().name(REFERENTIAL).description("Retrieve referential metadata from all items in the Komga server."), + Tag().name(CURRENT_USER).description("Manage current user."), + Tag().name(USERS).description("Manage users."), + Tag().name(API_KEYS).description("Manage API Keys"), + Tag().name(USER_SESSION), + Tag().name(OAUTH2).description("List registered OAuth2 providers"), + Tag().name(SYNCPOINTS).description("Sync points are automatically created during a Kobo sync."), + Tag().name(CLAIM).description("Claim a freshly installed Komga server."), + Tag().name(TASKS).description("Manage server tasks"), + Tag().name(HISTORY).description("Server events history"), + Tag().name(FILE_SYSTEM).description("List files from the host server's file system"), + Tag().name(SERVER_SETTINGS).description("Store and retrieve server settings"), + Tag().name(RELEASES).description("Retrieve releases information"), + Tag().name(ANNOUNCEMENTS).description("Retrieve announcements from the Komga website"), + Tag().name(MANAGEMENT).description("Manage server"), + Tag().name(MIHON), + Tag().name(COMICRACK), + Tag().name(CLIENT_SETTINGS).description("Store and retrieve global and per-user settings. Those settings are not used by Komga itself, but can be stored for convenience by client applications."), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/PageableAnnotations.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/PageableAnnotations.kt new file mode 100644 index 000000000..0a6bb0679 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/openapi/PageableAnnotations.kt @@ -0,0 +1,56 @@ +package org.gotson.komga.infrastructure.openapi + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema + +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) +@Parameter( + description = "Zero-based page index (0..N)", + `in` = ParameterIn.QUERY, + name = "page", + schema = Schema(type = "integer"), +) +annotation class PageAsQueryParam + +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) +@Parameters( + Parameter( + description = "Zero-based page index (0..N)", + `in` = ParameterIn.QUERY, + name = "page", + schema = Schema(type = "integer"), + ), + Parameter( + description = "The size of the page to be returned", + `in` = ParameterIn.QUERY, + name = "size", + schema = Schema(type = "integer"), + ), +) +annotation class PageableWithoutSortAsQueryParam + +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) +@Parameters( + Parameter( + description = "Zero-based page index (0..N)", + `in` = ParameterIn.QUERY, + name = "page", + schema = Schema(type = "integer"), + ), + Parameter( + description = "The size of the page to be returned", + `in` = ParameterIn.QUERY, + name = "size", + schema = Schema(type = "integer"), + ), + Parameter( + description = "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + `in` = ParameterIn.QUERY, + name = "sort", + array = ArraySchema(schema = Schema(type = "string")), + ), +) +annotation class PageableAsQueryParam diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneAsyncCommitter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneAsyncCommitter.kt new file mode 100644 index 000000000..d81ee9700 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneAsyncCommitter.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.index.IndexWriter +import org.apache.lucene.search.SearcherManager +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile +import org.springframework.scheduling.TaskScheduler +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.ZonedDateTime +import java.util.concurrent.ScheduledFuture + +@Profile("!test") +@Component +class LuceneAsyncCommitter( + private val indexWriter: IndexWriter, + private val searcherManager: SearcherManager, + private val taskScheduler: TaskScheduler, + @param:Value("#{@komgaProperties.lucene.commitDelay}") + private val commitDelay: Duration, +) : LuceneCommitter { + @Volatile + private var commitFuture: ScheduledFuture<*>? = null + + private val commitRunnable = + Runnable { + indexWriter.commit() + searcherManager.maybeRefresh() + } + + override fun commitAndMaybeRefresh() { + if (commitFuture == null || commitFuture!!.isDone) + commitFuture = taskScheduler.schedule(commitRunnable, ZonedDateTime.now().plus(commitDelay).toInstant()) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneCommitter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneCommitter.kt new file mode 100644 index 000000000..6fe825c2c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneCommitter.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.infrastructure.search + +interface LuceneCommitter { + fun commitAndMaybeRefresh() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneConfiguration.kt new file mode 100644 index 000000000..3fa245d2b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneConfiguration.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.analysis.Analyzer +import org.apache.lucene.index.IndexWriter +import org.apache.lucene.index.IndexWriterConfig +import org.apache.lucene.search.SearcherFactory +import org.apache.lucene.search.SearcherManager +import org.apache.lucene.store.ByteBuffersDirectory +import org.apache.lucene.store.Directory +import org.apache.lucene.store.FSDirectory +import org.apache.lucene.store.SingleInstanceLockFactory +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import java.nio.file.Paths + +@Configuration +class LuceneConfiguration( + private val komgaProperties: KomgaProperties, +) { + @Bean + fun indexAnalyzer() = + with(komgaProperties.lucene.indexAnalyzer) { + MultiLingualNGramAnalyzer(minGram, maxGram, preserveOriginal) + } + + @Bean + fun searchAnalyzer() = MultiLingualAnalyzer() + + @Bean + @Profile("test") + fun memoryDirectory(): Directory = ByteBuffersDirectory() + + @Bean + @Profile("!test") + fun diskDirectory(): Directory = FSDirectory.open(Paths.get(komgaProperties.lucene.dataDirectory), SingleInstanceLockFactory()) + + @Bean + fun indexWriter( + directory: Directory, + indexAnalyzer: Analyzer, + ): IndexWriter = IndexWriter(directory, IndexWriterConfig(indexAnalyzer)) + + @Bean + fun searcherManager(indexWriter: IndexWriter) = SearcherManager(indexWriter, SearcherFactory()) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneEntity.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneEntity.kt new file mode 100644 index 000000000..22ca86c7f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneEntity.kt @@ -0,0 +1,117 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.document.DateTools +import org.apache.lucene.document.Document +import org.apache.lucene.document.Field +import org.apache.lucene.document.StringField +import org.apache.lucene.document.TextField +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.language.toDate + +enum class LuceneEntity( + val type: String, + val id: String, + val defaultFields: Array, +) { + Book("book", "book_id", arrayOf("title", "isbn")), + Series("series", "series_id", arrayOf("title")), + Collection("collection", "collection_id", arrayOf("name")), + ReadList("readlist", "readlist_id", arrayOf("name")), + ; + + companion object { + const val TYPE = "type" + } +} + +fun BookDto.toDocument() = + Document().apply { + add(TextField("title", metadata.title, Field.Store.NO)) + add(TextField("isbn", metadata.isbn, Field.Store.NO)) + metadata.tags.forEach { + add(TextField("tag", it, Field.Store.NO)) + } + metadata.authors.forEach { + add(TextField("author", it.name, Field.Store.NO)) + add(TextField(it.role, it.name, Field.Store.NO)) + } + if (metadata.releaseDate != null) add(TextField("release_date", DateTools.dateToString(metadata.releaseDate.toDate(), DateTools.Resolution.YEAR), Field.Store.NO)) + add(TextField("status", media.status, Field.Store.NO)) + add(TextField("deleted", deleted.toString(), Field.Store.NO)) + add(TextField("oneshot", oneshot.toString(), Field.Store.NO)) + + add(StringField(LuceneEntity.TYPE, LuceneEntity.Book.type, Field.Store.NO)) + add(StringField(LuceneEntity.Book.id, id, Field.Store.YES)) + } + +fun SeriesDto.toDocument() = + Document().apply { + add(TextField("title", metadata.title, Field.Store.NO)) + if (metadata.titleSort != metadata.title) add(TextField("title", metadata.titleSort, Field.Store.NO)) + metadata.alternateTitles.forEach { add(TextField("title", it.title, Field.Store.NO)) } + add(TextField("publisher", metadata.publisher, Field.Store.NO)) + add(TextField("status", metadata.status, Field.Store.NO)) + add(TextField("reading_direction", metadata.readingDirection, Field.Store.NO)) + if (metadata.ageRating != null) add(TextField("age_rating", metadata.ageRating.toString(), Field.Store.NO)) + if (metadata.language.isNotBlank()) add(TextField("language", metadata.language, Field.Store.NO)) + metadata.tags.forEach { + add(TextField("series_tag", it, Field.Store.NO)) + add(TextField("tag", it, Field.Store.NO)) + } + booksMetadata.tags.forEach { + add(TextField("book_tag", it, Field.Store.NO)) + add(TextField("tag", it, Field.Store.NO)) + } + metadata.genres.forEach { + add(TextField("genre", it, Field.Store.NO)) + } + metadata.sharingLabels.forEach { + add(TextField("sharing_label", it, Field.Store.NO)) + } + if (metadata.totalBookCount != null) add(TextField("total_book_count", metadata.totalBookCount.toString(), Field.Store.NO)) + add(TextField("book_count", booksCount.toString(), Field.Store.NO)) + booksMetadata.authors.forEach { + add(TextField("author", it.name, Field.Store.NO)) + add(TextField(it.role, it.name, Field.Store.NO)) + } + if (booksMetadata.releaseDate != null) add(TextField("release_date", DateTools.dateToString(booksMetadata.releaseDate.toDate(), DateTools.Resolution.YEAR), Field.Store.NO)) + add(TextField("deleted", deleted.toString(), Field.Store.NO)) + add(TextField("oneshot", oneshot.toString(), Field.Store.NO)) + if (metadata.totalBookCount != null) add(TextField("complete", (metadata.totalBookCount == booksCount).toString(), Field.Store.NO)) + + add(StringField(LuceneEntity.TYPE, LuceneEntity.Series.type, Field.Store.NO)) + add(StringField(LuceneEntity.Series.id, id, Field.Store.YES)) + } + +fun SeriesDto.oneshotDocument(document: Document) = + document.apply { + add(TextField("publisher", metadata.publisher, Field.Store.NO)) + add(TextField("status", metadata.status, Field.Store.NO)) + add(TextField("reading_direction", metadata.readingDirection, Field.Store.NO)) + if (metadata.ageRating != null) add(TextField("age_rating", metadata.ageRating.toString(), Field.Store.NO)) + if (metadata.language.isNotBlank()) add(TextField("language", metadata.language, Field.Store.NO)) + metadata.genres.forEach { + add(TextField("genre", it, Field.Store.NO)) + } + metadata.sharingLabels.forEach { + add(TextField("sharing_label", it, Field.Store.NO)) + } + add(TextField("complete", "true", Field.Store.NO)) + } + +fun SeriesCollection.toDocument() = + Document().apply { + add(TextField("name", name, Field.Store.NO)) + add(StringField(LuceneEntity.TYPE, LuceneEntity.Collection.type, Field.Store.NO)) + add(StringField(LuceneEntity.Collection.id, id, Field.Store.YES)) + } + +fun ReadList.toDocument() = + Document().apply { + add(TextField("name", name, Field.Store.NO)) + add(StringField(LuceneEntity.TYPE, LuceneEntity.ReadList.type, Field.Store.NO)) + add(StringField(LuceneEntity.ReadList.id, id, Field.Store.YES)) + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneHelper.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneHelper.kt new file mode 100644 index 000000000..0e4d77cb7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneHelper.kt @@ -0,0 +1,118 @@ +package org.gotson.komga.infrastructure.search + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.lucene.analysis.Analyzer +import org.apache.lucene.document.Document +import org.apache.lucene.document.Field +import org.apache.lucene.document.StringField +import org.apache.lucene.index.DirectoryReader +import org.apache.lucene.index.IndexUpgrader +import org.apache.lucene.index.IndexWriter +import org.apache.lucene.index.IndexWriterConfig +import org.apache.lucene.index.Term +import org.apache.lucene.queryparser.classic.MultiFieldQueryParser +import org.apache.lucene.queryparser.classic.ParseException +import org.apache.lucene.queryparser.classic.QueryParser +import org.apache.lucene.search.BooleanClause +import org.apache.lucene.search.BooleanQuery +import org.apache.lucene.search.SearcherManager +import org.apache.lucene.search.TermQuery +import org.apache.lucene.store.Directory +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +private const val MAX_RESULTS = 1000 + +@Component +class LuceneHelper( + private val directory: Directory, + private val searchAnalyzer: Analyzer, + private val indexAnalyzer: Analyzer, + private val indexWriter: IndexWriter, + private val searcherManager: SearcherManager, + private val luceneCommitter: LuceneCommitter, +) { + fun indexExists(): Boolean = DirectoryReader.indexExists(directory) + + fun setIndexVersion(version: Int) { + val doc = + Document().apply { + add(StringField("index_version", version.toString(), Field.Store.YES)) + add(StringField("type", "index_version", Field.Store.NO)) + } + updateDocument(Term("type", "index_version"), doc) + logger.info { "Lucene index version: ${getIndexVersion()}" } + } + + fun getIndexVersion(): Int { + val searcher = searcherManager.acquire() + val topDocs = searcher.search(TermQuery(Term("type", "index_version")), 1) + return topDocs.scoreDocs + .map { searcher.storedFields().document(it.doc)["index_version"] } + .firstOrNull() + ?.toIntOrNull() ?: 1 + } + + fun searchEntitiesIds( + searchTerm: String?, + entity: LuceneEntity, + ): List? = + if (!searchTerm.isNullOrBlank()) { + try { + val fieldsQuery = + MultiFieldQueryParser(entity.defaultFields, searchAnalyzer) + .apply { + defaultOperator = QueryParser.Operator.AND + }.parse("$searchTerm *:*") + + val typeQuery = TermQuery(Term(LuceneEntity.TYPE, entity.type)) + + val booleanQuery = + BooleanQuery + .Builder() + .add(fieldsQuery, BooleanClause.Occur.MUST) + .add(typeQuery, BooleanClause.Occur.MUST) + .build() + + val searcher = searcherManager.acquire() + val topDocs = searcher.search(booleanQuery, MAX_RESULTS) + topDocs.scoreDocs.map { searcher.storedFields().document(it.doc)[entity.id] } + } catch (_: ParseException) { + emptyList() + } catch (e: Exception) { + logger.error(e) { "Error fetching entities from index" } + emptyList() + } + } else { + null + } + + fun upgradeIndex() { + IndexUpgrader(directory, IndexWriterConfig(indexAnalyzer), true).upgrade() + logger.info { "Lucene index upgraded" } + } + + fun addDocument(doc: Document) { + indexWriter.addDocument(doc) + luceneCommitter.commitAndMaybeRefresh() + } + + fun addDocuments(docs: Iterable) { + indexWriter.addDocuments(docs) + luceneCommitter.commitAndMaybeRefresh() + } + + fun updateDocument( + term: Term, + doc: Document, + ) { + indexWriter.updateDocument(term, doc) + luceneCommitter.commitAndMaybeRefresh() + } + + fun deleteDocuments(term: Term) { + indexWriter.deleteDocuments(term) + luceneCommitter.commitAndMaybeRefresh() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneSyncCommitter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneSyncCommitter.kt new file mode 100644 index 000000000..056dd3516 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/LuceneSyncCommitter.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.index.IndexWriter +import org.apache.lucene.search.SearcherManager +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Profile("test") +@Component +class LuceneSyncCommitter( + private val indexWriter: IndexWriter, + private val searcherManager: SearcherManager, +) : LuceneCommitter { + override fun commitAndMaybeRefresh() { + indexWriter.commit() + searcherManager.maybeRefreshBlocking() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualAnalyzer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualAnalyzer.kt new file mode 100644 index 000000000..592403ffe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualAnalyzer.kt @@ -0,0 +1,32 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.analysis.Analyzer +import org.apache.lucene.analysis.LowerCaseFilter +import org.apache.lucene.analysis.TokenStream +import org.apache.lucene.analysis.Tokenizer +import org.apache.lucene.analysis.cjk.CJKBigramFilter +import org.apache.lucene.analysis.cjk.CJKWidthFilter +import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilter +import org.apache.lucene.analysis.standard.StandardTokenizer + +open class MultiLingualAnalyzer : Analyzer() { + override fun createComponents(fieldName: String): TokenStreamComponents { + val source: Tokenizer = StandardTokenizer() + // run the widthfilter first before bigramming, it sometimes combines characters. + var filter: TokenStream = CJKWidthFilter(source) + filter = LowerCaseFilter(filter) + filter = CJKBigramFilter(filter) + filter = ASCIIFoldingFilter(filter) + return TokenStreamComponents(source, filter) + } + + override fun normalize( + fieldName: String?, + `in`: TokenStream, + ): TokenStream { + var filter: TokenStream = CJKWidthFilter(`in`) + filter = LowerCaseFilter(filter) + filter = ASCIIFoldingFilter(filter) + return filter + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualNGramAnalyzer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualNGramAnalyzer.kt new file mode 100644 index 000000000..435b511ba --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/MultiLingualNGramAnalyzer.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.analysis.LowerCaseFilter +import org.apache.lucene.analysis.TokenStream +import org.apache.lucene.analysis.Tokenizer +import org.apache.lucene.analysis.cjk.CJKBigramFilter +import org.apache.lucene.analysis.cjk.CJKWidthFilter +import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilter +import org.apache.lucene.analysis.ngram.NGramTokenFilter +import org.apache.lucene.analysis.standard.StandardTokenizer + +class MultiLingualNGramAnalyzer( + private val minGram: Int, + private val maxGram: Int, + private val preserveOriginal: Boolean, +) : MultiLingualAnalyzer() { + override fun createComponents(fieldName: String): TokenStreamComponents { + val source: Tokenizer = StandardTokenizer() + // run the widthfilter first before bigramming, it sometimes combines characters. + var filter: TokenStream = CJKWidthFilter(source) + filter = LowerCaseFilter(filter) + filter = CJKBigramFilter(filter) + filter = NGramTokenFilter(filter, minGram, maxGram, preserveOriginal) + filter = ASCIIFoldingFilter(filter) + return TokenStreamComponents(source, filter) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycle.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycle.kt new file mode 100644 index 000000000..52777186a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycle.kt @@ -0,0 +1,132 @@ +package org.gotson.komga.infrastructure.search + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.lucene.document.Document +import org.apache.lucene.index.Term +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.context.event.EventListener +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component +import kotlin.math.ceil +import kotlin.time.measureTime + +private val logger = KotlinLogging.logger {} +private const val INDEX_VERSION = 8 + +@Component +class SearchIndexLifecycle( + private val collectionRepository: SeriesCollectionRepository, + private val readListRepository: ReadListRepository, + private val bookDtoRepository: BookDtoRepository, + private val seriesDtoRepository: SeriesDtoRepository, + private val luceneHelper: LuceneHelper, +) { + fun upgradeIndex() { + luceneHelper.upgradeIndex() + luceneHelper.setIndexVersion(INDEX_VERSION) + } + + fun rebuildIndex(entities: Set? = null) { + val targetEntities = entities ?: LuceneEntity.entries.toSet() + + logger.info { "Rebuild index for: ${targetEntities.map { it.type }}" } + + targetEntities.forEach { + when (it) { + LuceneEntity.Book -> rebuildIndex(it, { p: Pageable -> bookDtoRepository.findAll(p) }, { e: BookDto -> e.bookToDocument() }) + LuceneEntity.Series -> rebuildIndex(it, { p: Pageable -> seriesDtoRepository.findAll(p) }, { e: SeriesDto -> e.toDocument() }) + LuceneEntity.Collection -> rebuildIndex(it, { p: Pageable -> collectionRepository.findAll(pageable = p) }, { e: SeriesCollection -> e.toDocument() }) + LuceneEntity.ReadList -> rebuildIndex(it, { p: Pageable -> readListRepository.findAll(pageable = p) }, { e: ReadList -> e.toDocument() }) + } + } + + luceneHelper.setIndexVersion(INDEX_VERSION) + } + + private fun rebuildIndex( + entity: LuceneEntity, + provider: (Pageable) -> Page, + toDoc: (T) -> Document?, + ) { + logger.info { "Rebuilding index for ${entity.name}" } + + val count = provider(Pageable.ofSize(1)).totalElements + val batchSize = 5_000 + val pages = ceil(count.toDouble() / batchSize).toInt() + logger.info { "Number of entities: $count" } + + measureTime { + luceneHelper.deleteDocuments(Term(LuceneEntity.TYPE, entity.type)) + + (0 until pages).forEach { page -> + logger.info { "Processing page ${page + 1} of $pages ($batchSize elements)" } + val entityDocs = + provider(PageRequest.of(page, batchSize)) + .content + .mapNotNull { toDoc(it) } + luceneHelper.addDocuments(entityDocs) + } + }.also { duration -> + logger.info { "Wrote ${entity.name} index in $duration" } + } + } + + @EventListener + fun consumeEvents(event: DomainEvent) { + when (event) { + is DomainEvent.SeriesAdded -> seriesDtoRepository.findByIdOrNull(event.series.id, "unused")?.toDocument()?.let { addEntity(it) } + is DomainEvent.SeriesUpdated -> seriesDtoRepository.findByIdOrNull(event.series.id, "unused")?.toDocument()?.let { updateEntity(LuceneEntity.Series, event.series.id, it) } + is DomainEvent.SeriesDeleted -> deleteEntity(LuceneEntity.Series, event.series.id) + + is DomainEvent.BookAdded -> bookDtoRepository.findByIdOrNull(event.book.id, "unused")?.bookToDocument()?.let { addEntity(it) } + is DomainEvent.BookUpdated -> bookDtoRepository.findByIdOrNull(event.book.id, "unused")?.bookToDocument()?.let { updateEntity(LuceneEntity.Book, event.book.id, it) } + is DomainEvent.BookDeleted -> deleteEntity(LuceneEntity.Book, event.book.id) + + is DomainEvent.ReadListAdded -> readListRepository.findByIdOrNull(event.readList.id)?.toDocument()?.let { addEntity(it) } + is DomainEvent.ReadListUpdated -> readListRepository.findByIdOrNull(event.readList.id)?.toDocument()?.let { updateEntity(LuceneEntity.ReadList, event.readList.id, it) } + is DomainEvent.ReadListDeleted -> deleteEntity(LuceneEntity.ReadList, event.readList.id) + + is DomainEvent.CollectionAdded -> collectionRepository.findByIdOrNull(event.collection.id)?.toDocument()?.let { addEntity(it) } + is DomainEvent.CollectionUpdated -> collectionRepository.findByIdOrNull(event.collection.id)?.toDocument()?.let { updateEntity(LuceneEntity.Collection, event.collection.id, it) } + is DomainEvent.CollectionDeleted -> deleteEntity(LuceneEntity.Collection, event.collection.id) + + else -> Unit + } + } + + private fun BookDto.bookToDocument(): Document = + if (this.oneshot) { + seriesDtoRepository.findByIdOrNull(seriesId, "unused")!!.oneshotDocument(toDocument()) + } else { + this.toDocument() + } + + private fun addEntity(doc: Document) { + luceneHelper.addDocument(doc) + } + + private fun updateEntity( + entity: LuceneEntity, + entityId: String, + newDoc: Document, + ) { + luceneHelper.updateDocument(Term(entity.id, entityId), newDoc) + } + + private fun deleteEntity( + entity: LuceneEntity, + entityId: String, + ) { + luceneHelper.deleteDocuments(Term(entity.id, entityId)) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/CorsConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/CorsConfiguration.kt new file mode 100644 index 000000000..78425d254 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/CorsConfiguration.kt @@ -0,0 +1,55 @@ +package org.gotson.komga.infrastructure.security + +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.springframework.boot.autoconfigure.condition.ConditionOutcome +import org.springframework.boot.autoconfigure.condition.SpringBootCondition +import org.springframework.boot.context.properties.bind.Bindable +import org.springframework.boot.context.properties.bind.Binder +import org.springframework.boot.context.properties.source.ConfigurationPropertyName +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ConditionContext +import org.springframework.context.annotation.Conditional +import org.springframework.context.annotation.Configuration +import org.springframework.core.type.AnnotatedTypeMetadata +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import java.util.Collections + +@Configuration +class CorsConfiguration { + @Bean + @Conditional(CorsAllowedOriginsPresent::class) + fun corsConfigurationSource( + sessionHeaderName: String, + komgaProperties: KomgaProperties, + ): UrlBasedCorsConfigurationSource = + UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration( + "/**", + CorsConfiguration().applyPermitDefaultValues().apply { + allowedOrigins = komgaProperties.cors.allowedOrigins + allowedMethods = HttpMethod.values().map { it.name() } + allowCredentials = true + addExposedHeader(HttpHeaders.CONTENT_DISPOSITION) + addExposedHeader(sessionHeaderName) + }, + ) + } + + class CorsAllowedOriginsPresent : SpringBootCondition() { + override fun getMatchOutcome( + context: ConditionContext, + metadata: AnnotatedTypeMetadata, + ): ConditionOutcome { + val defined = + Binder + .get(context.environment) + .bind(ConfigurationPropertyName.of("komga.cors.allowed-origins"), Bindable.of(List::class.java)) + .orElse(Collections.emptyList()) + .isNotEmpty() + return ConditionOutcome(defined, "Cors allowed-origins present") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt new file mode 100644 index 000000000..033d83b3a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.infrastructure.security + +import org.gotson.komga.domain.model.ApiKey +import org.gotson.komga.domain.model.KomgaUser +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.oidc.OidcIdToken +import org.springframework.security.oauth2.core.oidc.OidcUserInfo +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.security.oauth2.core.user.OAuth2User + +class KomgaPrincipal( + val user: KomgaUser, + val oAuth2User: OAuth2User? = null, + val oidcUser: OidcUser? = null, + val apiKey: ApiKey? = null, + private val name: String = user.email, +) : UserDetails, + OAuth2User, + OidcUser { + override fun getAuthorities(): MutableCollection = + user.roles + .map { SimpleGrantedAuthority("ROLE_$it") } + .toMutableSet() + + override fun isEnabled() = true + + override fun getUsername() = name + + override fun isCredentialsNonExpired() = true + + override fun getPassword() = user.password + + override fun isAccountNonExpired() = true + + override fun isAccountNonLocked() = true + + override fun getName() = name + + override fun getAttributes(): MutableMap = oAuth2User?.attributes ?: mutableMapOf() + + override fun getClaims(): MutableMap = oidcUser?.claims ?: mutableMapOf() + + override fun getUserInfo(): OidcUserInfo? = oidcUser?.userInfo + + override fun getIdToken(): OidcIdToken? = oidcUser?.idToken +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsService.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsService.kt new file mode 100644 index 000000000..51330f0c7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsService.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.infrastructure.security + +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Component + +@Component +class KomgaUserDetailsService( + private val userRepository: KomgaUserRepository, +) : UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails = + userRepository.findByEmailIgnoreCaseOrNull(username)?.let { + KomgaPrincipal(it) + } ?: throw UsernameNotFoundException(username) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt new file mode 100644 index 000000000..299704853 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt @@ -0,0 +1,110 @@ +package org.gotson.komga.infrastructure.security + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.AuthenticationActivity +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken +import org.springframework.context.event.EventListener +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.authentication.RememberMeAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent +import org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent +import org.springframework.security.authentication.event.AuthenticationSuccessEvent +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken +import org.springframework.security.web.authentication.WebAuthenticationDetails +import org.springframework.stereotype.Component +import java.util.EventObject + +private val logger = KotlinLogging.logger {} + +@Component +class LoginListener( + private val authenticationActivityRepository: AuthenticationActivityRepository, + private val userRepository: KomgaUserRepository, +) { + @EventListener + fun onSuccess(event: AuthenticationSuccessEvent) { + val komgaPrincipal = event.authentication.principal as KomgaPrincipal + val user = komgaPrincipal.user + val apiKey = komgaPrincipal.apiKey + val source = + when (event.source) { + is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}" + is ApiKeyAuthenticationToken -> "ApiKey" + is UsernamePasswordAuthenticationToken -> "Password" + is RememberMeAuthenticationToken -> "RememberMe" + else -> null + } + val activity = + AuthenticationActivity( + userId = user.id, + email = user.email, + apiKeyId = apiKey?.id, + apiKeyComment = apiKey?.comment, + ip = event.getIp(), + userAgent = event.getUserAgent(), + success = true, + source = source, + ) + + logger.debug { activity } + authenticationActivityRepository.insert(activity) + } + + @EventListener + fun onFailure(event: AbstractAuthenticationFailureEvent) { + // somehow we get 2 events with bad credentials, so discard this one + if (event is AuthenticationFailureProviderNotFoundEvent) return + val source = + when (event.source) { + is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}" + is ApiKeyAuthenticationToken -> "ApiKey" + is UsernamePasswordAuthenticationToken -> "Password" + is RememberMeAuthenticationToken -> "RememberMe" + else -> null + } + val principal = + event.authentication + ?.principal + ?.toString() + .orEmpty() + val activity = + AuthenticationActivity( + userId = userRepository.findByEmailIgnoreCaseOrNull(principal)?.id, + email = if (event.source !is ApiKeyAuthenticationToken) principal else null, + apiKeyComment = if (event.source is ApiKeyAuthenticationToken) principal else null, + ip = event.getIp(), + userAgent = event.getUserAgent(), + success = false, + source = source, + error = event.exception.message, + ) + + logger.debug { activity } + authenticationActivityRepository.insert(activity) + } + + private fun EventObject.getIp(): String? = + try { + when (source) { + is WebAuthenticationDetails -> (source as WebAuthenticationDetails).remoteAddress + is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as WebAuthenticationDetails).remoteAddress + else -> null + } + } catch (e: Exception) { + null + } + + private fun EventObject.getUserAgent(): String? = + try { + when (source) { + is UserAgentWebAuthenticationDetails -> (source as UserAgentWebAuthenticationDetails).userAgent + is AbstractAuthenticationToken -> ((source as AbstractAuthenticationToken).details as UserAgentWebAuthenticationDetails).userAgent + else -> null + } + } catch (e: Exception) { + null + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/OpdsAuthenticationEntryPoint.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/OpdsAuthenticationEntryPoint.kt new file mode 100644 index 000000000..f335b2e2a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/OpdsAuthenticationEntryPoint.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.infrastructure.security + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.gotson.komga.interfaces.api.OpdsGenerator +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.OpdsLinkRel +import org.gotson.komga.interfaces.api.opds.v2.ROUTE_AUTH +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +private const val DEFAULT_REALM: String = "Realm" + +@Component +class OpdsAuthenticationEntryPoint( + private val opdsGenerator: OpdsGenerator, + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + with(response) { + contentType = MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE + characterEncoding = Charsets.UTF_8.name() + status = HttpStatus.UNAUTHORIZED.value() + setHeader("WWW-Authenticate", """Basic realm="$DEFAULT_REALM"""") + setHeader( + HttpHeaders.LINK, + """<${ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(ROUTE_AUTH).toUriString()}>; rel="${OpdsLinkRel.AUTH}"; type="$MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE"""", + ) + with(writer) { + write(objectMapper.writeValueAsString(opdsGenerator.generateOpdsAuthDocument())) + flush() + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt new file mode 100644 index 000000000..02afedc87 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.infrastructure.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.token.Sha512DigestUtils +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder + +@Configuration +class PasswordEncoderConfiguration { + @Bean + fun getPasswordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun getTokenEncoder(): TokenEncoder = TokenEncoder { rawPassword -> Sha512DigestUtils.shaHex(rawPassword) } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt new file mode 100644 index 000000000..3608beabc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -0,0 +1,264 @@ +package org.gotson.komga.infrastructure.security + +import jakarta.servlet.Filter +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationFilter +import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationProvider +import org.gotson.komga.infrastructure.security.apikey.HeaderApiKeyAuthenticationConverter +import org.gotson.komga.infrastructure.security.apikey.UriRegexApiKeyAuthenticationConverter +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest +import org.springframework.boot.actuate.health.HealthEndpoint +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.security.authentication.AuthenticationEventPublisher +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfiguration( + private val komgaSettingsProvider: KomgaSettingsProvider, + private val komgaUserDetailsService: UserDetailsService, + private val apiKeyAuthenticationProvider: ApiKeyAuthenticationProvider, + private val oauth2UserService: OAuth2UserService, + private val oidcUserService: OAuth2UserService, + private val sessionCookieName: String, + private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource, + private val theSessionRegistry: SessionRegistry, + private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint, + private val authenticationEventPublisher: AuthenticationEventPublisher, + private val tokenEncoder: TokenEncoder, + private val hasher: Hasher, + clientRegistrationRepository: InMemoryClientRegistrationRepository?, +) { + private val oauth2Enabled = clientRegistrationRepository != null + + @Order(1) + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .cors {} + .csrf { it.disable() } + .securityMatchers { + // only apply security to those endpoints + it.requestMatchers( + "/api/**", + "/opds/**", + "/sse/**", + "/oauth2/authorization/**", + "/login/oauth2/code/**", + ) + it.requestMatchers(EndpointRequest.toAnyEndpoint()) + }.authorizeHttpRequests { + // allow unauthorized access to actuator health endpoint + // this will only show limited details as `management.endpoint.health.show-details` is set to `when-authorized` + it.requestMatchers(EndpointRequest.to(HealthEndpoint::class.java)).permitAll() + // restrict all other actuator endpoints to ADMIN only + it.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole(UserRoles.ADMIN.name) + + it + .requestMatchers( + // to claim server before any account is created + "/api/v1/claim", + // used by webui + "/api/v1/oauth2/providers", + // used by webui, we check for authorization within the controller method directly and filter results from there + "/api/v1/client-settings/global/list", + // epub resources - fonts are always requested anonymously, so we check for authorization within the controller method directly + "/api/v1/books/{bookId}/resource/**", + // dynamic fonts + "/api/v1/fonts/resource/**", + // OPDS authentication document + "/opds/v2/auth", + // KOReader user creation + "/koreader/users/create", + ).permitAll() + + // all other endpoints are restricted to authenticated users + it + .requestMatchers( + "/api/**", + "/opds/**", + "/sse/**", + ).authenticated() + }.headers { headersConfigurer -> + headersConfigurer.cacheControl { it.disable() } // headers are set in WebMvcConfiguration + headersConfigurer.frameOptions { it.sameOrigin() } // for epubreader iframes + }.userDetailsService(komgaUserDetailsService) + .httpBasic { + it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource) + }.logout { + it.logoutUrl("/api/logout") + it.deleteCookies(sessionCookieName) + it.invalidateHttpSession(true) + }.sessionManagement { session -> + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + session.sessionConcurrency { + it.sessionRegistry(theSessionRegistry) + it.maximumSessions(-1) + } + }.exceptionHandling { + it.defaultAuthenticationEntryPointFor(opdsAuthenticationEntryPoint, PathPatternRequestMatcher.withDefaults().matcher("/opds/v2/**")) + } + + if (oauth2Enabled) { + http.oauth2Login { oauth2 -> + oauth2.userInfoEndpoint { + it.userService(oauth2UserService) + it.oidcUserService(oidcUserService) + } + oauth2.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource) + oauth2 + .loginPage("/login") + .defaultSuccessUrl("/?server_redirect=Y", true) + .failureHandler { request, response, exception -> + val errorMessage = + when (exception) { + is OAuth2AuthenticationException -> exception.error.errorCode + else -> exception.message + } + val url = "/login?server_redirect=Y&error=$errorMessage" + SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception) + } + oauth2.redirectionEndpoint { + } + } + } + + http + .rememberMe { + it.rememberMeServices( + TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsService).apply { + setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt()) + setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource) + setCookieName("komga-remember-me") + }, + ) + } + + http.addFilterAfter(restAuthenticationFilter(), BasicAuthenticationFilter::class.java) + + return http.build() + } + + @Bean + fun koboFilterChain( + http: HttpSecurity, + ): SecurityFilterChain { + http { + cors {} + + csrf { disable() } + formLogin { disable() } + httpBasic { disable() } + logout { disable() } + + securityMatcher("/kobo/**") + authorizeHttpRequests { + authorize(anyRequest, hasRole(UserRoles.KOBO_SYNC.name)) + } + + headers { + cacheControl { disable() } + } + + // somehow the Kobo gets a Json issue when receiving the session ID in a cookie header + // this happens when requesting /v1/user/profile + // Kobo error: packetdump.warning) Invalid JSON script: QVariant(Invalid) "illegal value" +// sessionManagement { +// sessionCreationPolicy = SessionCreationPolicy.IF_REQUIRED +// sessionConcurrency { +// sessionRegistry = theSessionRegistry +// maximumSessions = -1 +// } +// } + + addFilterBefore(koboAuthenticationFilter()) + } + + return http.build() + } + + @Bean + fun kosyncFilterChain( + http: HttpSecurity, + ): SecurityFilterChain { + http { + cors {} + + csrf { disable() } + formLogin { disable() } + httpBasic { disable() } + logout { disable() } + + securityMatcher("/koreader/**") + authorizeHttpRequests { + authorize(anyRequest, hasRole(UserRoles.KOREADER_SYNC.name)) + } + + headers { + cacheControl { disable() } + } + + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.IF_REQUIRED + sessionConcurrency { + sessionRegistry = theSessionRegistry + maximumSessions = -1 + } + } + + addFilterBefore(kosyncAuthenticationFilter()) + } + + return http.build() + } + + fun koboAuthenticationFilter(): Filter = + ApiKeyAuthenticationFilter( + apiKeyAuthenticationProvider(), + UriRegexApiKeyAuthenticationConverter(Regex("""/kobo/([\w-]+)"""), hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource), + ) + + fun kosyncAuthenticationFilter(): Filter = + ApiKeyAuthenticationFilter( + apiKeyAuthenticationProvider(), + HeaderApiKeyAuthenticationConverter("X-Auth-User", hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource), + ) + + fun restAuthenticationFilter(): Filter = + ApiKeyAuthenticationFilter( + apiKeyAuthenticationProvider(), + HeaderApiKeyAuthenticationConverter("X-API-Key", hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource), + ) + + fun apiKeyAuthenticationProvider(): AuthenticationManager = + ProviderManager(apiKeyAuthenticationProvider).apply { + setAuthenticationEventPublisher(authenticationEventPublisher) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt new file mode 100644 index 000000000..a9372653b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.infrastructure.security + +/** + * Service interface for encoding tokens. + * Contrary to password encoding, token encoding is deterministic, so that lookups can be done using + * only the token, without a username. + */ +fun interface TokenEncoder { + fun encode(rawPassword: String): String +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt new file mode 100644 index 000000000..c3e905f4f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetails.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.infrastructure.security + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpHeaders +import org.springframework.security.web.authentication.WebAuthenticationDetails + +class UserAgentWebAuthenticationDetails( + request: HttpServletRequest, +) : WebAuthenticationDetails(request) { + val userAgent: String = request.getHeader(HttpHeaders.USER_AGENT).orEmpty() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetailsSource.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetailsSource.kt new file mode 100644 index 000000000..5c211d233 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/UserAgentWebAuthenticationDetailsSource.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.infrastructure.security + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component + +@Component +class UserAgentWebAuthenticationDetailsSource : WebAuthenticationDetailsSource() { + override fun buildDetails(context: HttpServletRequest): UserAgentWebAuthenticationDetails = UserAgentWebAuthenticationDetails(context) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt new file mode 100644 index 000000000..48cb328c9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt @@ -0,0 +1,98 @@ +package org.gotson.komga.infrastructure.security.apikey + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextHolderStrategy +import org.springframework.security.web.authentication.AuthenticationConverter +import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler +import org.springframework.security.web.authentication.AuthenticationFailureHandler +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository +import org.springframework.security.web.context.SecurityContextRepository +import org.springframework.web.filter.OncePerRequestFilter + +class ApiKeyAuthenticationFilter( + private val authenticationManager: AuthenticationManager, + private val authenticationConverter: AuthenticationConverter, +) : OncePerRequestFilter() { + private val securityContextHolderStrategy: SecurityContextHolderStrategy = + SecurityContextHolder + .getContextHolderStrategy() + + private val securityContextRepository: SecurityContextRepository = RequestAttributeSecurityContextRepository() + + private val failureHandler: AuthenticationFailureHandler = + AuthenticationEntryPointFailureHandler( + HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + ) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + val authRequest = authenticationConverter.convert(request) + if (authRequest == null) { + filterChain.doFilter(request, response) + return + } + if (authenticationIsRequired(authRequest.name)) { + val authResult = authenticationManager.authenticate(authRequest) + if (authResult == null) { + filterChain.doFilter(request, response) + return + } + successfulAuthentication(request, response, filterChain, authResult) + } + } catch (ex: AuthenticationException) { + unsuccessfulAuthentication(request, response, ex) + } + + filterChain.doFilter(request, response) + } + + private fun unsuccessfulAuthentication( + request: HttpServletRequest, + response: HttpServletResponse, + failed: AuthenticationException, + ) { + securityContextHolderStrategy.clearContext() + failureHandler.onAuthenticationFailure(request, response, failed) + } + + private fun successfulAuthentication( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + authentication: Authentication, + ) { + val context = + securityContextHolderStrategy.createEmptyContext().apply { + this.authentication = authentication + } + securityContextHolderStrategy.context = context + securityContextRepository.saveContext(context, request, response) + } + + private fun authenticationIsRequired(username: String): Boolean { + // Only reauthenticate if username doesn't match SecurityContextHolder and user isn't authenticated + val existingAuth = this.securityContextHolderStrategy.context.authentication + if (existingAuth == null || existingAuth.name != username || !existingAuth.isAuthenticated) { + return true + } + // Handle unusual condition where an AnonymousAuthenticationToken is already + // present. This shouldn't happen very often, as ApiKeyAuthenticationFilter is + // meant to be earlier in the filter chain than AnonymousAuthenticationFilter. + // Also check that the existing token is of type ApiKeyAuthenticationToken. + // This would prevent reusing a session obtained from Basic Auth for example. + return (existingAuth is AnonymousAuthenticationToken || existingAuth !is ApiKeyAuthenticationToken) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt new file mode 100644 index 000000000..fb072323e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.infrastructure.security.apikey + +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component + +/** + * A provider to lookup API keys in the repository. + */ +@Component +class ApiKeyAuthenticationProvider( + private val userRepository: KomgaUserRepository, +) : AbstractUserDetailsAuthenticationProvider() { + override fun additionalAuthenticationChecks( + userDetails: UserDetails?, + authentication: UsernamePasswordAuthenticationToken?, + ) = Unit + + override fun retrieveUser( + username: String, + authentication: UsernamePasswordAuthenticationToken, + ): UserDetails = + userRepository.findByApiKeyOrNull(authentication.credentials.toString())?.let { (user, apiKey) -> + KomgaPrincipal(user, apiKey = apiKey, name = authentication.name) + } ?: throw BadCredentialsException("Bad credentials") + + override fun createSuccessAuthentication( + principal: Any?, + authentication: Authentication?, + user: UserDetails?, + ): Authentication = + ApiKeyAuthenticationToken + .authenticated(principal, authentication?.credentials, user!!.authorities) + .apply { details = authentication?.details } + .also { logger.debug("Authenticated user") } + + override fun supports(authentication: Class<*>): Boolean = ApiKeyAuthenticationToken::class.java.isAssignableFrom(authentication) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt new file mode 100644 index 000000000..852843dd8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt @@ -0,0 +1,30 @@ +package org.gotson.komga.infrastructure.security.apikey + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +/** + * A specialization of [UsernamePasswordAuthenticationToken] to store API keys. + */ +class ApiKeyAuthenticationToken private constructor( + principal: Any?, + credentials: Any?, + authorities: Collection?, +) : UsernamePasswordAuthenticationToken(principal, credentials, authorities) { + private constructor(principal: Any?, credentials: Any?) : this(principal, credentials, null) { + isAuthenticated = false + } + + companion object { + fun authenticated( + principal: Any?, + credentials: Any?, + authorities: Collection?, + ) = ApiKeyAuthenticationToken(principal, credentials, authorities) + + fun unauthenticated( + principal: Any?, + credentials: Any?, + ) = ApiKeyAuthenticationToken(principal, credentials) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt new file mode 100644 index 000000000..451de9a35 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.infrastructure.security.apikey + +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * API key generator. + * Uses a random UUID v4 without dashes + */ +@Component +class ApiKeyGenerator { + fun generate() = UUID.randomUUID().toString().replace("-", "") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/HeaderApiKeyAuthenticationConverter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/HeaderApiKeyAuthenticationConverter.kt new file mode 100644 index 000000000..1fb226d17 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/HeaderApiKeyAuthenticationConverter.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.security.apikey + +import jakarta.servlet.http.HttpServletRequest +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.security.TokenEncoder +import org.springframework.security.authentication.AuthenticationDetailsSource +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AuthenticationConverter + +/** + * A strategy that retrieves the API key from the [headerName], + * and convert it to an [ApiKeyAuthenticationToken] + * + * @property headerName the header name from which to retrieve the API key + * @property hasher the hasher to use to encode the API key as username in the [Authentication] object + * @property tokenEncoder the encoder to use to encode the API key in the [Authentication] object + * @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details + */ +class HeaderApiKeyAuthenticationConverter( + private val headerName: String, + private val hasher: Hasher, + private val tokenEncoder: TokenEncoder, + private val authenticationDetailsSource: AuthenticationDetailsSource, +) : AuthenticationConverter { + override fun convert(request: HttpServletRequest): Authentication? = + request + .getHeader(headerName) + ?.let { + val maskedToken = hasher.computeHash(it) + val hashedToken = tokenEncoder.encode(it) + ApiKeyAuthenticationToken + .unauthenticated(maskedToken, hashedToken) + .apply { details = authenticationDetailsSource.buildDetails(request) } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt new file mode 100644 index 000000000..dad992089 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt @@ -0,0 +1,36 @@ +package org.gotson.komga.infrastructure.security.apikey + +import jakarta.servlet.http.HttpServletRequest +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.security.TokenEncoder +import org.springframework.security.authentication.AuthenticationDetailsSource +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AuthenticationConverter + +/** + * A strategy that uses a regex to retrieve the API key from the + * request URI, and convert it to an [ApiKeyAuthenticationToken] + * + * @property tokenRegex the regex used to extract the API key + * @property hasher the hasher to use to encode the API key as username in the [Authentication] object + * @property tokenEncoder the encoder to use to encode the API key as credentials in the [Authentication] object + * @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details + */ +class UriRegexApiKeyAuthenticationConverter( + private val tokenRegex: Regex, + private val hasher: Hasher, + private val tokenEncoder: TokenEncoder, + private val authenticationDetailsSource: AuthenticationDetailsSource, +) : AuthenticationConverter { + override fun convert(request: HttpServletRequest): Authentication? = + request.requestURI + ?.let { + tokenRegex.find(it)?.groupValues?.lastOrNull() + }?.let { + val maskedToken = hasher.computeHash(it) + val hashedToken = tokenEncoder.encode(it) + ApiKeyAuthenticationToken + .unauthenticated(maskedToken, hashedToken) + .apply { details = authenticationDetailsSource.buildDetails(request) } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt new file mode 100644 index 000000000..e75fcd0fd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/GithubOAuth2UserService.kt @@ -0,0 +1,62 @@ +package org.gotson.komga.infrastructure.security.oauth2 + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.RequestEntity +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.user.DefaultOAuth2User +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder + +private val logger = KotlinLogging.logger {} + +class GithubOAuth2UserService : DefaultOAuth2UserService() { + private val emailScopes = listOf("user:email", "user") + + private val parameterizedResponseType = object : ParameterizedTypeReference>>() {} + + override fun loadUser(userRequest: OAuth2UserRequest?): OAuth2User { + requireNotNull(userRequest) { "userRequest cannot be null" } + + var oAuth2User = super.loadUser(userRequest) + + if (userRequest.clientRegistration.scopes + .intersect(emailScopes) + .isNotEmpty() && + oAuth2User.getAttribute("email") == null + ) { + try { + val email = + RestTemplate() + .exchange( + RequestEntity( + HttpHeaders().apply { setBearerAuth(userRequest.accessToken.tokenValue) }, + HttpMethod.GET, + UriComponentsBuilder.fromUriString("${userRequest.clientRegistration.providerDetails.userInfoEndpoint.uri}/emails").build().toUri(), + ), + parameterizedResponseType, + ).body + ?.let { emails -> + emails + .filter { it["verified"] == true } + .filter { it["primary"] == true } + .firstNotNullOfOrNull { it["email"].toString() } + } + oAuth2User = + DefaultOAuth2User( + oAuth2User.authorities, + oAuth2User.attributes.toMutableMap().apply { put("email", email) }, + userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName, + ) + } catch (e: Exception) { + logger.warn { "Could not retrieve emails" } + } + } + + return oAuth2User + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt new file mode 100644 index 000000000..ca3b20394 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/oauth2/KomgaOAuth2UserServiceConfiguration.kt @@ -0,0 +1,80 @@ +package org.gotson.komga.infrastructure.security.oauth2 + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.security.oauth2.core.user.OAuth2User + +private val logger = KotlinLogging.logger {} + +@Configuration +class KomgaOAuth2UserServiceConfiguration( + private val userRepository: KomgaUserRepository, + private val userLifecycle: KomgaUserLifecycle, + private val komgaProperties: KomgaProperties, +) { + @Bean + fun oauth2UserService(): OAuth2UserService { + val defaultDelegate = DefaultOAuth2UserService() + val githubDelegate = GithubOAuth2UserService() + + return OAuth2UserService { userRequest: OAuth2UserRequest -> + val delegate = + when (userRequest.clientRegistration.registrationId.lowercase()) { + "github" -> githubDelegate + else -> defaultDelegate + } + + val oAuth2User = delegate.loadUser(userRequest) + + val email = + oAuth2User.getAttribute("email") + ?: throw OAuth2AuthenticationException("ERR_1024") + + val existingUser = + userRepository.findByEmailIgnoreCaseOrNull(email) + ?: tryCreateNewUser(email) + + KomgaPrincipal(existingUser, oAuth2User = oAuth2User) + } + } + + @Bean + fun oidcUserService(): OAuth2UserService { + val delegate = OidcUserService() + return OAuth2UserService { userRequest: OidcUserRequest -> + val oidcUser = delegate.loadUser(userRequest) + + if (oidcUser.email == null) throw OAuth2AuthenticationException("ERR_1028") + if (komgaProperties.oidcEmailVerification && oidcUser.emailVerified == null) throw OAuth2AuthenticationException("ERR_1027") + if (komgaProperties.oidcEmailVerification && oidcUser.emailVerified == false) throw OAuth2AuthenticationException("ERR_1026") + + val existingUser = + userRepository.findByEmailIgnoreCaseOrNull(oidcUser.email) + ?: tryCreateNewUser(oidcUser.email) + + KomgaPrincipal(existingUser, oidcUser) + } + } + + private fun tryCreateNewUser(email: String) = + if (komgaProperties.oauth2AccountCreation) { + logger.info { "Creating new user from OAuth2 login: $email" } + userLifecycle.createUser(KomgaUser(email, RandomStringUtils.secure().nextAlphanumeric(12))) + } else { + throw OAuth2AuthenticationException("ERR_1025") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionConfiguration.kt new file mode 100644 index 000000000..05bebd711 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionConfiguration.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.infrastructure.security.session + +import com.github.gotson.spring.session.caffeine.CaffeineIndexedSessionRepository +import com.github.gotson.spring.session.caffeine.config.annotation.web.http.EnableCaffeineHttpSession +import org.springframework.boot.autoconfigure.web.ServerProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.session.SessionRegistry +import org.springframework.session.FindByIndexNameSessionRepository +import org.springframework.session.config.SessionRepositoryCustomizer +import org.springframework.session.security.SpringSessionBackedSessionRegistry +import org.springframework.session.web.http.CookieSerializer +import org.springframework.session.web.http.DefaultCookieSerializer +import org.springframework.session.web.http.HttpSessionIdResolver + +@EnableCaffeineHttpSession +@Configuration +class SessionConfiguration { + @Bean + fun sessionCookieName() = "KOMGA-SESSION" + + @Bean + fun sessionHeaderName() = "X-Auth-Token" + + @Bean + fun cookieSerializer(sessionCookieName: String): CookieSerializer = + DefaultCookieSerializer().apply { + setCookieName(sessionCookieName) + } + + @Bean + fun httpSessionIdResolver( + sessionHeaderName: String, + cookieSerializer: CookieSerializer, + ): HttpSessionIdResolver = SmartHttpSessionIdResolver(sessionHeaderName, cookieSerializer) + + @Bean + fun customizeSessionRepository(serverProperties: ServerProperties) = + SessionRepositoryCustomizer { + it.setDefaultMaxInactiveInterval( + serverProperties.servlet.session.timeout.seconds + .toInt(), + ) + } + + @Bean + fun sessionRegistry(sessionRepository: FindByIndexNameSessionRepository<*>): SessionRegistry = SpringSessionBackedSessionRegistry(sessionRepository) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionListener.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionListener.kt new file mode 100644 index 000000000..d3c8227a4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SessionListener.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.infrastructure.security.session + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.event.EventListener +import org.springframework.session.events.AbstractSessionEvent +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Component +class SessionListener { + @EventListener + fun sessionEventLogging(event: AbstractSessionEvent) { + logger.debug { "${event.javaClass.simpleName}: ${event.sessionId}" } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SmartHttpSessionIdResolver.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SmartHttpSessionIdResolver.kt new file mode 100644 index 000000000..a937c4a55 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/session/SmartHttpSessionIdResolver.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.security.session + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.session.web.http.CookieHttpSessionIdResolver +import org.springframework.session.web.http.CookieSerializer +import org.springframework.session.web.http.HeaderHttpSessionIdResolver +import org.springframework.session.web.http.HttpSessionIdResolver + +class SmartHttpSessionIdResolver( + private val sessionHeaderName: String, + cookieSerializer: CookieSerializer, +) : HttpSessionIdResolver { + private val cookie = CookieHttpSessionIdResolver().apply { setCookieSerializer(cookieSerializer) } + private val header = HeaderHttpSessionIdResolver(sessionHeaderName) + + override fun resolveSessionIds(request: HttpServletRequest): List = request.getResolver().resolveSessionIds(request) + + override fun setSessionId( + request: HttpServletRequest, + response: HttpServletResponse, + sessionId: String, + ) { + request.getResolver().setSessionId(request, response, sessionId) + } + + override fun expireSession( + request: HttpServletRequest, + response: HttpServletResponse, + ) { + request.getResolver().expireSession(request, response) + } + + private fun HttpServletRequest.getResolver() = if (this.getHeader(sessionHeaderName) != null) header else cookie +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt new file mode 100644 index 000000000..02b02ea38 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.infrastructure.sidecar + +import org.gotson.komga.domain.model.Sidecar + +interface SidecarBookConsumer { + fun getSidecarBookType(): Sidecar.Type + + fun getSidecarBookPrefilter(): List + + fun isSidecarBookMatch( + basename: String, + sidecar: String, + ): Boolean +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt new file mode 100644 index 000000000..a62a672dc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.sidecar + +import org.gotson.komga.domain.model.Sidecar + +interface SidecarSeriesConsumer { + fun getSidecarSeriesType(): Sidecar.Type + + fun getSidecarSeriesFilenames(): List +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/transaction/TransactionConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/transaction/TransactionConfiguration.kt new file mode 100644 index 000000000..bc01da781 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/transaction/TransactionConfiguration.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.infrastructure.transaction + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate + +@Configuration +class TransactionConfiguration { + @Bean + fun transactionTemplate(transactionManager: PlatformTransactionManager) = TransactionTemplate(transactionManager) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/unicode/Collators.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/unicode/Collators.kt new file mode 100644 index 000000000..b5934e7f5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/unicode/Collators.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.infrastructure.unicode + +import com.ibm.icu.text.Collator + +object Collators { + /** + * Used for matching + */ + val collator1: Collator = + Collator.getInstance().apply { + strength = Collator.PRIMARY + decomposition = Collator.CANONICAL_DECOMPOSITION + } + + /** + * Used for sorting + */ + val collator3: Collator = + Collator.getInstance().apply { + strength = Collator.TERTIARY + decomposition = Collator.CANONICAL_DECOMPOSITION + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/TempDirectoryChecker.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/TempDirectoryChecker.kt new file mode 100644 index 000000000..65845a3d2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/TempDirectoryChecker.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.infrastructure.util + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.Files +import kotlin.io.path.Path + +private val logger = KotlinLogging.logger {} + +/** + * Checks that the temp directory, obtained with System.getProperty("java.io.tmpdir"), + * exists and is accessible. + * If the directory does not exist, this will attempt to create it. + * + * @throws IllegalStateException if the temp directory cannot be created, + * or is not accessible, or if the system property 'java.io.tmpdir' is not defined. + */ +fun checkTempDirectory() { + System.getProperty("java.io.tmpdir")?.let { + val tmpDir = Path(it) + + if (Files.notExists(tmpDir)) { + logger.warn { "Temp directory does not exist, attempting to create it: $tmpDir" } + try { + Files.createDirectories(tmpDir) + logger.info { "Created missing temp directory: $tmpDir" } + } catch (e: Exception) { + logger.error(e) { "Could not create missing temp directory: $tmpDir" } + throw IllegalStateException("Could not create missing temp directory: $tmpDir", e) + } + } + + check(Files.isWritable(tmpDir)) { "Temp directory is not writable: $tmpDir" } + } ?: throw IllegalStateException("System property 'java.io.tmpdir' is not defined") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/ZipFileUtils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/ZipFileUtils.kt new file mode 100644 index 000000000..f44a59c2e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/util/ZipFileUtils.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.infrastructure.util + +import org.apache.commons.compress.archivers.zip.ZipFile +import org.gotson.komga.domain.model.EntryNotFoundException +import java.io.InputStream +import java.nio.file.Path + +inline fun ZipFile.Builder.use(block: (ZipFile) -> R) = this.get().use(block) + +fun ZipFile.getEntryInputStream(entryName: String): InputStream? = this.getEntry(entryName)?.let { entry -> this.getInputStream(entry) } + +fun ZipFile.getEntryBytes(entryName: String): ByteArray? = this.getEntry(entryName)?.let { entry -> this.getInputStream(entry).use { it.readBytes() } } + +fun getZipEntryBytes( + path: Path, + entryName: String, +): ByteArray { + // fast path. Only read central directory record and try to find entry in it + val zipBuilder = + ZipFile + .builder() + .setPath(path) + .setUseUnicodeExtraFields(true) + .setIgnoreLocalFileHeader(true) + val bytes = zipBuilder.use { it.getEntryBytesClosing(entryName) } + if (bytes != null) return bytes + + // slow path. Entry with that name wasn't in central directory record + // Iterate each entry and, if present, set name from Unicode extra field in local file header + return zipBuilder.setIgnoreLocalFileHeader(false).use { + it.getEntryBytesClosing(entryName) + ?: throw EntryNotFoundException("Entry does not exist: $entryName") + } +} + +private fun ZipFile.getEntryBytesClosing(entryName: String) = + this.use { zip -> + zip.getEntry(entryName)?.let { entry -> + zip.getInputStream(entry).use { it.readBytes() } + } + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/BCP47.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/BCP47.kt new file mode 100644 index 000000000..9ef14ed91 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/BCP47.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import org.gotson.komga.domain.model.BCP47TagValidator +import kotlin.reflect.KClass + +@Constraint(validatedBy = [BCP47Validator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.ANNOTATION_CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class BCP47( + val message: String = "Must be a valid BCP 47 language tag", + val groups: Array> = [], + val payload: Array> = [], +) + +class BCP47Validator : ConstraintValidator { + override fun isValid( + value: String?, + context: ConstraintValidatorContext?, + ): Boolean { + if (value == null) return false + return BCP47TagValidator.isValid(value) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/Blank.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/Blank.kt new file mode 100644 index 000000000..0655c5a18 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/Blank.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import kotlin.reflect.KClass + +@Constraint(validatedBy = [BlankValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.ANNOTATION_CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Blank( + val message: String = "Must be blank", + val groups: Array> = [], + val payload: Array> = [], +) + +class BlankValidator : ConstraintValidator { + override fun isValid( + value: String?, + context: ConstraintValidatorContext?, + ): Boolean { + if (value == null) return false + return value.isBlank() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrBCP47.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrBCP47.kt new file mode 100644 index 000000000..562d2dbe2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrBCP47.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.constraints.Null +import org.hibernate.validator.constraints.CompositionType +import org.hibernate.validator.constraints.ConstraintComposition +import kotlin.reflect.KClass + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = []) +@Null +@Blank +@BCP47 +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrBlankOrBCP47( + val message: String = "Must be null or blank or valid BCP 47 language tag", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrISBN.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrISBN.kt new file mode 100644 index 000000000..cc1ea86b2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrBlankOrISBN.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.constraints.Null +import org.hibernate.validator.constraints.CompositionType +import org.hibernate.validator.constraints.ConstraintComposition +import org.hibernate.validator.constraints.ISBN +import kotlin.reflect.KClass + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = []) +@Null +@Blank +@ISBN +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrBlankOrISBN( + val message: String = "Must be null or blank or valid ISBN-13", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotBlank.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotBlank.kt new file mode 100644 index 000000000..ac04bb84e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotBlank.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Null +import org.hibernate.validator.constraints.CompositionType +import org.hibernate.validator.constraints.ConstraintComposition +import kotlin.reflect.KClass + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = []) +@Null +@NotBlank +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrNotBlank( + val message: String = "Must be null or not blank", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt new file mode 100644 index 000000000..2bdb57083 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/validation/NullOrNotEmpty.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.validation + +import jakarta.validation.Constraint +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Null +import org.hibernate.validator.constraints.CompositionType +import org.hibernate.validator.constraints.ConstraintComposition +import kotlin.reflect.KClass + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = []) +@Null +@NotEmpty +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrNotEmpty( + val message: String = "Must be null or not empty", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt new file mode 100644 index 000000000..6d840dc07 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Authors.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.infrastructure.web + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class Authors diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/AuthorsHandlerMethodArgumentResolver.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/AuthorsHandlerMethodArgumentResolver.kt new file mode 100644 index 000000000..1d31a6f84 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/AuthorsHandlerMethodArgumentResolver.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.infrastructure.web + +import org.gotson.komga.domain.model.Author +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class AuthorsHandlerMethodArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.getParameterAnnotation(Authors::class.java) != null + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? { + val param = webRequest.getParameterValues("author") ?: return null + + // Single empty parameter, e.g "author=" + if (param.size == 1 && param[0].isNullOrBlank()) return null + + return parseParameterIntoAuthors(param.toList()) + } + + private fun parseParameterIntoAuthors( + source: List, + delimiter: String = ",", + ): List = + source + .filter { it.contains(delimiter) } + .map { Author(name = it.substringBeforeLast(delimiter), role = it.substringAfterLast(delimiter)) } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsFilterConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsFilterConfiguration.kt new file mode 100644 index 000000000..8c2cbe25c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsFilterConfiguration.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class BracketParamsFilterConfiguration { + @Bean + fun bracketParamsFilter(): FilterRegistrationBean = + FilterRegistrationBean(BracketParamsFilter()) + .also { + it.addUrlPatterns( + "/api/*", + ) + it.setName("queryParamsFilter") + } + + inner class BracketParamsFilter : Filter { + override fun doFilter( + request: ServletRequest?, + response: ServletResponse?, + chain: FilterChain?, + ) { + chain?.doFilter(BracketParamsRequestWrapper(request as HttpServletRequest), response) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapper.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapper.kt new file mode 100644 index 000000000..c481864b3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapper.kt @@ -0,0 +1,46 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import org.gotson.komga.language.toEnumeration +import java.util.Enumeration + +class BracketParamsRequestWrapper( + request: HttpServletRequest, +) : HttpServletRequestWrapper(request) { + override fun getParameter(name: String): String? { + val nameWithoutSuffix = name.removeSuffix("[]") + val values = listOfNotNull(super.getParameter(nameWithoutSuffix), super.getParameter("$nameWithoutSuffix[]")) + return if (values.isEmpty()) + null + else + values.joinToString(",") + } + + override fun getParameterValues(name: String): Array? { + val nameWithoutSuffix = name.removeSuffix("[]") + val regular = super.getParameterValues(nameWithoutSuffix) + val suffix = super.getParameterValues("$nameWithoutSuffix[]") + val values = listOfNotNull(regular, suffix) + return if (values.isEmpty()) + null + else + values.reduce { acc, strings -> acc + strings } + } + + override fun getParameterNames(): Enumeration = + super + .getParameterNames() + .toList() + .map { it.removeSuffix("[]") } + .distinct() + .toEnumeration() + + override fun getParameterMap(): MutableMap> = + super + .getParameterMap() + .asSequence() + .groupBy({ it.key.removeSuffix("[]") }, { it.value }) + .mapValues { it.value.reduce { acc, strings -> acc + strings } } + .toMutableMap() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt new file mode 100644 index 000000000..5215446d5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPair.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.web + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +@Deprecated("was used only for search_regex which is deprecated") +annotation class DelimitedPair( + val parameterName: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt new file mode 100644 index 000000000..bea559860 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/DelimitedPairHandlerMethodArgumentResolver.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class DelimitedPairHandlerMethodArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.getParameterAnnotation(DelimitedPair::class.java) != null + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Pair? { + val paramName = parameter.getParameterAnnotation(DelimitedPair::class.java)?.parameterName ?: return null + val param = webRequest.getParameterValues(paramName) ?: return null + + // Single empty parameter, e.g "search=" + if (param.size == 1 && param[0].isNullOrBlank()) return null + + return parseParameterIntoPairs(param.first()) + } + + private fun parseParameterIntoPairs( + source: String, + delimiter: String = ",", + ): Pair? = + if (!source.contains(delimiter)) + null + else + Pair(source.substringBeforeLast(delimiter), source.substringAfterLast(delimiter)) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt new file mode 100644 index 000000000..1eea7e4bd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.server.PathContainer +import org.springframework.web.filter.ShallowEtagHeaderFilter +import org.springframework.web.util.pattern.PathPatternParser + +@Configuration +class EtagFilterConfiguration { + private val excludePatterns = + listOf( + PathPatternParser.defaultInstance.parse("/api/v1/books/*/file/**"), + PathPatternParser.defaultInstance.parse("/opds/v1.2/books/*/file/**"), + PathPatternParser.defaultInstance.parse("/api/v1/readlists/*/file/**"), + PathPatternParser.defaultInstance.parse("/api/v1/series/*/file/**"), + PathPatternParser.defaultInstance.parse("/kobo/*/v1/books/*/file/**"), + ) + + @Bean + fun shallowEtagHeaderFilter(): FilterRegistrationBean = + FilterRegistrationBean( + object : ShallowEtagHeaderFilter() { + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + val path = PathContainer.parsePath(request.servletPath) + return excludePatterns.any { it.matches(path) } + } + }, + ).also { + it.addUrlPatterns( + "/api/*", + "/opds/*", + "/kobo/*", + ) + it.setName("etagFilter") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilter.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilter.kt new file mode 100644 index 000000000..adc19b954 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilter.kt @@ -0,0 +1,67 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse +import org.springframework.web.filter.OncePerRequestFilter + +class KoboMissingPortFilter( + private val koboPortSupplier: () -> Int?, +) : OncePerRequestFilter() { + companion object { + private val FORWARDED_HEADER_NAMES = + setOf( + "Forwarded", + "X-Forwarded-Host", + "X-Forwarded-Port", + "X-Forwarded-Proto", + "X-Forwarded-Prefix", + "X-Forwarded-Ssl", + "X-Forwarded-For", + ) + } + + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + // ignore this filter if forwarded headers are present + FORWARDED_HEADER_NAMES.forEach { if (request.getHeader(it) != null) return true } + return false + } + + override fun shouldNotFilterAsyncDispatch(): Boolean = false + + override fun shouldNotFilterErrorDispatch(): Boolean = false + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val wrappedRequest = + try { + KoboMissingPortRequest(request, koboPortSupplier) + } catch (ex: Throwable) { + if (logger.isDebugEnabled) logger.debug("Failed to apply missing port to " + formatRequest(request), ex) + response.sendError(HttpServletResponse.SC_BAD_REQUEST) + return + } + filterChain.doFilter(wrappedRequest, response) + } + + override fun doFilterNestedErrorDispatch( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + doFilterInternal(request, response, filterChain) + } + + private fun formatRequest(request: HttpServletRequest) = "HTTP ${request.method} \"${request.requestURI}\"" + + private class KoboMissingPortRequest( + request: HttpServletRequest, + val port: () -> Int?, + ) : HttpServletRequestWrapper(request) { + override fun getServerPort() = port() ?: request.serverPort + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilterConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilterConfiguration.kt new file mode 100644 index 000000000..bafa02e61 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/KoboMissingPortFilterConfiguration.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.annotation.PostConstruct +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.web.filter.ForwardedHeaderFilter + +@Configuration +class KoboMissingPortFilterConfiguration( + private val komgaSettingsProvider: KomgaSettingsProvider, + private val serverSettings: WebServerEffectiveSettings, + private val forwardedHeaderFilter: FilterRegistrationBean?, +) { + @Bean + fun koboMissingPortFilter(): FilterRegistrationBean = + FilterRegistrationBean( + KoboMissingPortFilter { komgaSettingsProvider.koboPort ?: serverSettings.effectiveServerPort }, + ).apply { + addUrlPatterns( + "/kobo/*", + ) + setName("koboMissingPortFilter") + order = Ordered.HIGHEST_PRECEDENCE + } + + @PostConstruct + fun adjustForwardHeaderFilterOrder() { + // the ForwardHeaderFilter must be after the KoboMissingPortFilter, as the latter's detection is based on forwarded headers + // that the former will remove + forwardedHeaderFilter?.order = Ordered.HIGHEST_PRECEDENCE + 1 + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt new file mode 100644 index 000000000..de8149b8e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.filter.CommonsRequestLoggingFilter + +@Configuration +class RequestLoggingFilterConfig { + @ConditionalOnProperty(value = ["logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter"], havingValue = "debug") + @Bean + fun logFilter() = + CommonsRequestLoggingFilter().apply { + setIncludeQueryString(true) + setIncludePayload(true) + setMaxPayloadLength(10000) + setIncludeHeaders(true) + setAfterMessagePrefix("REQUEST DATA: ") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt new file mode 100644 index 000000000..8bbc64f96 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt @@ -0,0 +1,37 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.CacheControl +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.net.URL +import java.nio.file.Paths +import java.util.concurrent.TimeUnit +import kotlin.io.path.pathString +import kotlin.io.path.toPath + +fun URL.toFilePath(): String = this.toURI().toPath().pathString + +fun filePathToUrl(filePath: String): URL = Paths.get(filePath).toUri().toURL() + +fun ResponseEntity.BodyBuilder.setCachePrivate() = this.cacheControl(cachePrivate) + +val cachePrivate = + CacheControl + .maxAge(0, TimeUnit.SECONDS) + .cachePrivate() + .mustRevalidate() + +fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { + mediaTypeString?.let { + try { + return MediaType.parseMediaType(mediaTypeString) + } catch (_: Exception) { + } + } + return MediaType.APPLICATION_OCTET_STREAM +} + +fun getCurrentRequest(): HttpServletRequest = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes?)?.request ?: throw IllegalStateException("Could not get current request") diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt new file mode 100644 index 000000000..053fe9482 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebMvcConfiguration.kt @@ -0,0 +1,69 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.context.annotation.Configuration +import org.springframework.http.CacheControl +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.mvc.WebContentInterceptor +import java.util.concurrent.TimeUnit + +@Configuration +class WebMvcConfiguration : WebMvcConfigurer { + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + if (!registry.hasMappingForPattern("/webjars/**")) { + registry + .addResourceHandler("/webjars/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/") + } + + if (!registry.hasMappingForPattern("/swagger-ui.html**")) { + registry + .addResourceHandler("/swagger-ui.html**") + .addResourceLocations("classpath:/META-INF/resources/") + } + + registry + .addResourceHandler( + "/index.html", + "/index-next.html", + "/favicon.ico", + "/favicon-16x16.png", + "/favicon-32x32.png", + "/mstile-144x144.png", + "/apple-touch-icon.png", + "/apple-touch-icon-180x180.png", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", + "/manifest.json", + "/layers.css", + ).addResourceLocations("classpath:public/") + .setCacheControl(CacheControl.noStore()) + + listOf("css", "fonts", "img", "js", "assets") + .forEach { + registry + .addResourceHandler("/$it/**") + .addResourceLocations("classpath:public/$it/") + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) + } + } + + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor( + WebContentInterceptor().apply { + addCacheMapping( + cachePrivate, + "/api/**", + "/opds/**", + ) + }, + ) + } + + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(AuthorsHandlerMethodArgumentResolver()) + resolvers.add(DelimitedPairHandlerMethodArgumentResolver()) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerConfiguration.kt new file mode 100644 index 000000000..805bbf168 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerConfiguration.kt @@ -0,0 +1,29 @@ +package org.gotson.komga.infrastructure.web + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Component +class WebServerConfiguration( + private val settingsProvider: KomgaSettingsProvider, +) : WebServerFactoryCustomizer { + override fun customize(factory: ConfigurableServletWebServerFactory) { + settingsProvider.serverPort?.let { + if (it > 1) + factory.setPort(it) + else + logger.warn { "Ignoring invalid server port: $it" } + } + settingsProvider.serverContextPath?.let { + if (it.startsWith("/") && !it.endsWith("/")) + factory.setContextPath(it) + else + logger.warn { "Ignoring invalid server context path: $it" } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerEffectiveSettings.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerEffectiveSettings.kt new file mode 100644 index 000000000..71fd22723 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/WebServerEffectiveSettings.kt @@ -0,0 +1,19 @@ +package org.gotson.komga.infrastructure.web + +import jakarta.servlet.ServletContext +import org.springframework.boot.web.servlet.context.ServletWebServerInitializedEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class WebServerEffectiveSettings( + servletContext: ServletContext, +) { + var effectiveServerPort: Int? = null + val effectiveServletContextPath: String = servletContext.contextPath + + @EventListener + fun onApplicationEvent(event: ServletWebServerInitializedEvent) { + effectiveServerPort = event.webServer.port + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/MappingJackson2XmlHttpMessageConverterConfiguration.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/MappingJackson2XmlHttpMessageConverterConfiguration.kt new file mode 100644 index 000000000..b82822ef6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/MappingJackson2XmlHttpMessageConverterConfiguration.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.infrastructure.xml + +import org.gotson.komga.interfaces.api.opds.v1.dto.prefixToNamespace +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter + +@Configuration +class MappingJackson2XmlHttpMessageConverterConfiguration { + @Bean + fun mappingJackson2XmlHttpMessageConverter(builder: Jackson2ObjectMapperBuilder) = + MappingJackson2XmlHttpMessageConverter( + builder.createXmlMapper(true).factory(NamespaceXmlFactory(prefixToNamespace = prefixToNamespace)).build(), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/NamespaceXmlFactory.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/NamespaceXmlFactory.kt new file mode 100644 index 000000000..7cd7a57c8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/infrastructure/xml/NamespaceXmlFactory.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.infrastructure.xml + +import com.fasterxml.jackson.core.JsonEncoding +import com.fasterxml.jackson.core.io.IOContext +import com.fasterxml.jackson.dataformat.xml.XmlFactory +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator +import com.fasterxml.jackson.dataformat.xml.util.StaxUtil +import java.io.File +import java.io.OutputStream +import java.io.Writer +import javax.xml.stream.XMLStreamException +import javax.xml.stream.XMLStreamWriter + +class NamespaceXmlFactory( + private val defaultNamespace: String? = null, + private val prefixToNamespace: Map = emptyMap(), +) : XmlFactory() { + override fun _createXmlWriter( + ctxt: IOContext?, + w: Writer?, + ): XMLStreamWriter = super._createXmlWriter(ctxt, w).apply { configure() } + + override fun createGenerator( + out: OutputStream?, + enc: JsonEncoding?, + ): ToXmlGenerator = super.createGenerator(out, enc).apply { staxWriter.configure() } + + override fun createGenerator(out: OutputStream?): ToXmlGenerator = super.createGenerator(out).apply { staxWriter.configure() } + + override fun createGenerator(out: Writer?): ToXmlGenerator = super.createGenerator(out).apply { staxWriter.configure() } + + override fun createGenerator( + f: File?, + enc: JsonEncoding?, + ): ToXmlGenerator = super.createGenerator(f, enc).apply { staxWriter.configure() } + + override fun createGenerator(sw: XMLStreamWriter?): ToXmlGenerator = super.createGenerator(sw).apply { staxWriter.configure() } + + private fun XMLStreamWriter.configure() = + try { + defaultNamespace?.let { this.setDefaultNamespace(it) } + for ((key, value) in prefixToNamespace) { + this.setPrefix(key, value) + } + } catch (e: XMLStreamException) { + StaxUtil.throwAsGenerationException(e, null) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt new file mode 100644 index 000000000..23438af27 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt @@ -0,0 +1,415 @@ +package org.gotson.komga.interfaces.api + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import jakarta.servlet.http.HttpServletRequest +import org.apache.commons.io.FilenameUtils +import org.apache.commons.io.IOUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.EntryNotFoundException +import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.model.toR2Progression +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.springframework.core.io.FileSystemResource +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.util.MimeTypeUtils +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.FileNotFoundException +import java.io.OutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.NoSuchFileException +import kotlin.io.path.name + +private val logger = KotlinLogging.logger {} +private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg") + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class CommonBookController( + private val mediaRepository: MediaRepository, + private val bookRepository: BookRepository, + private val bookDtoRepository: BookDtoRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookLifecycle: BookLifecycle, + private val bookAnalyzer: BookAnalyzer, + private val contentRestrictionChecker: ContentRestrictionChecker, + private val contentDetector: ContentDetector, + private val readProgressRepository: ReadProgressRepository, +) { + fun getWebPubManifestInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = mediaRepository.findByIdOrNull(bookId)?.let { media -> + when ( + org.gotson.komga.domain.model.MediaType + .fromMediaType(media.mediaType) + ?.profile + ) { + MediaProfile.DIVINA -> getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) + MediaProfile.PDF -> getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) + MediaProfile.EPUB -> getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) + null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestEpubInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookDto) + webPubGenerator.toManifestEpub( + bookDto, + mediaRepository.findById(bookId), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestPdfInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + if (bookDto.media.mediaProfile != MediaProfile.PDF.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookDto) + webPubGenerator.toManifestPdf( + bookDto, + mediaRepository.findById(bookDto.id), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestDivinaInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookDto) + webPubGenerator.toManifestDivina( + bookDto, + mediaRepository.findById(bookDto.id), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getBookPageInternal( + bookId: String, + pageNumber: Int, + convertTo: String?, + request: ServletWebRequest, + principal: KomgaPrincipal, + acceptHeaders: MutableList?, + ) = bookRepository.findByIdOrNull((bookId))?.let { book -> + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { + return@let ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + if (media.profile == MediaProfile.PDF && acceptHeaders != null && acceptHeaders.any { it.isCompatibleWith(MediaType.APPLICATION_PDF) }) { + // keep only pdf and image + acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) } + MimeTypeUtils.sortBySpecificity(acceptHeaders) + if (acceptHeaders.first().isCompatibleWith(MediaType.APPLICATION_PDF)) + return getBookPageRawInternal(book, media, pageNumber) + } + + try { + val convertFormat = + when (convertTo?.lowercase()) { + "jpeg" -> ImageType.JPEG + "png" -> ImageType.PNG + "", null -> null + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo") + } + + val pageContent = bookLifecycle.getBookPage(book, pageNumber, convertFormat) + + ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "jpeg" + val imageFileName = "${book.name}-$pageNumber$extension" + contentDisposition = + ContentDisposition + .builder("inline") + .filename(imageFileName, StandardCharsets.UTF_8) + .build() + }, + ).contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .setNotModified(media) + .body(pageContent.bytes) + } catch (_: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: ImageConversionException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, ex.message) + } catch (_: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get raw book page", description = "Returns the book page in raw format, without content negotiation.", tags = [OpenApiConfiguration.TagNames.BOOK_PAGES]) + @GetMapping( + value = [ + "api/v1/books/{bookId}/pages/{pageNumber}/raw", + "opds/v2/books/{bookId}/pages/{pageNumber}/raw", + ], + produces = [MediaType.ALL_VALUE], + ) + @PreAuthorize("hasRole('PAGE_STREAMING')") + fun getBookPageRawByNumber( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + bookRepository.findByIdOrNull((bookId))?.let { book -> + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { + return@let ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + getBookPageRawInternal(book, media, pageNumber) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getBookPageRawInternal( + book: Book, + media: Media, + pageNumber: Int, + ): ResponseEntity = + try { + val pageContent = bookAnalyzer.getPageContentRaw(BookWithMedia(book, media), pageNumber) + + ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "" + val pageFileName = "${book.name}-$pageNumber$extension" + contentDisposition = + ContentDisposition + .builder("inline") + .filename(pageFileName, StandardCharsets.UTF_8) + .build() + }, + ).contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .setNotModified(media) + .body(pageContent.bytes) + } catch (_: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: MediaUnsupportedException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message) + } catch (_: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + + @Operation(summary = "Get Epub resource", description = "Return a resource from within an Epub book.", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @SecurityRequirements + @GetMapping( + value = [ + "api/v1/books/{bookId}/resource/{*resource}", + "opds/v2/books/{bookId}/resource/{*resource}", + ], + produces = ["*/*"], + ) + fun getBookEpubResource( + request: HttpServletRequest, + @AuthenticationPrincipal principal: KomgaPrincipal?, + @PathVariable bookId: String, + @PathVariable resource: String, + ): ResponseEntity { + val resourceName = resource.removePrefix("/") + val isFont = FONT_EXTENSIONS.contains(FilenameUtils.getExtension(resourceName).lowercase()) + + if (!isFont && principal == null) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + + val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val media = mediaRepository.findById(book.id) + + if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) { + return ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .header("Content-Security-Policy", "script-src 'none'; object-src 'none';") + .setNotModified(media) + .body(ByteArray(0)) + } + + if (media.profile != MediaProfile.EPUB) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${media.mediaType}' not compatible with requested profile") + if (!isFont) contentRestrictionChecker.checkContentRestrictionBook(principal!!.user, book) + + val res = media.files.firstOrNull { it.fileName == resourceName } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val bytes = + try { + bookAnalyzer.getFileContent(BookWithMedia(book, media), resourceName) + } catch (_: EntryNotFoundException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + return ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("inline") + .filename(FilenameUtils.getName(resourceName), StandardCharsets.UTF_8) + .build() + set("Content-Security-Policy", "script-src 'none'; object-src 'none';") + }, + ).contentType(getMediaTypeOrDefault(res.mediaType)) + .setNotModified(media) + .body(bytes) + } + + @Operation(summary = "Download book file", description = "Download the book file.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @GetMapping( + value = [ + "api/v1/books/{bookId}/file", + "api/v1/books/{bookId}/file/*", + "opds/v1.2/books/{bookId}/file/*", + "opds/v2/books/{bookId}/file", + "opds/v2/books/{bookId}/file/*", + ], + produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], + ) + @PreAuthorize("hasRole('FILE_DOWNLOAD')") + fun downloadBookFile( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = getBookFileInternal(principal, bookId) + + fun getBookFileInternal( + principal: KomgaPrincipal, + bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + try { + val media = mediaRepository.findById(book.id) + with(FileSystemResource(book.path)) { + if (!exists()) throw FileNotFoundException(path) + val stream = + StreamingResponseBody { os: OutputStream -> + this.inputStream.use { + IOUtils.copyLarge(it, os, ByteArray(8192)) + os.close() + } + } + ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("attachment") + .filename(book.path.name, StandardCharsets.UTF_8) + .build() + }, + ).contentType(getMediaTypeOrDefault(media.mediaType)) + .contentLength(this.contentLength()) + .body(stream) + } + } catch (ex: FileNotFoundException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get book progression", description = "The Progression API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = [ + "api/v1/books/{bookId}/progression", + "opds/v2/books/{bookId}/progression", + ], + produces = [MEDIATYPE_PROGRESSION_JSON_VALUE], + ) + fun getBookProgression( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let { + ResponseEntity.ok(it.toR2Progression()) + } ?: ResponseEntity.noContent().build() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Mark book progression", description = "The Progression API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @PutMapping( + value = [ + "api/v1/books/{bookId}/progression", + "opds/v2/books/{bookId}/progression", + ], + ) + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateBookProgression( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestBody progression: R2Progression, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + try { + bookLifecycle.markProgression(book, principal.user, progression) + } catch (e: IllegalStateException) { + throw ResponseStatusException(HttpStatus.CONFLICT, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt new file mode 100644 index 000000000..17f94ca1c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt @@ -0,0 +1,157 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class ContentRestrictionChecker( + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookRepository: BookRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val seriesRepository: SeriesRepository, + private val thumbnailSeriesRepository: ThumbnailSeriesRepository, +) { + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionBook( + komgaUser: KomgaUser, + book: BookDto, + ) { + if (!komgaUser.canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (komgaUser.restrictions.isRestricted) + seriesMetadataRepository.findById(book.seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionBook( + komgaUser: KomgaUser, + book: Book, + ) { + if (!komgaUser.canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (komgaUser.restrictions.isRestricted) + seriesMetadataRepository.findById(book.seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionBook( + komgaUser: KomgaUser, + bookId: String, + ) { + if (!komgaUser.canAccessAllLibraries()) { + bookRepository.getLibraryIdOrNull(bookId)?.let { + if (!komgaUser.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (komgaUser.restrictions.isRestricted) + bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId -> + seriesMetadataRepository.findById(seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + /** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionBookThumbnail( + komgaUser: KomgaUser, + thumbnailId: String, + ) { + if (!komgaUser.canAccessAllLibraries()) { + thumbnailBookRepository.getLibraryIdOrNull(thumbnailId)?.let { + if (!komgaUser.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (komgaUser.restrictions.isRestricted) + thumbnailBookRepository.getSeriesIdOrNull(thumbnailId)?.let { seriesId -> + seriesMetadataRepository.findById(seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + /** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionSeries( + komgaUser: KomgaUser, + series: SeriesDto, + ) { + if (!komgaUser.canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (!komgaUser.isContentAllowed(series.metadata.ageRating, series.metadata.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + + /** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionSeries( + komgaUser: KomgaUser, + seriesId: String, + ) { + if (!komgaUser.canAccessAllLibraries()) { + seriesRepository.getLibraryId(seriesId)?.let { + if (!komgaUser.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (komgaUser.restrictions.isRestricted) + seriesMetadataRepository.findById(seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestrictionSeriesThumbnail( + komgaUser: KomgaUser, + thumbnailId: String, + ) { + if (!komgaUser.canAccessAllLibraries()) { + thumbnailSeriesRepository.getLibraryIdOrNull(thumbnailId)?.let { + if (!komgaUser.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (komgaUser.restrictions.isRestricted) + thumbnailSeriesRepository.getSeriesIdOrNull(thumbnailId)?.let { seriesId -> + seriesMetadataRepository.findById(seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/OpdsGenerator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/OpdsGenerator.kt new file mode 100644 index 000000000..c5a8523c0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/OpdsGenerator.kt @@ -0,0 +1,105 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.REL_PROGRESSION_API +import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.opds.v2.ROUTE_AUTH +import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationDocumentDto +import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationFlowDto +import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationType +import org.gotson.komga.interfaces.api.opds.v2.dto.LabelsDto +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@Component +class OpdsGenerator( + @Qualifier("thumbnailType") thumbnailType: ImageType, + imageConverter: ImageConverter, + bookAnalyzer: BookAnalyzer, + mediaRepository: MediaRepository, +) : WebPubGenerator(thumbnailType, imageConverter, bookAnalyzer, mediaRepository) { + override val pathSegments = listOf("opds", "v2") + + fun toOpdsPublicationDto(bookDto: BookDto): WPPublicationDto = toBasePublicationDto(bookDto).copy(images = buildThumbnailLinkDtos(bookDto.id)) + + override fun getDefaultMediaType(): MediaType = MEDIATYPE_OPDS_PUBLICATION_JSON + + override fun getBookSeriesLink(bookDto: BookDto): List = + listOf( + WPLinkDto( + href = + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment(*pathSegments.toTypedArray()) + .path("series/${bookDto.seriesId}") + .toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ), + ) + + override fun getExtraLinkProperties(): Map> = + mapOf( + "authenticate" to + mapOf( + "href" to + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment(*pathSegments.toTypedArray()) + .path(ROUTE_AUTH) + .toUriString(), + "type" to MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE, + ), + ) + + override fun getExtraLinks(bookId: String): List = + buildList { + add( + WPLinkDto( + type = MEDIATYPE_PROGRESSION_JSON_VALUE, + rel = REL_PROGRESSION_API, + href = + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment(*pathSegments.toTypedArray()) + .path("books/$bookId/progression") + .toUriString(), + properties = getExtraLinkProperties(), + ), + ) + } + + fun generateOpdsAuthDocument() = + AuthenticationDocumentDto( + id = + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment(*pathSegments.toTypedArray()) + .path(ROUTE_AUTH) + .toUriString(), + title = "Komga", + description = "Enter your email and password to authenticate.", + links = + listOf( + WPLinkDto(rel = "help", href = "https://komga.org"), + WPLinkDto(rel = "logo", href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("android-chrome-512x512.png").toUriString()), + ), + authentication = + listOf( + AuthenticationFlowDto( + type = AuthenticationType.BASIC, + labels = LabelsDto(login = "Email", password = "Password"), + ), + ), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt new file mode 100644 index 000000000..c87156bb4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.Media +import org.gotson.komga.infrastructure.web.setCachePrivate +import org.springframework.http.ResponseEntity +import java.time.ZoneOffset + +fun getBookLastModified(media: Media) = media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() + +fun ResponseEntity.BodyBuilder.setNotModified(media: Media): ResponseEntity.BodyBuilder = this.setCachePrivate().lastModified(getBookLastModified(media)) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt new file mode 100644 index 000000000..04eab9047 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt @@ -0,0 +1,310 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.EpubTocEntry +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.ProxyExtension +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.OpdsLinkRel +import org.gotson.komga.interfaces.api.dto.PROFILE_DIVINA +import org.gotson.komga.interfaces.api.dto.PROFILE_EPUB +import org.gotson.komga.interfaces.api.dto.PROFILE_PDF +import org.gotson.komga.interfaces.api.dto.WPBelongsToDto +import org.gotson.komga.interfaces.api.dto.WPContributorDto +import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPMetadataDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.dto.WPReadingProgressionDto +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.language.toZonedDateTime +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriComponentsBuilder +import org.gotson.komga.domain.model.MediaType as KomgaMediaType + +@Component +class WebPubGenerator( + @Qualifier("thumbnailType") private val thumbnailType: ImageType, + private val imageConverter: ImageConverter, + private val bookAnalyzer: BookAnalyzer, + private val mediaRepository: MediaRepository, +) { + protected val pathSegments = listOf("api", "v1") + + protected fun toBasePublicationDto(bookDto: BookDto): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()) + return WPPublicationDto( + mediaType = getDefaultMediaType(), + context = "https://readium.org/webpub-manifest/context.jsonld", + metadata = toWPMetadataDto(bookDto).withAuthors(bookDto.metadata.authors), + links = bookDto.toWPLinkDtos(uriBuilder), + ) + } + + protected fun getDefaultMediaType(): MediaType = MEDIATYPE_WEBPUB_JSON + + protected fun buildThumbnailLinkDtos(bookId: String) = + listOf( + WPLinkDto( + href = + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment(*pathSegments.toTypedArray()) + .path("books/$bookId/thumbnail") + .toUriString(), + type = thumbnailType.mediaType, + properties = getExtraLinkProperties(), + ), + ) + + fun toManifestDivina( + bookDto: BookDto, + media: Media, + seriesMetadata: SeriesMetadata, + ): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()) + return toBasePublicationDto(bookDto).let { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + it.copy( + mediaType = MEDIATYPE_DIVINA_JSON, + metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_DIVINA), + readingOrder = + pages.mapIndexed { index: Int, page: BookPage -> + WPLinkDto( + href = + uriBuilder + .cloneBuilder() + .path("books/${bookDto.id}/pages/${index + 1}") + .queryParam("contentNegotiation", "false") + .toUriString(), + type = page.mediaType, + width = page.dimension?.width, + height = page.dimension?.height, + alternate = + if (!recommendedImageMediaTypes.contains(page.mediaType) && imageConverter.canConvertMediaType(page.mediaType, MediaType.IMAGE_JPEG_VALUE)) + listOf( + WPLinkDto( + href = + uriBuilder + .cloneBuilder() + .path("books/${bookDto.id}/pages/${index + 1}") + .queryParam("contentNegotiation", "false") + .queryParam("convert", "jpeg") + .toUriString(), + type = MediaType.IMAGE_JPEG_VALUE, + width = page.dimension?.width, + height = page.dimension?.height, + ), + ) + else + emptyList(), + ) + }, + resources = buildThumbnailLinkDtos(bookDto.id), + ) + } + } + + fun toManifestPdf( + bookDto: BookDto, + media: Media, + seriesMetadata: SeriesMetadata, + ): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()) + return toBasePublicationDto(bookDto).let { + it.copy( + mediaType = MEDIATYPE_WEBPUB_JSON, + metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_PDF), + readingOrder = + List(media.pageCount) { index: Int -> + WPLinkDto( + href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}/raw").toUriString(), + type = KomgaMediaType.PDF.type, + ) + }, + resources = buildThumbnailLinkDtos(bookDto.id), + ) + } + } + + fun toManifestEpub( + bookDto: BookDto, + media: Media, + seriesMetadata: SeriesMetadata, + ): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()) + val extension = + when (media.extension) { + is ProxyExtension if media.extension.proxyForType() -> mediaRepository.findExtensionByIdOrNull(media.bookId) as? MediaExtensionEpub + is MediaExtensionEpub -> media.extension + else -> null + } + return toBasePublicationDto(bookDto).let { publication -> + publication.copy( + mediaType = MEDIATYPE_WEBPUB_JSON, + metadata = + publication.metadata.withSeriesMetadata(seriesMetadata).copy( + conformsTo = PROFILE_EPUB, + rendition = + when (extension?.isFixedLayout) { + true -> mapOf("layout" to "fixed") + false -> mapOf("layout" to "reflowable") + else -> emptyMap() + }, + ), + readingOrder = + media.files.filter { it.subType == MediaFile.SubType.EPUB_PAGE }.map { + WPLinkDto( + href = + uriBuilder + .cloneBuilder() + .path("books/${bookDto.id}/resource/") + .path(it.fileName) + .toUriString(), + type = it.mediaType, + ) + }, + resources = + buildThumbnailLinkDtos(bookDto.id) + + media.files.filter { it.subType == MediaFile.SubType.EPUB_ASSET }.map { + WPLinkDto( + href = + uriBuilder + .cloneBuilder() + .path("books/${bookDto.id}/resource/") + .path(it.fileName) + .toUriString(), + type = it.mediaType, + ) + }, + toc = extension?.toc?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(), + landmarks = extension?.landmarks?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(), + pageList = extension?.pageList?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(), + ) + } + } + + private fun EpubTocEntry.toWPLinkDto(uriBuilder: UriComponentsBuilder): WPLinkDto = + WPLinkDto( + title = title, + href = + href?.let { + val fragment = it.substringAfterLast("#", "") + val h = it.removeSuffix("#$fragment") + uriBuilder.cloneBuilder().path(h).toUriString() + if (fragment.isNotEmpty()) "#$fragment" else "" + }, + children = children.map { it.toWPLinkDto(uriBuilder) }, + ) + + protected fun toWPMetadataDto(bookDto: BookDto) = + WPMetadataDto( + title = bookDto.metadata.title, + description = bookDto.metadata.summary, + numberOfPages = bookDto.media.pagesCount, + modified = bookDto.lastModified.toZonedDateTime(), + published = bookDto.metadata.releaseDate, + subject = bookDto.metadata.tags.toList(), + identifier = if (bookDto.metadata.isbn.isNotBlank()) "urn:isbn:${bookDto.metadata.isbn}" else null, + belongsTo = + WPBelongsToDto( + series = + listOf( + WPContributorDto( + bookDto.seriesTitle, + bookDto.metadata.numberSort, + getBookSeriesLink(bookDto), + ), + ), + ), + ) + + protected fun getBookSeriesLink(bookDto: BookDto): List = emptyList() + + private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = + copy( + language = seriesMetadata.language, + readingProgression = + when (seriesMetadata.readingDirection) { + SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR + SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL + SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB + SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB + null -> null + }, + ) + + private fun WPMetadataDto.withAuthors(authors: List): WPMetadataDto { + val groups = authors.groupBy({ it.role }, { it.name }) + return copy( + author = groups["author"].orEmpty(), + translator = groups["translator"].orEmpty(), + editor = groups["editor"].orEmpty(), + artist = groups["artist"].orEmpty(), + illustrator = groups["illustrator"].orEmpty(), + letterer = groups["letterer"].orEmpty(), + penciler = groups["penciler"].orEmpty() + groups["penciller"].orEmpty(), + colorist = groups["colorist"].orEmpty(), + inker = groups["inker"].orEmpty(), + // use contributor role for all roles not mentioned above + contributor = authors.filterNot { wpKnownRoles.contains(it.role) }.map { it.name }, + ) + } + + protected fun getExtraLinkProperties(): Map> = emptyMap() + + protected fun getExtraLinks(bookId: String): List = emptyList() + + private fun BookDto.toWPLinkDtos(uriBuilder: UriComponentsBuilder): List { + val komgaMediaType = KomgaMediaType.fromMediaType(media.mediaType) + return buildList { + // most appropriate manifest + add(WPLinkDto(rel = OpdsLinkRel.SELF, href = uriBuilder.cloneBuilder().path("books/$id/manifest").toUriString(), type = mediaProfileToWebPub(komgaMediaType?.profile), properties = getExtraLinkProperties())) + // PDF is also available under the Divina profile / EPUB that are Divina compatible + if (komgaMediaType?.profile == MediaProfile.PDF || (komgaMediaType?.profile == MediaProfile.EPUB && media.epubDivinaCompatible)) + add(WPLinkDto(href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE, properties = getExtraLinkProperties())) + // main acquisition link + add(WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = komgaMediaType?.exportType ?: media.mediaType, href = uriBuilder.cloneBuilder().path("books/$id/file").toUriString(), properties = getExtraLinkProperties())) + // extra links + addAll(getExtraLinks(id)) + } + } + + private fun mediaProfileToWebPub(profile: MediaProfile?): String = + when (profile) { + MediaProfile.DIVINA -> MEDIATYPE_DIVINA_JSON_VALUE + MediaProfile.PDF -> MEDIATYPE_WEBPUB_JSON_VALUE + MediaProfile.EPUB -> MEDIATYPE_WEBPUB_JSON_VALUE + null -> MEDIATYPE_WEBPUB_JSON_VALUE + } + + companion object { + private val wpKnownRoles = + listOf( + "author", + "translator", + "editor", + "artist", + "illustrator", + "letterer", + "penciler", + "penciller", + "colorist", + "inker", + ) + private val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt new file mode 100644 index 000000000..04c7ce3d3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.interfaces.api.dto + +import org.springframework.http.MediaType + +const val MEDIATYPE_OPDS_JSON_VALUE = "application/opds+json" +const val MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE = "application/opds-publication+json" +const val MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE = "application/opds-authentication+json" +const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json" +const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json" +const val MEDIATYPE_POSITION_LIST_JSON_VALUE = "application/vnd.readium.position-list+json" +const val MEDIATYPE_PROGRESSION_JSON_VALUE = "application/vnd.readium.progression+json" + +const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina" +const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub" +const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf" + +const val REL_PROGRESSION_API = "http://www.cantook.com/api/progression" + +val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json") +val MEDIATYPE_DIVINA_JSON = MediaType("application", "divina+json") +val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json") +val MEDIATYPE_POSITION_LIST_JSON = MediaType("application", "vnd.readium.position-list+json") +val MEDIATYPE_PROGRESSION_JSON = MediaType("application", "vnd.readium.progression+json") diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt new file mode 100644 index 000000000..b3007faac --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.interfaces.api.dto + +class OpdsLinkRel { + companion object { + const val SEARCH = "search" + const val COVER = "cover" + const val SELF = "self" + const val START = "start" + const val PREVIOUS = "previous" + const val NEXT = "next" + const val SUBSECTION = "subsection" + const val SORT_NEW = "http://opds-spec.org/sort/new" + const val SORT_POPULAR = "http://opds-spec.org/sort/popular" + const val ACQUISITION = "http://opds-spec.org/acquisition" + const val AUTH = "http://opds-spec.org/auth/document" + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt new file mode 100644 index 000000000..3d4530655 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt @@ -0,0 +1,106 @@ +package org.gotson.komga.interfaces.api.dto + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.Positive +import org.springframework.http.MediaType +import java.time.LocalDate +import java.time.ZonedDateTime + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class WPLinkDto( + val title: String? = null, + val rel: String? = null, + val href: String? = null, + val type: String? = null, + val templated: Boolean? = null, + @Positive + val width: Int? = null, + @Positive + val height: Int? = null, + val alternate: List = emptyList(), + val children: List = emptyList(), + val properties: Map> = emptyMap(), +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class WPPublicationDto( + @JsonIgnore + val mediaType: MediaType, + @JsonAlias("@context") + val context: String? = null, + val metadata: WPMetadataDto, + val links: List, + val images: List = emptyList(), + val readingOrder: List = emptyList(), + val resources: List = emptyList(), + // epub specific fields + val toc: List = emptyList(), + val landmarks: List = emptyList(), + val pageList: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class WPMetadataDto( + val title: String, + val identifier: String? = null, + @JsonAlias("@type") + val type: String? = null, + val conformsTo: String? = null, + val sortAs: String? = null, + val subtitle: String? = null, + val modified: ZonedDateTime? = null, + val published: LocalDate? = null, + val language: String? = null, + val author: List = emptyList(), + val translator: List = emptyList(), + val editor: List = emptyList(), + val artist: List = emptyList(), + val illustrator: List = emptyList(), + val letterer: List = emptyList(), + val penciler: List = emptyList(), + val colorist: List = emptyList(), + val inker: List = emptyList(), + val contributor: List = emptyList(), + val publisher: List = emptyList(), + val subject: List = emptyList(), + val readingProgression: WPReadingProgressionDto? = null, + val description: String? = null, + @Positive + val numberOfPages: Int? = null, + val belongsTo: WPBelongsToDto? = null, + // epub specific fields + val rendition: Map = emptyMap(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class WPBelongsToDto( + val series: List = emptyList(), + val collection: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class WPContributorDto( + val name: String, + val position: Float? = null, + val links: List = emptyList(), +) + +enum class WPReadingProgressionDto { + @JsonProperty("rtl") + RTL, + + @JsonProperty("ltr") + LTR, + + @JsonProperty("ttb") + TTB, + + @JsonProperty("btt") + BTT, + + @JsonProperty("auto") + AUTO, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt new file mode 100644 index 000000000..4352a0e6b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt @@ -0,0 +1,852 @@ +package org.gotson.komga.interfaces.api.kobo + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.readValue +import com.fasterxml.jackson.module.kotlin.treeToValue +import com.github.benmanes.caffeine.cache.Caffeine +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.KomgaSyncToken +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaType.EPUB +import org.gotson.komga.domain.model.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.SyncPointLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.kobo.KepubConverter +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_DEVICEID +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNC +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_USERKEY +import org.gotson.komga.infrastructure.kobo.KoboProxy +import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.getCurrentRequest +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.gotson.komga.interfaces.api.kobo.dto.AuthDto +import org.gotson.komga.interfaces.api.kobo.dto.BookEntitlementContainerDto +import org.gotson.komga.interfaces.api.kobo.dto.BookmarkDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedProductMetadataDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedTagDto +import org.gotson.komga.interfaces.api.kobo.dto.DeletedTagDto +import org.gotson.komga.interfaces.api.kobo.dto.DownloadUrlDto +import org.gotson.komga.interfaces.api.kobo.dto.FormatDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto +import org.gotson.komga.interfaces.api.kobo.dto.NewEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.NewTagDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateStateUpdateDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateUpdateResultDto +import org.gotson.komga.interfaces.api.kobo.dto.RequestResultDto +import org.gotson.komga.interfaces.api.kobo.dto.ResourcesDto +import org.gotson.komga.interfaces.api.kobo.dto.ResultDto +import org.gotson.komga.interfaces.api.kobo.dto.StatisticsDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusInfoDto +import org.gotson.komga.interfaces.api.kobo.dto.SyncResultDto +import org.gotson.komga.interfaces.api.kobo.dto.TagItemDto +import org.gotson.komga.interfaces.api.kobo.dto.TestsDto +import org.gotson.komga.interfaces.api.kobo.dto.WrappedReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.toBookEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.toDto +import org.gotson.komga.interfaces.api.kobo.dto.toWrappedTagDto +import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository +import org.gotson.komga.language.toUTCZoned +import org.springframework.core.io.FileSystemResource +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriBuilder +import org.springframework.web.util.UriComponentsBuilder +import java.io.FileNotFoundException +import java.io.OutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.time.ZonedDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.nameWithoutExtension + +private val logger = KotlinLogging.logger {} + +/** + * The following documentation is coming from the awesome work from [Calibre-web](https://github.com/gotson/calibre-web/blob/14b578dd3a15bd371102d5b9828da830e59b4557/cps/kobo_auth.py). + * + * **Log-in** + * + * When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. + * Upon successful sign-in, the user is redirected to + * https://auth.kobobooks.com/CrossDomainSignIn?id= + * which serves the following response: + * + * ```html + * + * ``` + * + * And triggers the insertion of a userKey into the device's User table. + * + * Together, the device's DeviceId and UserKey act as an *irrevocable* authentication + * token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is + * required to authorize the API call. + * + * Changing Kobo password *does not* invalidate user keys! This is apparently a known + * issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13 + * (although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints + * will still grant access given the userkey.) + * + * **Official Kobo Store Api authorization** + * + * * For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is + * passed in the x-kobo-userkey header, and is sufficient to authorize the API call. + * * Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through + * an authorization header. To get a BearerToken, the device makes a POST request to the + * /v1/auth/device endpoint with the secret UserKey and the device's DeviceId. + * * The book download endpoint passes an auth token as a URL param instead of a header. + * + * **Komga implementation** + * + * To authenticate the user, an API key is added to the Komga URL when setting up the api_store + * setting on the device. + * Thus, every request from the device to the api_store will hit Komga with the + * API key in the url (e.g: https://mylibrary.com/kobo//v1/library/sync). + * + * In addition, once authenticated a session cookie is set on response, which will + * be sent back for the duration of the session to authorize subsequent API calls + * and avoid having to lookup the API key in database. + */ +@RestController +@RequestMapping(value = ["/kobo/{authToken}/"], produces = ["application/json; charset=utf-8"]) +class KoboController( + private val koboProxy: KoboProxy, + private val kepubConverter: KepubConverter, + private val syncPointLifecycle: SyncPointLifecycle, + private val syncPointRepository: SyncPointRepository, + private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + private val komgaProperties: KomgaProperties, + private val koboDtoRepository: KoboDtoRepository, + private val mapper: ObjectMapper, + private val commonBookController: CommonBookController, + private val bookLifecycle: BookLifecycle, + private val bookRepository: BookRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val readProgressRepository: ReadProgressRepository, + private val imageConverter: ImageConverter, + private val mediaRepository: MediaRepository, + private val contentRestrictionChecker: ContentRestrictionChecker, + private val objectMapper: ObjectMapper, +) { + private val cachedKepub = + Caffeine + .newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .removalListener { _, value, _ -> + if (value?.deleteIfExists() == true) logger.debug { "Deleted cached kepub: $value" } + }.build() + + @GetMapping("ping") + fun ping() = "pong" + + @GetMapping("v1/initialization") + fun initialization( + @PathVariable authToken: String, + ): ResponseEntity { + val resources = + try { + koboProxy.proxyCurrentRequest().body?.get("Resources") + } catch (e: Exception) { + if (e is ResponseStatusException && e.statusCode == HttpStatus.UNAUTHORIZED) throw e + logger.warn { "Failed to get response from Kobo /v1/initialization, fallback to noproxy" } + null + } ?: koboProxy.nativeKoboResources + + with(resources as ObjectNode) { + put("image_host", ServletUriComponentsBuilder.fromCurrentContextPath().toUriString()) + put( + "image_url_template", + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "false", "image.jpg") + .build() + .toUriString(), + ) + put( + "image_url_quality_template", + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "{Quality}", "{IsGreyscale}", "image.jpg") + .build() + .toUriString(), + ) + } + + return ResponseEntity + .ok() + .header("x-kobo-apitoken", "e30=") + .body(ResourcesDto(resources)) + } + + /** + * @return an [AuthDto] + */ + @PostMapping("v1/auth/device") + fun authDevice( + @RequestBody rawBody: ByteArray, + ): Any { + try { + return koboProxy.proxyCurrentRequest(rawBody) + } catch (_: Exception) { + logger.warn { "Failed to get response from Kobo /v1/auth/device, fallback to noproxy" } + } + + val body = objectMapper.readTree(rawBody) + + /* + * Komga does not use the /v1/auth/device API call for authentication/authorization. + * Return dummy data to keep the device happy. + */ + return AuthDto( + accessToken = RandomStringUtils.secure().nextAlphanumeric(24), + refreshToken = RandomStringUtils.secure().nextAlphanumeric(24), + trackingId = UUID.randomUUID().toString(), + userKey = body.get("UserKey")?.asText() ?: "", + ) + } + + // @RequestMapping(value = ["/v1/analytics/gettests"], method = [RequestMethod.GET, RequestMethod.POST]) + fun analyticsGetTests( + @RequestHeader(name = X_KOBO_USERKEY, required = false) userKey: String?, + ) = TestsDto( + result = "Success", + testKey = userKey ?: "", + ) + + /** + * @return an array of [SyncResultDto] + */ + @GetMapping("v1/library/sync") + fun syncLibrary( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable authToken: String, + ): ResponseEntity> { + val syncTokenReceived = komgaSyncTokenGenerator.fromRequestHeaders(getCurrentRequest()) ?: KomgaSyncToken() + + // find the ongoing sync point, else create one + val toSyncPoint = + getSyncPointVerified(syncTokenReceived.ongoingSyncPointId, principal.user.id) + ?: syncPointLifecycle.createSyncPoint(principal.user, principal.apiKey?.id, null) // for now we sync all libraries + + // find the last successful sync, if any + val fromSyncPoint = getSyncPointVerified(syncTokenReceived.lastSuccessfulSyncPointId, principal.user.id) + + logger.debug { "Library sync from SyncPoint $fromSyncPoint, to SyncPoint: $toSyncPoint" } + + var shouldContinueSync: Boolean + val downloadUriBuilder = getDownloadUrlBuilder(authToken) + val syncResultKomga: Collection = + if (fromSyncPoint != null) { + // find books added/changed/removed and map to DTO + var maxRemainingCount = komgaProperties.kobo.syncItemLimit + + val booksAdded = + syncPointLifecycle.takeBooksAdded(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = it.hasNext() + } + + val booksChanged = + if (booksAdded.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val booksRemoved = + if (booksChanged.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksRemoved(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val changedReadingState = + if (booksRemoved.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksReadProgressChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val readListsAdded = + if (changedReadingState.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeReadListsAdded(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val readListsChanged = + if (readListsAdded.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeReadListsChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val readListsRemoved = + if (readListsChanged.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeReadListsRemoved(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed, ${changedReadingState.numberOfElements} books with changed reading state, $readListsAdded readlists added, $readListsChanged readlists changed, $readListsRemoved removed" } + + val metadata = + koboDtoRepository + .findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }) + .associateBy { it.entitlementId } + .mapValues { it.value.withDownloadUrls(downloadUriBuilder) } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content + changedReadingState.content).map { it.bookId }, principal.user.id).associateBy { it.bookId } + val readListsBooks = syncPointRepository.findBookIdsByReadListIds(toSyncPoint.id, (readListsAdded.content + readListsChanged.content).map { it.readListId }).groupBy { it.readListId } + + buildList { + addAll( + booksAdded.content.map { + NewEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + }, + ) + addAll( + booksChanged.content.map { + NewEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + }, + ) + addAll( + booksChanged.content.map { + ChangedProductMetadataDto(metadata[it.bookId]!!) + }, + ) + addAll( + booksRemoved.content.map { + ChangedEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(true), + bookMetadata = getMetadataForRemovedBook(it.bookId), + ), + ) + }, + ) + addAll( + // changed books are also passed as changed reading state because Kobo does not process ChangedEntitlement even if it contains a ReadingState + (booksChanged.content + changedReadingState.content).mapNotNull { book -> + readProgress[book.bookId]?.let { + ChangedReadingStateDto( + WrappedReadingStateDto( + it.toDto(), + ), + ) + } + }, + ) + addAll( + readListsAdded.content.map { + NewTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) })) + }, + ) + addAll( + readListsChanged.content.map { + ChangedTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) })) + }, + ) + addAll( + readListsRemoved.content.map { + DeletedTagDto(it.toWrappedTagDto()) + }, + ) + } + } else { + // no starting point, sync everything + var maxRemainingCount = komgaProperties.kobo.syncItemLimit + + val books = + syncPointLifecycle.takeBooks(toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = it.hasNext() + } + + val readLists = + if (books.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeReadLists(toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + logger.debug { "Library sync: ${books.numberOfElements} books, ${readLists.numberOfElements} readlists" } + + val metadata = + koboDtoRepository + .findBookMetadataByIds(books.content.map { it.bookId }) + .associateBy { it.entitlementId } + .mapValues { it.value.withDownloadUrls(downloadUriBuilder) } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId(books.content.map { it.bookId }, principal.user.id).associateBy { it.bookId } + val readListsBooks = syncPointRepository.findBookIdsByReadListIds(toSyncPoint.id, readLists.content.map { it.readListId }).groupBy { it.readListId } + + buildList { + addAll( + books.content.map { + NewEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + }, + ) + addAll( + readLists.content.map { + NewTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) })) + }, + ) + } + } + + // merge Kobo store sync response, we only trigger this once all Komga updates have been processed (shouldContinueSync == false) + val (syncResultMerged, syncTokenMerged, shouldContinueSyncMerged) = + if (!shouldContinueSync && koboProxy.isEnabled()) { + try { + val koboStoreResponse = koboProxy.proxyCurrentRequest(includeSyncToken = true) + val syncResultsKobo = koboStoreResponse.body?.let { mapper.treeToValue>(it) } ?: emptyList() + val syncTokenKobo = koboStoreResponse.headers[X_KOBO_SYNCTOKEN]?.firstOrNull()?.let { komgaSyncTokenGenerator.fromBase64(it) } + val shouldContinueSyncKobo = koboStoreResponse.headers[X_KOBO_SYNC]?.firstOrNull()?.lowercase() == "continue" + + Triple(syncResultKomga + syncResultsKobo, syncTokenKobo ?: syncTokenReceived, shouldContinueSyncKobo) + } catch (e: Exception) { + logger.error(e) { "Kobo sync endpoint failure" } + Triple(syncResultKomga, syncTokenReceived, false) + } + } else { + Triple(syncResultKomga, syncTokenReceived, shouldContinueSync) + } + + // update synctoken to send back to Kobo device + val syncTokenUpdated = + if (shouldContinueSyncMerged) { + syncTokenMerged.copy(ongoingSyncPointId = toSyncPoint.id) + } else { + // cleanup old syncpoint if it exists + fromSyncPoint?.let { syncPointRepository.deleteOne(it.id) } + + syncTokenMerged.copy(ongoingSyncPointId = null, lastSuccessfulSyncPointId = toSyncPoint.id) + } + + return ResponseEntity + .ok() + .headers { + if (shouldContinueSyncMerged) it.set(X_KOBO_SYNC, "continue") + it.set(X_KOBO_SYNCTOKEN, komgaSyncTokenGenerator.toBase64(syncTokenUpdated)) + }.body(syncResultMerged) + } + + /** + * @return an array of [KoboBookMetadataDto] + */ + @GetMapping("/v1/library/{bookId}/metadata") + fun getBookMetadata( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable authToken: String, + @PathVariable bookId: String, + ): ResponseEntity<*> = + if (!bookRepository.existsById(bookId) && koboProxy.isEnabled()) { + koboProxy.proxyCurrentRequest() + } else { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + ResponseEntity.ok(koboDtoRepository.findBookMetadataByIds(listOf(bookId)).map { it.withDownloadUrls(getDownloadUrlBuilder(authToken)) }) + } + + /** + * @return an array of [ReadingStateDto] + */ + @GetMapping("/v1/library/{bookId}/state") + fun getState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity<*> { + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest() + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + val response = readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.toDto() ?: getEmptyReadProgressForBook(book) + return ResponseEntity.ok(listOf(response)) + } + + /** + * @return a [RequestResultDto] + */ + @PutMapping("/v1/library/{bookId}/state") + fun updateState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestBody rawBody: ByteArray, + @RequestHeader(name = X_KOBO_DEVICEID, required = false) koboDeviceId: String = "unknown", + ): ResponseEntity<*> { + val body = objectMapper.readValue(rawBody) + + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest(rawBody) + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + val koboUpdate = body.readingStates.firstOrNull() ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST) + if (koboUpdate.currentBookmark.location == null || koboUpdate.currentBookmark.contentSourceProgressPercent == null) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + + // convert the Kobo update request to an R2Progression + val r2Progression = + R2Progression( + modified = koboUpdate.lastModified, + device = + R2Device( + id = principal.apiKey?.id ?: "unknown", + name = principal.apiKey?.comment ?: "unknown", + ), + locator = + if (koboUpdate.statusInfo.status == StatusDto.FINISHED) { + // If the book is finished, Kobo sends the first resource instead of the last, so we can't trust what Kobo sent + val epubExtension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw IllegalArgumentException("Epub extension not found") + .also { logger.error { "Epub extension not found for book ${book.id}. Book should be re-analyzed." } } + epubExtension.positions.last() + } else { + R2Locator( + href = koboUpdate.currentBookmark.location.source, + // assume default, will be overwritten by the correct type when saved + type = "application/xhtml+xml", + koboSpan = + if (koboUpdate.currentBookmark.location.type + .contentEquals("kobospan", true) + ) + koboUpdate.currentBookmark.location.value + else + null, + locations = + R2Locator.Location( + progression = koboUpdate.currentBookmark.contentSourceProgressPercent / 100, + totalProgression = koboUpdate.currentBookmark.progressPercent?.div(100), + ), + ) + }, + ) + + val response = + try { + bookLifecycle.markProgression(book, principal.user, r2Progression) + + RequestResultDto( + requestResult = ResultDto.SUCCESS, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.SUCCESS.wrapped(), + statisticsResult = ResultDto.IGNORED.wrapped(), + statusInfoResult = ResultDto.SUCCESS.wrapped(), + ), + ), + ) + } catch (e: Exception) { + logger.error(e) { "Could not update progression" } + RequestResultDto( + requestResult = ResultDto.FAILURE, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.FAILURE.wrapped(), + statisticsResult = ResultDto.FAILURE.wrapped(), + statusInfoResult = ResultDto.FAILURE.wrapped(), + ), + ), + ) + } + + return ResponseEntity.ok(response) + } + + @GetMapping( + value = ["v1/books/{bookId}/file/epub"], + produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], + ) + @PreAuthorize("hasRole('FILE_DOWNLOAD')") + fun getBookFile( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestParam(required = false, name = "convert_kepub") convertToKepub: Boolean = false, + ): ResponseEntity { + if (convertToKepub) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + // check cache + val cacheKey = book.computeCacheKey() + var kepubPath = cachedKepub.getIfPresent(cacheKey)?.let { if (it.exists()) it else null } + + if (kepubPath == null) { + // convert + val converted = + kepubConverter.convertEpubToKepub(BookWithMedia(book, mediaRepository.findById(bookId))) + ?: throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Kepub conversion failed") + converted.toFile().deleteOnExit() + cachedKepub.put(cacheKey, converted) + kepubPath = converted + } else { + logger.debug { "Found kepub in cache" } + } + + try { + with(FileSystemResource(kepubPath)) { + if (!exists()) throw FileNotFoundException(path) + val stream = + StreamingResponseBody { os: OutputStream -> + this.inputStream.use { + IOUtils.copyLarge(it, os, ByteArray(8192)) + os.close() + } + } + return ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("attachment") + .filename("${book.path.nameWithoutExtension}.kepub.epub", StandardCharsets.UTF_8) + .build() + }, + ).contentType(getMediaTypeOrDefault(EPUB.type)) + .contentLength(contentLength()) + .body(stream) + } + } catch (ex: FileNotFoundException) { + logger.warn(ex) { "File not found: $kepubPath" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } else { + return commonBookController.getBookFileInternal(principal, bookId) + } + } + + private fun Book.computeCacheKey() = "$id-$fileLastModified" + + @GetMapping( + value = [ + "v1/books/{thumbnailId}/thumbnail/{width}/{height}/{isGreyScale}/image.jpg", + "v1/books/{thumbnailId}/thumbnail/{width}/{height}/{quality}/{isGreyScale}/image.jpg", + ], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookCover( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable thumbnailId: String, + @PathVariable width: String?, + @PathVariable height: String?, + @PathVariable quality: String?, + @PathVariable isGreyScale: String?, + ): ResponseEntity = + if (!thumbnailBookRepository.existsById(thumbnailId) && koboProxy.isEnabled()) { + ResponseEntity + .status(HttpStatus.TEMPORARY_REDIRECT) + .location(UriComponentsBuilder.fromUriString(koboProxy.imageHostUrl).buildAndExpand(thumbnailId, width, height).toUri()) + .build() + } else { + contentRestrictionChecker.checkContentRestrictionBookThumbnail(principal.user, thumbnailId) + + val poster = bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val posterBytes = + if (poster.mediaType != ImageType.JPEG.mediaType) + imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat) + else + poster.bytes + ResponseEntity.ok(posterBytes) + } + + @RequestMapping( + value = ["{*path}"], + method = [RequestMethod.GET, RequestMethod.PUT, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PATCH], + ) + fun catchAll( + @RequestBody body: ByteArray?, + ): ResponseEntity = + if (koboProxy.isEnabled()) + koboProxy.proxyCurrentRequest(body) + else + ResponseEntity.ok().body(mapper.createObjectNode()) + + private fun getDownloadUrlBuilder(token: String): UriBuilder = + ServletUriComponentsBuilder + .fromCurrentContextPath() + .pathSegment("kobo", token, "v1", "books", "{bookId}", "file", "epub") + .query("convert_kepub={convert}") + + private fun KoboBookMetadataDto.withDownloadUrls(downloadUriBuilder: UriBuilder) = + this.copy( + downloadUrls = + buildList { + val (format, convert) = + when { + // for fixed layout we always send EPUB3FL, so the Kobo can display in full screen + // no conversion to Kepub is necessary, as there is already 1 chapter per page, which is sufficient for progress tracking + isPrePaginated -> FormatDto.EPUB3FL to false + // provide Kepub if available, or convert if possible + isKepub || kepubConverter.isAvailable -> FormatDto.KEPUB to !isKepub + else -> FormatDto.EPUB3 to false + } + add( + DownloadUrlDto( + format = format, + size = fileSize, + url = downloadUriBuilder.build(entitlementId, convert).toURL().toString(), + ), + ) + }, + ) + + /** + * Retrieve a SyncPoint by ID, and verifies it belongs to the same userId + */ + private fun getSyncPointVerified( + syncPointId: String?, + userId: String, + ): SyncPoint? { + if (syncPointId != null) { + val syncPoint = syncPointRepository.findByIdOrNull(syncPointId) + // verify that the SyncPoint is owned by the user + if (syncPoint?.userId == userId) return syncPoint + } + return null + } + + private fun getMetadataForRemovedBook(bookId: String) = + KoboBookMetadataDto( + coverImageId = bookId, + crossRevisionId = bookId, + entitlementId = bookId, + revisionId = bookId, + workId = bookId, + title = bookId, + isKepub = false, + isPrePaginated = false, + fileSize = 0, + ) + + private fun getEmptyReadProgressForBook(book: Book): ReadingStateDto { + val createdDateUTC = book.createdDate.toUTCZoned() + return ReadingStateDto( + created = createdDateUTC, + lastModified = createdDateUTC, + priorityTimestamp = createdDateUTC, + entitlementId = book.id, + currentBookmark = BookmarkDto(createdDateUTC), + statistics = StatisticsDto(createdDateUTC), + statusInfo = + StatusInfoDto( + lastModified = createdDateUTC, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) + } + + private fun getEmptyReadProgressForBook( + bookId: String, + createdDate: ZonedDateTime, + ): ReadingStateDto = + ReadingStateDto( + created = createdDate, + lastModified = createdDate, + priorityTimestamp = createdDate, + entitlementId = bookId, + currentBookmark = BookmarkDto(createdDate), + statistics = StatisticsDto(createdDate), + statusInfo = + StatusInfoDto( + lastModified = createdDate, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt new file mode 100644 index 000000000..cf1df62d0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class AmountDto( + val currencyCode: String? = null, + val totalAmount: Int, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt new file mode 100644 index 000000000..01408cc8b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class AuthDto( + val accessToken: String, + val refreshToken: String, + val tokenType: String = "Bearer", + val trackingId: String, + val userKey: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt new file mode 100644 index 000000000..739080754 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class BookEntitlementContainerDto( + val bookEntitlement: BookEntitlementDto, + val bookMetadata: KoboBookMetadataDto, + val readingState: ReadingStateDto? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt new file mode 100644 index 000000000..21dd20129 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt @@ -0,0 +1,37 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.SyncPoint +import java.time.ZoneId +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class BookEntitlementDto( + val accessibility: String = "Full", + val activePeriod: PeriodDto, + val created: ZonedDateTime, + val crossRevisionId: String, + val id: String, + val isHiddenFromArchive: Boolean = false, + val isLocked: Boolean = false, + /** + * True if the book has been deleted or is not available + */ + val isRemoved: Boolean, + val lastModified: ZonedDateTime, + val originCategory: String = "Imported", + val revisionId: String, + val status: String = "Active", +) + +fun SyncPoint.Book.toBookEntitlementDto(isRemoved: Boolean) = + BookEntitlementDto( + activePeriod = ZonedDateTime.now(ZoneId.of("Z")).toPeriodDto(), + created = createdDate, + crossRevisionId = bookId, + revisionId = bookId, + id = bookId, + isRemoved = isRemoved, + lastModified = fileLastModified, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt new file mode 100644 index 000000000..ffba4abd6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BookmarkDto( + val lastModified: ZonedDateTime, + /** + * Total progression in the book. + * Between 0 and 100. + */ + val progressPercent: Float? = null, + /** + * Progression within the resource. + * Between 0 and 100. + */ + val contentSourceProgressPercent: Float? = null, + val location: LocationDto? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt new file mode 100644 index 000000000..0f51c4e7f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ContributorDto( + val name: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt new file mode 100644 index 000000000..55ef77fa5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class DownloadUrlDto( + val drmType: String = "None", + val format: FormatDto, + val size: Long, + val platform: String = "Generic", + val url: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt new file mode 100644 index 000000000..ace0d3413 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +enum class FormatDto { + EPUB3FL, + EPUB, + EPUB3, + KEPUB, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt new file mode 100644 index 000000000..bb99e62fd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt @@ -0,0 +1,50 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +const val DUMMY_ID = "00000000-0000-0000-0000-000000000001" + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class KoboBookMetadataDto( + val categories: List = listOf(DUMMY_ID), + val contributorRoles: List = emptyList(), + val contributors: List = emptyList(), + val coverImageId: String? = null, + val crossRevisionId: String, + val currentDisplayPrice: AmountDto = AmountDto("USD", 0), + val currentLoveDisplayPrice: AmountDto = AmountDto(totalAmount = 0), + val description: String? = null, + val downloadUrls: List = emptyList(), + val entitlementId: String, + val externalIds: List = emptyList(), + val genre: String = DUMMY_ID, + val isEligibleForKoboLove: Boolean = false, + val isInternetArchive: Boolean = false, + val isPreOrder: Boolean = false, + val isSocialEnabled: Boolean = true, + val isbn: String? = null, + /** + * 2-letter code + */ + val language: String = "en", + val phoneticPronunciations: Map = emptyMap(), + val publicationDate: ZonedDateTime? = null, + val publisher: PublisherDto? = null, + val revisionId: String, + val series: KoboSeriesDto? = null, + val slug: String? = null, + val subTitle: String? = null, + val title: String, + val workId: String, + @JsonIgnore + val isKepub: Boolean, + @JsonIgnore + val isPrePaginated: Boolean, + @JsonIgnore + val fileSize: Long, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt new file mode 100644 index 000000000..7a40344b9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class KoboSeriesDto( + val id: String, + val name: String, + val number: String, + val numberFloat: Float, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt new file mode 100644 index 000000000..e93967f68 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class LocationDto( + /** + * For type=KoboSpan values are in the form "kobo.x.y" + */ + val value: String? = null, + /** + * Typically "KoboSpan" + */ + val type: String? = "KoboSpan", + /** + * The epub HTML resource + */ + val source: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt new file mode 100644 index 000000000..0d546ddeb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class PeriodDto( + val from: ZonedDateTime, +) + +fun ZonedDateTime.toPeriodDto() = PeriodDto(this) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt new file mode 100644 index 000000000..dc73239c3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class PublisherDto( + val imprint: String = "", + val name: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt new file mode 100644 index 000000000..1a7ee2d00 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt @@ -0,0 +1,64 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.language.toUTCZoned +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateDto( + val created: ZonedDateTime? = null, + val currentBookmark: BookmarkDto, + val entitlementId: String, + val lastModified: ZonedDateTime, + /** + * From CW: apparently always equals to lastModified + */ + val priorityTimestamp: ZonedDateTime? = null, + val statistics: StatisticsDto, + val statusInfo: StatusInfoDto, +) + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedReadingStateDto( + val readingState: ReadingStateDto, +) + +fun ReadProgress.toDto() = + ReadingStateDto( + created = this.createdDate.toUTCZoned(), + lastModified = this.lastModifiedDate.toUTCZoned(), + priorityTimestamp = this.lastModifiedDate.toUTCZoned(), + entitlementId = this.bookId, + currentBookmark = + BookmarkDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + progressPercent = + this.locator + ?.locations + ?.totalProgression + ?.times(100), + contentSourceProgressPercent = + this.locator + ?.locations + ?.progression + ?.times(100), + location = this.locator?.let { LocationDto(source = it.href, value = it.koboSpan) }, + ), + statistics = + StatisticsDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + ), + statusInfo = + StatusInfoDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + status = + when { + this.completed -> StatusDto.FINISHED + !this.completed -> StatusDto.READING + else -> StatusDto.READY_TO_READ + }, + timesStartedReading = 1, + ), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt new file mode 100644 index 000000000..dd9150e78 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateStateUpdateDto( + val readingStates: Collection = emptyList(), +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt new file mode 100644 index 000000000..5c257d7ea --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class RequestResultDto( + val requestResult: ResultDto, + val updateResults: Collection, +) + +interface UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateUpdateResultDto( + val entitlementId: String, + val currentBookmarkResult: WrappedResultDto, + val statisticsResult: WrappedResultDto, + val statusInfoResult: WrappedResultDto, +) : UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedResultDto( + val result: ResultDto, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt new file mode 100644 index 000000000..7f716e8e4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ResourcesDto( + val resources: JsonNode, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt new file mode 100644 index 000000000..edfc3489c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +enum class ResultDto { + @JsonProperty("Success") + SUCCESS, + + // Not sure what Kobo accepts exactly, so I made up my own + @JsonProperty("Failure") + FAILURE, + + @JsonProperty("Ignored") + IGNORED, + ; + + fun wrapped() = WrappedResultDto(this) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt new file mode 100644 index 000000000..b8b972304 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class StatisticsDto( + val lastModified: ZonedDateTime, + val remainingTimeMinutes: Int? = null, + val spentReadingMinutes: Int? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt new file mode 100644 index 000000000..dabedde8c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +enum class StatusDto { + @JsonProperty("ReadyToRead") + READY_TO_READ, + + @JsonProperty("Finished") + FINISHED, + + @JsonProperty("Reading") + READING, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt new file mode 100644 index 000000000..1a784555a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class StatusInfoDto( + val lastModified: ZonedDateTime, + val status: StatusDto, + val timesStartedReading: Int? = null, + val lastTimeFinished: ZonedDateTime? = null, + val lastTimeStartedReading: ZonedDateTime? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt new file mode 100644 index 000000000..9db0661e3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +interface SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class NewEntitlementDto( + val newEntitlement: BookEntitlementContainerDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedEntitlementDto( + val changedEntitlement: BookEntitlementContainerDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedProductMetadataDto( + val changedProductMetadata: KoboBookMetadataDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class NewTagDto( + val newTag: WrappedTagDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedTagDto( + val changedTag: WrappedTagDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class DeletedTagDto( + val deletedTag: WrappedTagDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedReadingStateDto( + val changedReadingState: WrappedReadingStateDto, +) : SyncResultDto diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt new file mode 100644 index 000000000..ad3d6a3af --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.SyncPoint +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TagDto( + val id: String, + val created: ZonedDateTime, + val lastModified: ZonedDateTime, + val name: String, + val type: TagTypeDto, + val items: List? = null, +) + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedTagDto( + val tag: TagDto, +) + +fun SyncPoint.ReadList.toWrappedTagDto(items: List? = null) = + WrappedTagDto( + TagDto( + id = readListId, + created = createdDate, + lastModified = lastModifiedDate, + name = readListName, + type = TagTypeDto.USER_TAG, + items = items, + ), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt new file mode 100644 index 000000000..cb1124dae --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TagItemDto( + val revisionId: String, + val type: String = "ProductRevisionTagItem", +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt new file mode 100644 index 000000000..6c0391edc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +enum class TagTypeDto { + @JsonProperty("SystemTag") + SYSTEM_TAG, + + @JsonProperty("UserTag") + USER_TAG, +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt new file mode 100644 index 000000000..56863decc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TestsDto( + val result: String, + val testKey: String, + val tests: Map = emptyMap(), +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt new file mode 100644 index 000000000..cc410b0fe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.kobo.persistence + +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto + +interface KoboDtoRepository { + fun findBookMetadataByIds( + bookIds: Collection, + ): Collection +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt new file mode 100644 index 000000000..fcec13fd6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt @@ -0,0 +1,204 @@ +package org.gotson.komga.interfaces.api.kosync + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.kosync.dto.DocumentProgressDto +import org.gotson.komga.interfaces.api.kosync.dto.UserAuthenticationDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.time.ZonedDateTime + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("/koreader", produces = [MediaType.APPLICATION_JSON_VALUE, "application/vnd.koreader.v1+json"]) +class KoreaderSyncController( + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val readProgressRepository: ReadProgressRepository, + private val bookLifecycle: BookLifecycle, +) { + private val resourceRegex1 = Regex("""DocFragment\[(\d+)]""", RegexOption.IGNORE_CASE) + private val resourceRegex2 = Regex("""#_doc_fragment_(\d+)_""", RegexOption.IGNORE_CASE) + + @PostMapping("users/create") + fun registerUser(): ResponseEntity = throw ResponseStatusException(HttpStatus.FORBIDDEN, "User creation is disabled") + + @GetMapping("users/auth") + fun authorize() = UserAuthenticationDto() + + @GetMapping("syncs/progress/{bookHash}") + fun getProgress( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookHash: String, + ): DocumentProgressDto { + val books = bookRepository.findAllByHashKoreader(bookHash) + if (books.isEmpty()) { + logger.debug { "No book found with KOReader hash: $bookHash" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book not found") + } + if (books.size > 1) { + logger.debug { "No unique book found with KOReader hash: $bookHash. Found ${books.size} books with the same hash." } + throw ResponseStatusException(HttpStatus.CONFLICT, "More than 1 book found with the same hash") + } + + val book = books.first() + val media = mediaRepository.findById(book.id) + + val readProgress = + readProgressRepository.findByBookIdAndUserIdOrNull(book.id, principal.user.id) + ?: throw ResponseStatusException(HttpStatus.OK, "No progress found for this book") + + val progressPercentage = + readProgress + .locator + ?.locations + ?.totalProgression + ?: (readProgress.page.toFloat() / mediaRepository.findById(book.id).pageCount.toFloat()) + + val progress = + when (media.profile) { + MediaProfile.DIVINA, MediaProfile.PDF -> readProgress.page.toString() + MediaProfile.EPUB -> { + val extension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Epub extension not found") + .also { logger.error { "Epub extension not found for book ${book.id}. Book should be re-analyzed." } } + + // convert the href to its index for KOReader + val resourceIndex = + extension.positions + .groupBy { it.href } + .keys + .indexOf(readProgress.locator?.href) + + // return a progress string that points to the beginning of the resource + "/body/DocFragment[${resourceIndex + 1}].0" + } + + null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has no media profile") + } + + return DocumentProgressDto( + document = bookHash, + percentage = progressPercentage, + progress = progress, + device = readProgress.deviceName, + deviceId = readProgress.deviceId, + ) + } + + @PutMapping("syncs/progress") + fun updateProgress( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody koreaderProgress: DocumentProgressDto, + ) { + val books = bookRepository.findAllByHashKoreader(koreaderProgress.document) + if (books.isEmpty()) { + logger.debug { "No book found with KOReader hash: ${koreaderProgress.document}" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book not found") + } + if (books.size > 1) { + logger.debug { "No unique book found with KOReader hash: ${koreaderProgress.document}. Found ${books.size} books with the same hash." } + throw ResponseStatusException(HttpStatus.CONFLICT, "More than 1 book found with the same hash") + } + + val book = books.first() + val media = mediaRepository.findById(book.id) + + // convert the KOReader update request to an R2Progression + val locator = + when (media.profile) { + MediaProfile.DIVINA, MediaProfile.PDF -> + R2Locator( + href = "", + type = "", + locations = + R2Locator.Location( + position = koreaderProgress.progress.toInt(), + totalProgression = koreaderProgress.percentage, + ), + ) + + MediaProfile.EPUB -> { + val resourceIndex = + // we try to parse the progress using the 2 possible formats + resourceRegex1 + .find(koreaderProgress.progress) + ?.groups + // capturing group is at index 1, 0 is the full match + ?.get(1) + ?.value + ?.toIntOrNull() + // KOReader indexing starts at 1, not 0 + ?.minus(1) + ?: resourceRegex2 + .find(koreaderProgress.progress) + ?.groups + // capturing group is at index 1, 0 is the full match + ?.get(1) + ?.value + ?.toIntOrNull() + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not get Epub resource index from progress: ${koreaderProgress.progress}") + .also { logger.error { "Could not get Epub resource index from progress: ${koreaderProgress.progress}" } } + + val extension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Epub extension not found") + .also { logger.error { "Epub extension not found for book ${book.id}. Book should be re-analyzed." } } + + // get the href from the index provided by KOReader + val href = + extension.positions + .groupBy { it.href } + .keys + .elementAt(resourceIndex) + + R2Locator( + href = href, + // assume default, will be overwritten by the correct type when saved + type = "application/xhtml+xml", + locations = + R2Locator.Location( + progression = 0F, + totalProgression = koreaderProgress.percentage, + ), + ) + } + + null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has no media profile") + } + + val r2Progression = + R2Progression( + device = + R2Device( + id = koreaderProgress.deviceId, + name = koreaderProgress.device, + ), + modified = ZonedDateTime.now(), + locator = locator, + ) + + bookLifecycle.markProgression(book, principal.user, r2Progression) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt new file mode 100644 index 000000000..cb36fbc4b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt @@ -0,0 +1,28 @@ +package org.gotson.komga.interfaces.api.kosync.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class DocumentProgressDto( + /** + * The document hash, computed using the KOReader partial MD5 algorithm. + */ + val document: String, + /** + * Total progress percentage in the document, between 0 and 1. + */ + val percentage: Float, + /** + * Current progress. + * + * For PDF and CBZ, contains the current page number (starting from 1). + * + * For EPUB, it can contain a position in the following forms + * - '/body/DocFragment[10]/body/div/p[1]/text().0', where 10 correspond to the 1-based index of the resource in the manifest + * - '#_doc_fragment_44_ c37' (after clicking in the TOC), where 44 correspond to the 0-based index of the resource in the manifest + */ + val progress: String, + val device: String, + val deviceId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/UserAuthenticationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/UserAuthenticationDto.kt new file mode 100644 index 000000000..557edd8fe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/UserAuthenticationDto.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.api.kosync.dto + +data class UserAuthenticationDto( + val authorized: String = "OK", +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt new file mode 100644 index 000000000..64ad77a4a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.interfaces.api.opds + +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +class OpdsCommonController( + private val contentRestrictionChecker: ContentRestrictionChecker, + private val bookLifecycle: BookLifecycle, + private val imageConverter: ImageConverter, +) { + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = [ + "/opds/v1.2/books/{bookId}/thumbnail", + "/opds/v2/books/{bookId}/thumbnail", + ], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + val poster = bookLifecycle.getThumbnailBytesOriginal(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return if (poster.mediaType != ImageType.JPEG.mediaType) + imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat) + else + poster.bytes + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt new file mode 100644 index 000000000..dade54612 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt @@ -0,0 +1,820 @@ +package org.gotson.komga.interfaces.api.opds.v1 + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.OpdsLinkRel +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsAuthor +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsEntryAcquisition +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsEntryNavigation +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeed +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeedAcquisition +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeedNavigation +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLink +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkFeedNavigation +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkFileAcquisition +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkImage +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkImageThumbnail +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkPageStreaming +import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkSearch +import org.gotson.komga.interfaces.api.opds.v1.dto.OpenSearchDescription +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.language.toZonedDateTime +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriComponentsBuilder +import org.springframework.web.util.UriUtils +import java.net.URI +import java.nio.charset.StandardCharsets +import java.text.DecimalFormat +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.Optional + +private const val ROUTE_BASE = "/opds/v1.2/" +private const val ROUTE_CATALOG = "catalog" +private const val ROUTE_ON_DECK = "ondeck" +private const val ROUTE_KEEP_READING = "keep-reading" +private const val ROUTE_SERIES_ALL = "series" +private const val ROUTE_SERIES_LATEST = "series/latest" +private const val ROUTE_BOOKS_LATEST = "books/latest" +private const val ROUTE_LIBRARIES_ALL = "libraries" +private const val ROUTE_COLLECTIONS_ALL = "collections" +private const val ROUTE_READLISTS_ALL = "readlists" +private const val ROUTE_PUBLISHERS_ALL = "publishers" +private const val ROUTE_SEARCH = "search" + +private const val ID_ON_DECK = "ondeck" +private const val ID_KEEP_READING = "keepReading" +private const val ID_SERIES_ALL = "allSeries" +private const val ID_SERIES_LATEST = "latestSeries" +private const val ID_BOOKS_LATEST = "latestBooks" +private const val ID_LIBRARIES_ALL = "allLibraries" +private const val ID_COLLECTIONS_ALL = "allCollections" +private const val ID_READLISTS_ALL = "allReadLists" +private const val ID_PUBLISHERS_ALL = "allPublishers" + +@RestController +@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE]) +class OpdsController( + private val libraryRepository: LibraryRepository, + private val collectionRepository: SeriesCollectionRepository, + private val readListRepository: ReadListRepository, + private val seriesDtoRepository: SeriesDtoRepository, + private val bookDtoRepository: BookDtoRepository, + private val mediaRepository: MediaRepository, + private val referentialRepository: ReferentialRepository, + private val bookLifecycle: BookLifecycle, + private val commonBookController: CommonBookController, + private val komgaSettingsProvider: KomgaSettingsProvider, + private val contentRestrictionChecker: ContentRestrictionChecker, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, +) { + private val komgaAuthor = OpdsAuthor("Komga", URI("https://github.com/gotson/komga")) + + private val decimalFormat = DecimalFormat("0.#") + + private val opdsPseSupportedFormats = listOf("image/jpeg", "image/png", "image/gif") + + private fun linkStart() = OpdsLinkFeedNavigation(OpdsLinkRel.START, uriBuilder(ROUTE_CATALOG).toUriString()) + + private fun uriBuilder(path: String) = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v1.2").path(path) + + private fun linkPage( + uriBuilder: UriComponentsBuilder, + page: Page, + ): List = + listOfNotNull( + if (!page.isFirst) + OpdsLinkFeedNavigation( + OpdsLinkRel.PREVIOUS, + uriBuilder.cloneBuilder().queryParam("page", page.pageable.previousOrFirst().pageNumber).toUriString(), + ) + else + null, + if (!page.isLast) + OpdsLinkFeedNavigation( + OpdsLinkRel.NEXT, + uriBuilder.cloneBuilder().queryParam("page", page.pageable.next().pageNumber).toUriString(), + ) + else + null, + ) + + @GetMapping(ROUTE_CATALOG) + fun getCatalog(): OpdsFeed = + OpdsFeedNavigation( + id = "root", + title = "Komga OPDS catalog", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder(ROUTE_CATALOG).toUriString()), + linkStart(), + OpdsLinkSearch(uriBuilder(ROUTE_SEARCH).toUriString()), + OpdsLink(MEDIATYPE_OPDS_JSON_VALUE, "alternate", href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2", "catalog").toUriString()), + ), + entries = + listOf( + OpdsEntryNavigation( + title = "Keep Reading", + updated = ZonedDateTime.now(), + id = ID_KEEP_READING, + content = "Continue reading your in progress books", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_KEEP_READING).toUriString()), + ), + OpdsEntryNavigation( + title = "On Deck", + updated = ZonedDateTime.now(), + id = ID_ON_DECK, + content = "Browse what to read next", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_ON_DECK).toUriString()), + ), + OpdsEntryNavigation( + title = "All series", + updated = ZonedDateTime.now(), + id = ID_SERIES_ALL, + content = "Browse by series", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_ALL).toUriString()), + ), + OpdsEntryNavigation( + title = "Latest series", + updated = ZonedDateTime.now(), + id = ID_SERIES_LATEST, + content = "Browse latest series", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_LATEST).toUriString()), + ), + OpdsEntryNavigation( + title = "Latest books", + updated = ZonedDateTime.now(), + id = ID_BOOKS_LATEST, + content = "Browse latest books", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_BOOKS_LATEST).toUriString()), + ), + OpdsEntryNavigation( + title = "All libraries", + updated = ZonedDateTime.now(), + id = ID_LIBRARIES_ALL, + content = "Browse by library", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_LIBRARIES_ALL).toUriString()), + ), + OpdsEntryNavigation( + title = "All collections", + updated = ZonedDateTime.now(), + id = ID_COLLECTIONS_ALL, + content = "Browse by collection", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_COLLECTIONS_ALL).toUriString()), + ), + OpdsEntryNavigation( + title = "All read lists", + updated = ZonedDateTime.now(), + id = ID_READLISTS_ALL, + content = "Browse by read lists", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_READLISTS_ALL).toUriString()), + ), + OpdsEntryNavigation( + title = "All publishers", + updated = ZonedDateTime.now(), + id = ID_PUBLISHERS_ALL, + content = "Browse by publishers", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_PUBLISHERS_ALL).toUriString()), + ), + ), + ) + + @GetMapping(ROUTE_SEARCH) + fun getSearch(): OpenSearchDescription = + OpenSearchDescription( + shortName = "Search", + description = "Search for series", + url = OpenSearchDescription.OpenSearchUrl(uriBuilder(ROUTE_SERIES_ALL).toUriString() + "?search={searchTerms}"), + ) + + @GetMapping(ROUTE_ON_DECK) + fun getOnDeck( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val bookPage = + bookDtoRepository.findAllOnDeck( + principal.user.id, + principal.user.getAuthorizedLibraryIds(null), + page, + principal.user.restrictions, + ) + + val builder = uriBuilder(ROUTE_ON_DECK) + + return OpdsFeedAcquisition( + id = ID_ON_DECK, + title = "On Deck", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, builder.toUriString()), + linkStart(), + *linkPage(builder, bookPage).toTypedArray(), + ), + entries = bookPage.content.getEntriesWithSeriesTitle(), + ) + } + + @GetMapping(ROUTE_KEEP_READING) + fun getKeepReading( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + + val bookPage = + bookDtoRepository.findAll( + bookSearch, + SearchContext(principal.user), + pageable, + ) + + val builder = uriBuilder(ROUTE_KEEP_READING) + + return OpdsFeedAcquisition( + id = ID_KEEP_READING, + title = "Keep Reading", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, builder.toUriString()), + linkStart(), + *linkPage(builder, bookPage).toTypedArray(), + ), + entries = bookPage.content.getEntriesWithSeriesTitle(), + ) + } + + @GetMapping(ROUTE_SERIES_ALL) + fun getAllSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @RequestParam(name = "publisher", required = false) publishers: List?, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val sort = + if (!searchTerm.isNullOrBlank()) + Sort.by("relevance") + else + Sort.by(Sort.Order.asc("metadata.titleSort")) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort) + + val seriesSearch = + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + searchTerm?.let { add(SearchCondition.Title(SearchOperator.Contains(it))) } + publishers?.let { add(SearchCondition.AnyOfSeries(it.map { publisher -> SearchCondition.Publisher(SearchOperator.Is(publisher)) })) } + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + }, + ), + ) + + val seriesPage = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable) + + val builder = + uriBuilder(ROUTE_SERIES_ALL) + .queryParamIfPresent("search", Optional.ofNullable(searchTerm)) + .queryParamIfPresent("publisher", Optional.ofNullable(publishers)) + + return OpdsFeedNavigation( + id = ID_SERIES_ALL, + title = if (!searchTerm.isNullOrBlank()) "Series search for: $searchTerm" else "All series", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, builder.toUriString()), + linkStart(), + *linkPage(builder, seriesPage).toTypedArray(), + ), + entries = seriesPage.content.map { it.toOpdsEntry() }, + ) + } + + @GetMapping(ROUTE_SERIES_LATEST) + fun getLatestSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified"))) + + val seriesSearch = SeriesSearch(SearchCondition.Deleted(SearchOperator.IsFalse)) + + val seriesPage = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable) + + val uriBuilder = uriBuilder(ROUTE_SERIES_LATEST) + + return OpdsFeedNavigation( + id = ID_SERIES_LATEST, + title = "Latest series", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, seriesPage).toTypedArray(), + ), + entries = seriesPage.content.map { it.toOpdsEntry() }, + ) + } + + @GetMapping(ROUTE_BOOKS_LATEST) + fun getLatestBooks( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))) + + val bookPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable) + + val uriBuilder = uriBuilder(ROUTE_BOOKS_LATEST) + + return OpdsFeedAcquisition( + id = ID_BOOKS_LATEST, + title = "Latest books", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, bookPage).toTypedArray(), + ), + entries = bookPage.content.getEntriesWithSeriesTitle(), + ) + } + + @GetMapping(ROUTE_LIBRARIES_ALL) + fun getLibraries( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): OpdsFeed { + val libraries = + if (principal.user.canAccessAllLibraries()) { + libraryRepository.findAll() + } else { + libraryRepository.findAllByIds(principal.user.sharedLibrariesIds) + } + return OpdsFeedNavigation( + id = ID_LIBRARIES_ALL, + title = "All libraries", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder(ROUTE_LIBRARIES_ALL).toUriString()), + linkStart(), + ), + entries = libraries.map { it.toOpdsEntry() }, + ) + } + + @GetMapping(ROUTE_COLLECTIONS_ALL) + fun getCollections( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) + val collections = collectionRepository.findAll(principal.user.getAuthorizedLibraryIds(null), principal.user.getAuthorizedLibraryIds(null), pageable = pageable) + + val uriBuilder = uriBuilder(ROUTE_COLLECTIONS_ALL) + + return OpdsFeedNavigation( + id = ID_COLLECTIONS_ALL, + title = "All collections", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, collections).toTypedArray(), + ), + entries = collections.content.map { it.toOpdsEntry() }, + ) + } + + @GetMapping(ROUTE_READLISTS_ALL) + fun getReadLists( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) + val readLists = readListRepository.findAll(principal.user.getAuthorizedLibraryIds(null), principal.user.getAuthorizedLibraryIds(null), pageable = pageable) + + val uriBuilder = uriBuilder(ROUTE_READLISTS_ALL) + + return OpdsFeedNavigation( + id = ID_READLISTS_ALL, + title = "All read lists", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, readLists).toTypedArray(), + ), + entries = readLists.content.map { it.toOpdsEntry() }, + ) + } + + @GetMapping(ROUTE_PUBLISHERS_ALL) + fun getPublishers( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed { + val publishers = referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null), page) + + val uriBuilder = uriBuilder(ROUTE_PUBLISHERS_ALL) + + return OpdsFeedNavigation( + id = ID_PUBLISHERS_ALL, + title = "All publishers", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, publishers).toTypedArray(), + ), + entries = + publishers.content.map { publisher -> + OpdsEntryNavigation( + title = publisher, + updated = ZonedDateTime.now(), + id = "publisher:${UriUtils.encodeQueryParam(publisher, StandardCharsets.UTF_8)}", + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_ALL).queryParam("publisher", publisher).toUriString()), + ) + }, + ) + } + + @GetMapping("series/{id}") + fun getOneSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed = + seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series -> + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, series) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.SeriesId(SearchOperator.Is(series.id)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort"))) + + val entries = + bookDtoRepository + .findAll(bookSearch, SearchContext(principal.user), pageable) + .map { it.toOpdsEntry(mediaRepository.findById(it.id)) } + + val uriBuilder = uriBuilder("series/$id") + + OpdsFeedAcquisition( + id = series.id, + title = series.metadata.title, + updated = series.lastModified.toZonedDateTime(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + entries = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping("libraries/{id}") + fun getOneLibrary( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed = + libraryRepository.findByIdOrNull(id)?.let { library -> + if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + + val seriesSearch = + SeriesSearch( + SearchCondition.AllOfSeries( + SearchCondition.LibraryId(SearchOperator.Is(library.id)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort"))) + + val entries = + seriesDtoRepository + .findAll(seriesSearch, SearchContext(principal.user), pageable) + .map { it.toOpdsEntry() } + + val uriBuilder = uriBuilder("libraries/$id") + + OpdsFeedNavigation( + id = library.id, + title = library.name, + updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + entries = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping("collections/{id}") + fun getOneCollection( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed = + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + val sort = + if (collection.ordered) + Sort.by(Sort.Order.asc("collection.number")) + else + Sort.by(Sort.Order.asc("metadata.titleSort")) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort) + + val seriesSearch = + SeriesSearch( + SearchCondition.AllOfSeries( + SearchCondition.CollectionId(SearchOperator.Is(collection.id)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + + val entries = + seriesDtoRepository + .findAll(seriesSearch, SearchContext(principal.user), pageable) + .map { it.toOpdsEntry() } + + val uriBuilder = uriBuilder("collections/$id") + + OpdsFeedNavigation( + id = collection.id, + title = collection.name, + updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + entries = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping("readlists/{id}") + fun getOneReadList( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): OpdsFeed = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + val sort = + if (readList.ordered) + Sort.by(Sort.Order.asc("readList.number")) + else + Sort.by(Sort.Order.asc("metadata.releaseDate")) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.ReadListId(SearchOperator.Is(readList.id)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + val booksPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable) + + val entries = + booksPage.map { bookDto -> + bookDto.toOpdsEntry(mediaRepository.findById(bookDto.id)) { "${it.seriesTitle} ${it.metadata.number}: " } + } + + val uriBuilder = uriBuilder("readlists/$id") + + OpdsFeedAcquisition( + id = readList.id, + title = readList.name, + updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + author = komgaAuthor, + links = + listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()), + linkStart(), + *linkPage(uriBuilder, booksPage).toTypedArray(), + ), + entries = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["books/{bookId}/thumbnail/small"], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookThumbnailSmall( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else komgaSettingsProvider.thumbnailSize.maxEdge)?.bytes + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) + @GetMapping("books/{bookId}/pages/{pageNumber}", produces = ["image/png", "image/gif", "image/jpeg"]) + @PreAuthorize("hasRole('PAGE_STREAMING')") + fun getBookPageOpds( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + @Parameter( + description = "Convert the image to the provided format.", + schema = Schema(allowableValues = ["jpeg", "png"]), + ) + @RequestParam(value = "convert", required = false) + convertTo: String?, + ): ResponseEntity = commonBookController.getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null) + + private fun SeriesDto.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation { + val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" + return OpdsEntryNavigation( + title = pre + metadata.title, + updated = lastModified.atZone(ZoneId.of("Z")) ?: ZonedDateTime.now(), + id = id, + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("series/$id").toUriString()), + ) + } + + private fun BookDto.toOpdsEntry( + media: Media, + prepend: (BookDto) -> String = { "" }, + ): OpdsEntryAcquisition { + val mediaTypes = + when (media.profile) { + MediaProfile.DIVINA -> media.pages.map { it.mediaType }.distinct() + MediaProfile.PDF -> listOf(pdfImageType.mediaType) + MediaProfile.EPUB -> if (media.epubDivinaCompatible) media.pages.map { it.mediaType }.distinct() else emptyList() + null -> emptyList() + } + + val opdsLinkPageStreaming = + if (mediaTypes.isEmpty()) { + null + } else if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) { + OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate) + } else { + OpdsLinkPageStreaming("image/jpeg", uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}?convert=jpeg", media.pageCount, readProgress?.page, readProgress?.readDate) + } + + val thumbnailMediaType = + when (media.profile) { + MediaProfile.PDF -> pdfImageType.mediaType + else -> "image/jpeg" + } + + return OpdsEntryAcquisition( + title = "${prepend(this)}${metadata.title}", + updated = lastModified.toZonedDateTime(), + id = id, + content = + buildString { + append("${FilenameUtils.getExtension(url).lowercase()} - $size") + if (metadata.summary.isNotBlank()) append("\n\n${metadata.summary}") + }, + authors = metadata.authors.map { OpdsAuthor(it.name) }, + links = + listOfNotNull( + OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail/small").toUriString()), + OpdsLinkImage(thumbnailMediaType, uriBuilder("books/$id/thumbnail").toUriString()), + OpdsLinkFileAcquisition(media.mediaType, uriBuilder("books/$id/file/${sanitize(FilenameUtils.getName(url))}").toUriString()), + opdsLinkPageStreaming, + ), + ) + } + + private fun Library.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( + title = name, + updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id, + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("libraries/$id").toUriString()), + ) + + private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( + title = name, + updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id, + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("collections/$id").toUriString()), + ) + + private fun ReadList.toOpdsEntry(): OpdsEntryNavigation = + OpdsEntryNavigation( + title = name, + updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id, + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("readlists/$id").toUriString()), + ) + + private fun List.getEntriesWithSeriesTitle(): List = + map { bookDto -> + bookDto.toOpdsEntry(mediaRepository.findById(bookDto.id)) { "${it.seriesTitle} ${it.metadata.number}: " } + } + + private fun sanitize(fileName: String): String = fileName.replace(";", "") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt new file mode 100644 index 000000000..3c46d2ee8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import java.net.URI + +data class OpdsAuthor( + @JacksonXmlProperty(namespace = ATOM) + val name: String, + @JsonInclude(JsonInclude.Include.NON_NULL) + @JacksonXmlProperty(namespace = ATOM) + val uri: URI? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt new file mode 100644 index 000000000..2c5dfe123 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import java.time.ZonedDateTime + +@JsonInclude(JsonInclude.Include.NON_NULL) +abstract class OpdsEntry( + @get:JacksonXmlProperty(namespace = ATOM) + val title: String, + @get:JacksonXmlProperty(namespace = ATOM) + val updated: ZonedDateTime, + @get:JacksonXmlProperty(namespace = ATOM) + val id: String, + content: String, +) { + @get:JacksonXmlProperty(namespace = ATOM) + val content: String = content.replace("\n", "
") +} + +class OpdsEntryNavigation( + title: String, + updated: ZonedDateTime, + id: String, + content: String, + @JacksonXmlProperty(namespace = ATOM) + val link: OpdsLink, +) : OpdsEntry(title, updated, id, content) + +class OpdsEntryAcquisition( + title: String, + updated: ZonedDateTime, + id: String, + content: String, + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "author", namespace = ATOM) + val authors: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "link", namespace = ATOM) + val links: List, +) : OpdsEntry(title, updated, id, content) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt new file mode 100644 index 000000000..be86b6b02 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import java.time.ZonedDateTime + +@JacksonXmlRootElement(localName = "feed", namespace = ATOM) +abstract class OpdsFeed( + @JacksonXmlProperty(namespace = ATOM) + val id: String, + @JacksonXmlProperty(namespace = ATOM) + val title: String, + @JacksonXmlProperty(namespace = ATOM) + val updated: ZonedDateTime, + @JacksonXmlProperty(namespace = ATOM) + val author: OpdsAuthor, + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "link", namespace = ATOM) + val links: List, + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "entry", namespace = ATOM) + val entries: List, +) + +@JsonSerialize(`as` = OpdsFeed::class) +class OpdsFeedNavigation( + id: String, + title: String, + updated: ZonedDateTime, + author: OpdsAuthor, + links: List, + entries: List, +) : OpdsFeed(id, title, updated, author, links, entries) + +@JsonSerialize(`as` = OpdsFeed::class) +class OpdsFeedAcquisition( + id: String, + title: String, + updated: ZonedDateTime, + author: OpdsAuthor, + links: List, + entries: List, +) : OpdsFeed(id, title, updated, author, links, entries) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt new file mode 100644 index 000000000..d8f9cfdb8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt @@ -0,0 +1,90 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import java.time.LocalDateTime + +open class OpdsLink( + @get:JacksonXmlProperty(isAttribute = true) + val type: String, + @get:JacksonXmlProperty(isAttribute = true) + val rel: String, + @get:JacksonXmlProperty(isAttribute = true) + val href: String, +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFeedNavigation( + rel: String, + href: String, +) : OpdsLink( + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + rel = rel, + href = href, + ) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFeedAcquisition( + rel: String, + href: String, +) : OpdsLink( + type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + rel = rel, + href = href, + ) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkImage( + mediaType: String, + href: String, +) : OpdsLink( + type = mediaType, + rel = "http://opds-spec.org/image", + href = href, + ) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkImageThumbnail( + mediaType: String, + href: String, +) : OpdsLink( + type = mediaType, + rel = "http://opds-spec.org/image/thumbnail", + href = href, + ) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFileAcquisition( + mediaType: String?, + href: String, +) : OpdsLink( + type = mediaType ?: "application/octet-stream", + rel = "http://opds-spec.org/acquisition", + href = href, + ) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkSearch( + href: String, +) : OpdsLink( + type = "application/opensearchdescription+xml", + rel = "search", + href = href, + ) + +class OpdsLinkPageStreaming( + mediaType: String, + href: String, + @get:JacksonXmlProperty(isAttribute = true, namespace = OPDS_PSE) + val count: Int, + @get:JacksonXmlProperty(isAttribute = true, namespace = OPDS_PSE) + val lastRead: Int?, + @get:JacksonXmlProperty(isAttribute = true, namespace = OPDS_PSE) + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastReadDate: LocalDateTime?, +) : OpdsLink( + type = mediaType, + rel = "http://vaemendis.net/opds-pse/stream", + href = href, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt new file mode 100644 index 000000000..84f2f8518 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt @@ -0,0 +1,29 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import jakarta.validation.constraints.Size + +@JacksonXmlRootElement(localName = "OpenSearchDescription", namespace = OPENSEARCH) +class OpenSearchDescription( + @JacksonXmlProperty(localName = "ShortName", namespace = OPENSEARCH) + @Size(min = 1, max = 16) + val shortName: String, + @JacksonXmlProperty(localName = "Description", namespace = OPENSEARCH) + @Size(min = 1, max = 1024) + val description: String, + @JacksonXmlProperty(localName = "InputEncoding", namespace = OPENSEARCH) + val inputEncoding: String = "UTF-8", + @JacksonXmlProperty(localName = "OutputEncoding", namespace = OPENSEARCH) + val outputEncoding: String = "UTF-8", + @JacksonXmlProperty(localName = "Url", namespace = OPENSEARCH) + val url: OpenSearchUrl, +) { + class OpenSearchUrl( + @JacksonXmlProperty(isAttribute = true) + val template: String, + ) { + @JacksonXmlProperty(isAttribute = true) + val type = "application/atom+xml;profile=opds-catalog;kind=acquisition" + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt new file mode 100644 index 000000000..853bb5717 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.opds.v1.dto + +const val ATOM = "http://www.w3.org/2005/Atom" +const val OPDS_PSE = "http://vaemendis.net/opds-pse/ns" +const val OPENSEARCH = "http://a9.com/-/spec/opensearch/1.1/" + +val prefixToNamespace = + mapOf( + "pse" to OPDS_PSE, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt new file mode 100644 index 000000000..78c56c5ba --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt @@ -0,0 +1,934 @@ +package org.gotson.komga.interfaces.api.opds.v2 + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.gotson.komga.interfaces.api.OpdsGenerator +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.OpdsLinkRel +import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.opds.v2.dto.FacetDto +import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto +import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto +import org.gotson.komga.interfaces.api.opds.v2.dto.FeedMetadataDto +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.language.toZonedDateTime +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriComponentsBuilder +import java.time.ZoneId +import java.time.ZonedDateTime + +private const val ROUTE_CATALOG = "catalog" +const val ROUTE_AUTH = "auth" + +private const val RECOMMENDED_ITEMS_NUMBER = 5 + +@RestController +@RequestMapping(value = ["/opds/v2/"], produces = [MEDIATYPE_OPDS_JSON_VALUE]) +class Opds2Controller( + private val libraryRepository: LibraryRepository, + private val collectionRepository: SeriesCollectionRepository, + private val readListRepository: ReadListRepository, + private val seriesDtoRepository: SeriesDtoRepository, + private val bookDtoRepository: BookDtoRepository, + private val referentialRepository: ReferentialRepository, + private val commonBookController: CommonBookController, + private val opdsGenerator: OpdsGenerator, + private val contentRestrictionChecker: ContentRestrictionChecker, +) { + private fun linkStart() = + WPLinkDto( + title = "Home", + rel = OpdsLinkRel.START, + type = MEDIATYPE_OPDS_JSON_VALUE, + href = uriBuilder(ROUTE_CATALOG).toUriString(), + ) + + private fun linkSearch() = + WPLinkDto( + title = "Search", + rel = OpdsLinkRel.SEARCH, + type = MEDIATYPE_OPDS_JSON_VALUE, + href = uriBuilder("search").toUriString() + "{?query}", + templated = true, + ) + + private fun uriBuilder(path: String) = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(path) + + private fun linkPage( + uriBuilder: UriComponentsBuilder, + page: Page<*>, + ): List = + listOfNotNull( + if (!page.isFirst) + WPLinkDto( + rel = OpdsLinkRel.PREVIOUS, + href = uriBuilder.cloneBuilder().queryParam("page", page.pageable.previousOrFirst().pageNumber).toUriString(), + ) + else + null, + if (!page.isLast) + WPLinkDto( + rel = OpdsLinkRel.NEXT, + href = uriBuilder.cloneBuilder().queryParam("page", page.pageable.next().pageNumber).toUriString(), + ) + else + null, + ) + + private fun linkSelf( + path: String, + type: String? = null, + ) = linkSelf(uriBuilder(path), type) + + private fun linkSelf( + uriBuilder: UriComponentsBuilder, + type: String? = null, + ) = WPLinkDto( + rel = OpdsLinkRel.SELF, + href = uriBuilder.toUriString(), + type = type, + ) + + private fun getLibrariesFeedGroup( + principal: KomgaPrincipal, + ): FeedGroupDto { + val libraries = + if (principal.user.canAccessAllLibraries()) { + libraryRepository.findAll() + } else { + libraryRepository.findAllByIds(principal.user.sharedLibrariesIds) + } + return FeedGroupDto( + metadata = + FeedMetadataDto( + title = "Libraries", + ), + links = listOf(linkSelf("libraries")), + navigation = libraries.map { it.toWPLinkDto() }, + ) + } + + private fun getLibraryNavigation( + user: KomgaUser, + libraryId: String?, + ): List { + val uriBuilder = uriBuilder("libraries${if (libraryId != null) "/$libraryId" else ""}") + + val collections = collectionRepository.findAll(libraryId?.let { listOf(it) }, restrictions = user.restrictions, pageable = Pageable.ofSize(1)) + val readLists = readListRepository.findAll(libraryId?.let { listOf(it) }, restrictions = user.restrictions, pageable = Pageable.ofSize(1)) + + return listOfNotNull( + WPLinkDto("Recommended", OpdsLinkRel.SUBSECTION, href = uriBuilder.toUriString(), type = MEDIATYPE_OPDS_JSON_VALUE), + WPLinkDto("Browse", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("browse").toUriString(), type = MEDIATYPE_OPDS_JSON_VALUE), + if (collections.isEmpty) + null + else + WPLinkDto("Collections", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("collections").toUriString(), type = MEDIATYPE_OPDS_JSON_VALUE), + if (readLists.isEmpty) + null + else + WPLinkDto("Read lists", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("readlists").toUriString(), type = MEDIATYPE_OPDS_JSON_VALUE), + ) + } + + @GetMapping(value = [ROUTE_CATALOG, "libraries", "libraries/{id}"]) + fun getLibrariesRecommended( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + ): FeedDto { + val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal) + + val keepReading = + bookDtoRepository + .findAll( + BookSearch( + SearchCondition.AllOfBook( + buildList { + if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id))) + add(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))) + add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + }, + ), + ), + SearchContext(principal.user), + PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))), + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val onDeck = + bookDtoRepository + .findAllOnDeck( + principal.user.id, + authorizedLibraryIds, + Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER), + principal.user.restrictions, + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val latestBooks = + bookDtoRepository + .findAll( + BookSearch( + SearchCondition.AllOfBook( + buildList { + if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id))) + add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + }, + ), + ), + SearchContext(principal.user), + PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))), + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val latestSeries = + seriesDtoRepository + .findAll( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + add(SearchCondition.OneShot(SearchOperator.IsFalse)) + }, + ), + ), + SearchContext(principal.user), + PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("lastModified"))), + ).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Recommended", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + ), + navigation = getLibraryNavigation(principal.user, libraryId), + groups = + listOfNotNull( + if (library == null) getLibrariesFeedGroup(principal) else null, + if (!keepReading.isEmpty) + FeedGroupDto( + FeedMetadataDto("Keep Reading", page = keepReading), + links = listOf(WPLinkDto("Keep Reading", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("keep-reading").toUriString(), MEDIATYPE_OPDS_JSON_VALUE)), + publications = keepReading.content, + ) + else + null, + if (!onDeck.isEmpty) + FeedGroupDto( + FeedMetadataDto("On Deck", page = onDeck), + links = listOf(WPLinkDto("On Deck", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("on-deck").toUriString(), MEDIATYPE_OPDS_JSON_VALUE)), + publications = onDeck.content, + ) + else + null, + if (!latestBooks.isEmpty) + FeedGroupDto( + FeedMetadataDto("Latest Books", page = latestBooks), + links = listOf(WPLinkDto("Latest Books", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("books", "latest").toUriString(), MEDIATYPE_OPDS_JSON_VALUE)), + publications = latestBooks.content, + ) + else + null, + if (!latestSeries.isEmpty) + FeedGroupDto( + FeedMetadataDto("Latest Series", page = latestSeries), + links = listOf(WPLinkDto("Latest Series", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("series", "latest").toUriString(), MEDIATYPE_OPDS_JSON_VALUE)), + navigation = latestSeries.content, + ) + else + null, + ), + ) + } + + @GetMapping(value = ["libraries/keep-reading", "libraries/{id}/keep-reading"]) + fun getKeepReading( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, _) = checkLibraryAccess(libraryId, principal) + + val entries = + bookDtoRepository + .findAll( + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ), + SearchContext(principal.user), + PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))), + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Keep Reading", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + publications = entries.content, + ) + } + + @GetMapping(value = ["libraries/on-deck", "libraries/{id}/on-deck"]) + fun getOnDeck( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal) + + val entries = + bookDtoRepository + .findAllOnDeck( + principal.user.id, + authorizedLibraryIds, + page, + principal.user.restrictions, + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - On Deck", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + publications = entries.content, + ) + } + + @GetMapping(value = ["libraries/books/latest", "libraries/{id}/books/latest"]) + fun getLatestBooks( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, _) = checkLibraryAccess(libraryId, principal) + + val entries = + bookDtoRepository + .findAll( + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ), + SearchContext(principal.user), + PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))), + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Latest Books", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + publications = entries.content, + ) + } + + @GetMapping(value = ["libraries/series/latest", "libraries/{id}/series/latest"]) + fun getLatestSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, _) = checkLibraryAccess(libraryId, principal) + + val entries = + seriesDtoRepository + .findAll( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + add(SearchCondition.OneShot(SearchOperator.IsFalse)) + }, + ), + ), + SearchContext(principal.user), + PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified"))), + ).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/series/latest") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Latest Series", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + navigation = entries.content, + ) + } + + @GetMapping(value = ["libraries/browse", "libraries/{id}/browse"]) + fun getLibrariesBrowse( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @RequestParam(name = "publisher", required = false) publishers: List? = null, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal) + + val seriesSearch = + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id))) + if (!publishers.isNullOrEmpty()) { + add(SearchCondition.AllOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) })) + } + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + }, + ), + ) + + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort"))) + + val entries = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/browse") + + val publisherLinks = + referentialRepository.findAllPublishers(authorizedLibraryIds).map { + WPLinkDto( + title = it, + href = uriBuilder.cloneBuilder().queryParam("publisher", it).toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ) + } + + return FeedDto( + metadata = + FeedMetadataDto( + title = library?.name ?: "All libraries", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + navigation = getLibraryNavigation(principal.user, libraryId), + groups = + listOfNotNull( + FeedGroupDto(FeedMetadataDto("Series"), navigation = entries.content), + if (publisherLinks.isNotEmpty()) FeedGroupDto(FeedMetadataDto("Publisher"), navigation = publisherLinks) else null, + ), + ) + } + + @GetMapping(value = ["libraries/collections", "libraries/{id}/collections"]) + fun getLibrariesCollections( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal) + + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) + val entries = + collectionRepository + .findAll( + authorizedLibraryIds, + authorizedLibraryIds, + pageable = pageable, + ).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/collections") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Collections", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + navigation = getLibraryNavigation(principal.user, libraryId), + groups = + listOfNotNull( + FeedGroupDto(FeedMetadataDto("Collections"), navigation = entries.content), + ), + ) + } + + @GetMapping("collections/{id}") + fun getOneCollection( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): FeedDto = + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + val sort = + if (collection.ordered) + Sort.by(Sort.Order.asc("collection.number")) + else + Sort.by(Sort.Order.asc("metadata.titleSort")) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort) + + val seriesSearch = + SeriesSearch( + SearchCondition.AllOfSeries( + SearchCondition.CollectionId(SearchOperator.Is(collection.id)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + + val entries = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("collections/$id") + + FeedDto( + FeedMetadataDto( + title = collection.name, + modified = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + navigation = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping(value = ["libraries/readlists", "libraries/{id}/readlists"]) + fun getLibrariesReadLists( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id", required = false) libraryId: String?, + @Parameter(hidden = true) page: Pageable, + ): FeedDto { + val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal) + + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) + val entries = + readListRepository + .findAll( + authorizedLibraryIds, + authorizedLibraryIds, + pageable = pageable, + ).map { it.toWPLinkDto() } + + val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/readlists") + + return FeedDto( + metadata = + FeedMetadataDto( + title = (library?.name ?: "All libraries") + " - Read Lists", + modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + navigation = getLibraryNavigation(principal.user, libraryId), + groups = + listOfNotNull( + FeedGroupDto(FeedMetadataDto("Read Lists"), navigation = entries.content), + ), + ) + } + + @GetMapping("readlists/{id}") + fun getOneReadList( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @Parameter(hidden = true) page: Pageable, + ): FeedDto = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + val sort = + if (readList.ordered) + Sort.by(Sort.Order.asc("readList.number")) + else + Sort.by(Sort.Order.asc("metadata.releaseDate")) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.ReadListId(SearchOperator.Is(readList.id)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.Deleted(SearchOperator.IsFalse), + ), + ) + val booksPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable) + + val entries = booksPage.map { opdsGenerator.toOpdsPublicationDto(it) } + + val uriBuilder = uriBuilder("readlists/$id") + + FeedDto( + FeedMetadataDto( + title = readList.name, + modified = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, booksPage).toTypedArray(), + ), + publications = entries.content, + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + private fun checkLibraryAccess( + libraryId: String?, + principal: KomgaPrincipal, + ): Pair?> { + val library = + if (libraryId != null) + libraryRepository.findByIdOrNull(libraryId)?.let { library -> + if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + library + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + else + null + + val libraryIds = principal.user.getAuthorizedLibraryIds(if (libraryId != null) listOf(libraryId) else null) + return Pair(library, libraryIds) + } + + @GetMapping("series/{id}") + fun getOneSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @RequestParam(name = "tag", required = false) tag: String? = null, + @Parameter(hidden = true) page: Pageable, + ): FeedDto = + seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series -> + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, series) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + buildList { + add(SearchCondition.SeriesId(SearchOperator.Is(series.id))) + add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY))) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + tag?.let { add(SearchCondition.Tag(SearchOperator.Is(it))) } + }, + ), + ) + val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort"))) + + val entries = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable).map { opdsGenerator.toOpdsPublicationDto(it) } + + val uriBuilder = uriBuilder("series/$id") + + val tagLinks = + referentialRepository.findAllBookTagsBySeries(series.id, null).map { + WPLinkDto( + title = it, + href = uriBuilder.cloneBuilder().queryParam("tag", it).toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + rel = if (it == tag) OpdsLinkRel.SELF else null, + ) + } + + FeedDto( + metadata = + FeedMetadataDto( + title = series.metadata.title, + modified = series.lastModified.toZonedDateTime(), + description = series.metadata.summary.ifBlank { series.booksMetadata.summary }, + page = entries, + ), + links = + listOf( + linkSelf(uriBuilder), + linkStart(), + linkSearch(), + *linkPage(uriBuilder, entries).toTypedArray(), + ), + publications = entries.content, + facets = + listOfNotNull( + if (tagLinks.isNotEmpty()) FacetDto(FeedMetadataDto("Tag"), tagLinks) else null, + ), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping("search") + fun getSearchResults( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "query", required = false) query: String? = null, + ): FeedDto { + val pageable = PageRequest.of(0, 20, Sort.by("relevance")) + val queryTerms = query?.split("\\s+".toRegex()) + + val resultsSeries = + seriesDtoRepository + .findAll( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + add(SearchCondition.OneShot(SearchOperator.IsFalse)) + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + if (!queryTerms.isNullOrEmpty()) { + add(SearchCondition.AllOfSeries(queryTerms.map { SearchCondition.Title(SearchOperator.Contains(it)) })) + } + }, + ), + ), + SearchContext(principal.user), + pageable, + ).map { it.toWPLinkDto() } + + val resultsBooks = + bookDtoRepository + .findAll( + BookSearch( + SearchCondition.AllOfBook( + buildList { + add(SearchCondition.Deleted(SearchOperator.IsFalse)) + if (!queryTerms.isNullOrEmpty()) { + add(SearchCondition.AllOfBook(queryTerms.map { SearchCondition.Title(SearchOperator.Contains(it)) })) + } + }, + ), + ), + SearchContext(principal.user), + pageable, + ).map { opdsGenerator.toOpdsPublicationDto(it) } + + val resultsCollections = + collectionRepository + .findAll( + principal.user.getAuthorizedLibraryIds(null), + principal.user.getAuthorizedLibraryIds(null), + query, + pageable, + principal.user.restrictions, + ).map { it.toWPLinkDto() } + + val resultsReadLists = + readListRepository + .findAll( + principal.user.getAuthorizedLibraryIds(null), + principal.user.getAuthorizedLibraryIds(null), + query, + pageable, + principal.user.restrictions, + ).map { it.toWPLinkDto() } + + return FeedDto( + metadata = + FeedMetadataDto( + title = "Search results", + modified = ZonedDateTime.now(), + ), + links = + listOf( + linkStart(), + linkSearch(), + ), + groups = + listOfNotNull( + if (!resultsSeries.isEmpty) FeedGroupDto(FeedMetadataDto("Series"), navigation = resultsSeries.content) else null, + if (!resultsBooks.isEmpty) FeedGroupDto(FeedMetadataDto("Books"), publications = resultsBooks.content) else null, + if (!resultsCollections.isEmpty) FeedGroupDto(FeedMetadataDto("Collections"), navigation = resultsCollections.content) else null, + if (!resultsReadLists.isEmpty) FeedGroupDto(FeedMetadataDto("Read Lists"), navigation = resultsReadLists.content) else null, + ), + ) + } + + @GetMapping(value = [ROUTE_AUTH], produces = [MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE]) + fun getAuthDocument() = opdsGenerator.generateOpdsAuthDocument() + + @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["books/{bookId}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE], + ) + @PreAuthorize("hasRole('PAGE_STREAMING')") + fun getBookPage( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + @Parameter( + description = "Convert the image to the provided format.", + schema = Schema(allowableValues = ["jpeg", "png"]), + ) + @RequestParam(value = "convert", required = false) + convertTo: String?, + ): ResponseEntity = commonBookController.getBookPageInternal(bookId, pageNumber, convertTo, request, principal, null) + + @GetMapping( + value = ["books/{bookId}/manifest"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifest( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/epub"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestEpub( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestEpubInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/pdf"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestPdf( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestPdfInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/divina"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestDivina( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestDivinaInternal(principal, bookId, opdsGenerator) + + private fun Library.toWPLinkDto(): WPLinkDto = + WPLinkDto( + title = name, + href = uriBuilder("libraries/$id").toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ) + + private fun SeriesDto.toWPLinkDto(): WPLinkDto = + WPLinkDto( + title = metadata.title, + href = uriBuilder("series/$id").toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ) + + private fun SeriesCollection.toWPLinkDto(): WPLinkDto = + WPLinkDto( + title = name, + href = uriBuilder("collections/$id").toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ) + + private fun ReadList.toWPLinkDto(): WPLinkDto = + WPLinkDto( + title = name, + href = uriBuilder("readlists/$id").toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt new file mode 100644 index 000000000..ae848c684 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.interfaces.api.opds.v2.dto + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.PositiveOrZero +import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.springframework.data.domain.Page +import java.time.ZonedDateTime + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FeedDto( + val metadata: FeedMetadataDto, + val links: List = emptyList(), + val navigation: List = emptyList(), + val facets: List = emptyList(), + val groups: List = emptyList(), + val publications: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FacetDto( + val metadata: FeedMetadataDto, + val links: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FeedGroupDto( + val metadata: FeedMetadataDto, + val links: List = emptyList(), + val navigation: List = emptyList(), + val publications: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FeedMetadataDto( + val title: String, + val subTitle: String? = null, + @JsonAlias("@type") + val type: String? = null, + val identifier: String? = null, + val modified: ZonedDateTime? = null, + val description: String? = null, + @JsonIgnore + val page: Page<*>? = null, + @Positive + val itemsPerPage: Int? = page?.size, + @Positive + val currentPage: Int? = page?.number?.plus(1), + @PositiveOrZero + val numberOfItems: Long? = page?.totalElements, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/OpdsAuthDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/OpdsAuthDto.kt new file mode 100644 index 000000000..26554b5df --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/OpdsAuthDto.kt @@ -0,0 +1,58 @@ +package org.gotson.komga.interfaces.api.opds.v2.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonValue +import org.gotson.komga.interfaces.api.dto.WPLinkDto + +/** + * https://drafts.opds.io/authentication-for-opds-1.0.html#23-syntax + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class AuthenticationDocumentDto( + /** + * A list of supported Authentication Flows as defined in section 3. Authentication Flows. + */ + val authentication: List, + /** + * Title of the Catalog being accessed. + */ + val title: String, + /** + * Unique identifier for the Catalog provider and canonical location for the Authentication Document. + */ + val id: String, + /** + * A description of the service being displayed to the user. + */ + val description: String? = null, + /** + * An Authentication Document may also contain a links object. + * This is used to associate the Authentication Document with resources that are not locally available. + */ + val links: List = emptyList(), +) + +/** + * In addition to the Authentication Document, this specification also defines multiple scenarios to handle how the client is authenticated. + * Each Authentication Document contains at least one Authentication Object that describes how a client can leverage an Authentication Flow. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class AuthenticationFlowDto( + val type: AuthenticationType, + val labels: LabelsDto? = null, + val links: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class LabelsDto( + val login: String? = null, + val password: String? = null, +) + +enum class AuthenticationType( + @get:JsonValue val value: String, +) { + BASIC("http://opds-spec.org/auth/basic"), + OAUTH2_IMPLICIT("http://opds-spec.org/auth/oauth/implicit"), + OAUTH2_PASSWORD("http://opds-spec.org/auth/oauth/password"), +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt new file mode 100644 index 000000000..dbf935e96 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt @@ -0,0 +1,67 @@ +package org.gotson.komga.interfaces.api.persistence + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface BookDtoRepository { + fun findAll(pageable: Pageable): Page + + fun findAll( + context: SearchContext, + pageable: Pageable, + ): Page + + fun findAll( + search: BookSearch, + context: SearchContext, + pageable: Pageable, + ): Page + + fun findByIdOrNull( + bookId: String, + userId: String, + ): BookDto? + + fun findPreviousInSeriesOrNull( + bookId: String, + userId: String, + ): BookDto? + + fun findNextInSeriesOrNull( + bookId: String, + userId: String, + ): BookDto? + + fun findPreviousInReadListOrNull( + readList: ReadList, + bookId: String, + userId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): BookDto? + + fun findNextInReadListOrNull( + readList: ReadList, + bookId: String, + userId: String, + filterOnLibraryIds: Collection?, + restrictions: ContentRestrictions = ContentRestrictions(), + ): BookDto? + + fun findAllOnDeck( + userId: String, + filterOnLibraryIds: Collection?, + pageable: Pageable, + restrictions: ContentRestrictions = ContentRestrictions(), + ): Page + + fun findAllDuplicates( + userId: String, + pageable: Pageable, + ): Page +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt new file mode 100644 index 000000000..0f6e9da80 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/HistoricalEventDtoRepository.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.persistence + +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface HistoricalEventDtoRepository { + fun findAll(pageable: Pageable): Page +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/ReadProgressDtoRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/ReadProgressDtoRepository.kt new file mode 100644 index 000000000..2f66b0661 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/ReadProgressDtoRepository.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.persistence + +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressDto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressV2Dto + +interface ReadProgressDtoRepository { + fun findProgressV2BySeries( + seriesId: String, + userId: String, + ): TachiyomiReadProgressV2Dto + + fun findProgressByReadList( + readListId: String, + userId: String, + ): TachiyomiReadProgressDto +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt new file mode 100644 index 000000000..f4f6f482e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt @@ -0,0 +1,41 @@ +package org.gotson.komga.interfaces.api.persistence + +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.interfaces.api.rest.dto.GroupCountDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface SeriesDtoRepository { + fun findByIdOrNull( + seriesId: String, + userId: String, + ): SeriesDto? + + fun findAll( + pageable: Pageable, + ): Page + + fun findAll( + context: SearchContext, + pageable: Pageable, + ): Page + + fun findAll( + search: SeriesSearch, + context: SearchContext, + pageable: Pageable, + ): Page + + fun findAllRecentlyUpdated( + search: SeriesSearch, + context: SearchContext, + pageable: Pageable, + ): Page + + fun countByFirstCharacter( + search: SeriesSearch, + context: SearchContext, + ): List +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt new file mode 100644 index 000000000..6d7cc4b1f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt @@ -0,0 +1,75 @@ +package org.gotson.komga.interfaces.api.rest + +import com.github.benmanes.caffeine.cache.Caffeine +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.JsonFeedDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException +import java.util.concurrent.TimeUnit + +private const val WEBSITE = "https://komga.org" + +@RestController +@RequestMapping("api/v1/announcements", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.ANNOUNCEMENTS) +class AnnouncementController( + private val userRepository: KomgaUserRepository, + webClientBuilder: WebClient.Builder, +) { + private val webClient = webClientBuilder.baseUrl("$WEBSITE/blog/feed.json").build() + + private val cache = + Caffeine + .newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build() + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Retrieve announcements") + fun getAnnouncements( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): JsonFeedDto = + cache + .get("announcements") { fetchWebsiteAnnouncements() } + ?.let { feed -> + val read = userRepository.findAnnouncementIdsReadByUserId(principal.user.id) + feed.copy(items = feed.items.map { item -> item.copy(komgaExtension = JsonFeedDto.KomgaExtensionDto(read.contains(item.id))) }) + } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @PreAuthorize("hasRole('ADMIN')") + @PutMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Mark announcements as read") + fun markAnnouncementsRead( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody announcementIds: Set, + ) { + userRepository.saveAnnouncementIdsRead(principal.user, announcementIds) + } + + fun fetchWebsiteAnnouncements(): JsonFeedDto? { + val response = + webClient + .get() + .retrieve() + .toEntity(JsonFeedDto::class.java) + .block() + return response?.body + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt new file mode 100644 index 000000000..618ab60d3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -0,0 +1,779 @@ +package org.gotson.komga.interfaces.api.rest + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import org.gotson.komga.application.tasks.HIGHEST_PRIORITY +import org.gotson.komga.application.tasks.HIGH_PRIORITY +import org.gotson.komga.application.tasks.LOWEST_PRIORITY +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.EntityNotFoundException +import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableAsQueryParam +import org.gotson.komga.infrastructure.openapi.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.gotson.komga.interfaces.api.WebPubGenerator +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.getBookLastModified +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto +import org.gotson.komga.interfaces.api.rest.dto.BookMetadataUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.PageDto +import org.gotson.komga.interfaces.api.rest.dto.R2Positions +import org.gotson.komga.interfaces.api.rest.dto.ReadListDto +import org.gotson.komga.interfaces.api.rest.dto.ReadProgressUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailBookDto +import org.gotson.komga.interfaces.api.rest.dto.patch +import org.gotson.komga.interfaces.api.rest.dto.restrictUrl +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.gotson.komga.interfaces.api.setNotModified +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.context.request.WebRequest +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.nio.file.NoSuchFileException +import java.time.LocalDate +import java.time.ZoneOffset + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class BookController( + private val taskEmitter: TaskEmitter, + private val bookAnalyzer: BookAnalyzer, + private val bookLifecycle: BookLifecycle, + private val bookRepository: BookRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val mediaRepository: MediaRepository, + private val bookDtoRepository: BookDtoRepository, + private val readListRepository: ReadListRepository, + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, + private val eventPublisher: ApplicationEventPublisher, + private val thumbnailBookRepository: ThumbnailBookRepository, + private val webPubGenerator: WebPubGenerator, + private val contentRestrictionChecker: ContentRestrictionChecker, + private val commonBookController: CommonBookController, +) { + @Deprecated("use /v1/books/list instead") + @PageableAsQueryParam + @GetMapping("api/v1/books") + @Operation(summary = "List books", description = "Use POST /api/v1/books/list instead. Deprecated since 1.19.0.", tags = [OpenApiConfiguration.TagNames.BOOKS, OpenApiConfiguration.TagNames.DEPRECATED]) + fun getAllBooksDeprecated( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String? = null, + @RequestParam(name = "library_id", required = false) libraryIds: List? = null, + @RequestParam(name = "media_status", required = false) mediaStatus: List? = null, + @RequestParam(name = "read_status", required = false) readStatus: List? = null, + @RequestParam(name = "released_after", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + releasedAfter: LocalDate? = null, + @RequestParam(name = "tag", required = false) tags: List? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + !searchTerm.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.unsorted() + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) })) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + releasedAfter?.let { add(SearchCondition.ReleaseDate(SearchOperator.After(it.atStartOfDay(ZoneOffset.UTC)))) } + }, + ), + searchTerm, + ) + + return bookDtoRepository + .findAll(bookSearch, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } + + @PageableAsQueryParam + @PostMapping("api/v1/books/list") + @Operation(summary = "List books", tags = [OpenApiConfiguration.TagNames.BOOKS]) + fun getBooks( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody search: BookSearch, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + !search.fullTextSearch.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.unsorted() + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return bookDtoRepository + .findAll(search, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List latest books", description = "Return newly added or updated books.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PageableWithoutSortAsQueryParam + @GetMapping("api/v1/books/latest") + fun getBooksLatest( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = Sort.by(Sort.Order.desc("lastModifiedDate")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return bookDtoRepository + .findAll( + SearchContext(principal.user), + pageRequest, + ).map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List books on deck", description = "Return first unread book of series with at least one book read and no books in progress.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PageableWithoutSortAsQueryParam + @GetMapping("api/v1/books/ondeck") + fun getBooksOnDeck( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List? = null, + @Parameter(hidden = true) page: Pageable, + ): Page = + bookDtoRepository + .findAllOnDeck( + principal.user.id, + principal.user.getAuthorizedLibraryIds(libraryIds), + page, + principal.user.restrictions, + ).map { it.restrictUrl(!principal.user.isAdmin) } + + @Operation(summary = "List duplicate books", description = "Return books that have the same file hash.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PageableAsQueryParam + @GetMapping("api/v1/books/duplicates") + @PreAuthorize("hasRole('ADMIN')") + fun getBooksDuplicates( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + else -> Sort.by(Sort.Order.asc("fileHash")) + } + + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return bookDtoRepository.findAllDuplicates(principal.user.id, pageRequest) + } + + @Operation(summary = "Get book details", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @GetMapping("api/v1/books/{bookId}") + @Throws(EntityNotFoundException::class) + fun getBookById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): BookDto = + bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, it) + + it.restrictUrl(!principal.user.isAdmin) + } ?: throw EntityNotFoundException() + + @Operation(summary = "Get previous book in series", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @GetMapping("api/v1/books/{bookId}/previous") + fun getBookSiblingPrevious( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): BookDto { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + return bookDtoRepository + .findPreviousInSeriesOrNull(bookId, principal.user.id) + ?.restrictUrl(!principal.user.isAdmin) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Get next book in series", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @GetMapping("api/v1/books/{bookId}/next") + fun getBookSiblingNext( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): BookDto { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + return bookDtoRepository + .findNextInSeriesOrNull(bookId, principal.user.id) + ?.restrictUrl(!principal.user.isAdmin) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List book's readlists", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @GetMapping("api/v1/books/{bookId}/readlists") + fun getReadListsByBookId( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + ): List { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + return readListRepository + .findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) + .map { it.toDto() } + } + + @Operation(summary = "Get book's poster image", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["api/v1/books/{bookId}/thumbnail"], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + return bookLifecycle.getThumbnailBytes(bookId)?.bytes ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Get book poster image", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["api/v1/books/{bookId}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getBookThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + contentRestrictionChecker.checkContentRestrictionBookThumbnail(principal.user, thumbnailId) + + return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId)?.bytes + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List book posters", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @GetMapping(value = ["api/v1/books/{bookId}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getBookThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + ): Collection { + contentRestrictionChecker.checkContentRestrictionBook(principal.user, bookId) + + return thumbnailBookRepository + .findAllByBookId(bookId) + .map { it.toDto() } + } + + @Operation(summary = "Add book poster", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @PostMapping(value = ["api/v1/books/{bookId}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('ADMIN')") + fun addUserUploadedBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ): ThumbnailBookDto { + val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + val mediaType = file.inputStream.buffered().use { contentDetector.detectMediaType(it) } + if (!contentDetector.isImage(mediaType)) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + return bookLifecycle + .addThumbnailForBook( + ThumbnailBook( + bookId = book.id, + thumbnail = file.bytes, + type = ThumbnailBook.Type.USER_UPLOADED, + selected = selected, + fileSize = file.bytes.size.toLong(), + mediaType = mediaType, + dimension = imageAnalyzer.getDimension(file.inputStream.buffered()) ?: Dimension(0, 0), + ), + if (selected) MarkSelectedPreference.YES else MarkSelectedPreference.NO, + ).toDto() + } + + @Operation(summary = "Mark book poster as selected", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @PutMapping("api/v1/books/{bookId}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markBookThumbnailSelected( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.bookId != book.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + thumbnailBookRepository.markSelected(poster) + eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(poster.copy(selected = true))) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete book poster", description = "Only uploaded posters can be deleted.", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @DeleteMapping("api/v1/books/{bookId}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.bookId != book.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + try { + bookLifecycle.deleteThumbnailForBook(poster) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List book pages", tags = [OpenApiConfiguration.TagNames.BOOK_PAGES]) + @GetMapping("api/v1/books/{bookId}/pages") + fun getBookPages( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): List = + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + val media = mediaRepository.findById(book.id) + when (media.status) { + Media.Status.UNKNOWN -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet") + Media.Status.OUTDATED -> throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Book is outdated and must be re-analyzed", + ) + + Media.Status.ERROR -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + Media.Status.UNSUPPORTED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book format is not supported") + Media.Status.READY -> { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + sizeBytes = bookPage.fileSize, + ) + } + } + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get book page image", tags = [OpenApiConfiguration.TagNames.BOOK_PAGES]) + @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["api/v1/books/{bookId}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE], + ) + @PreAuthorize("hasRole('PAGE_STREAMING')") + fun getBookPageByNumber( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + @Parameter( + description = "Convert the image to the provided format.", + schema = Schema(allowableValues = ["jpeg", "png"]), + ) + @RequestParam(value = "convert", required = false) + convertTo: String?, + @Parameter(description = "If set to true, pages will start at index 0. If set to false, pages will start at index 1.") + @RequestParam(value = "zero_based", defaultValue = "false") + zeroBasedIndex: Boolean, + @Parameter(description = "Some very limited server driven content negotiation is handled. If a book is a PDF book, and the Accept header contains 'application/pdf' as a more specific type than other 'image/' types, a raw PDF page will be returned.") + @RequestHeader(HttpHeaders.ACCEPT, required = false) + acceptHeaders: MutableList?, + @RequestParam(value = "contentNegotiation", defaultValue = "true") + contentNegotiation: Boolean, + ): ResponseEntity = commonBookController.getBookPageInternal(bookId, if (zeroBasedIndex) pageNumber + 1 else pageNumber, convertTo, request, principal, if (contentNegotiation) acceptHeaders else null) + + @Operation(summary = "Get book page thumbnail", description = "The image is resized to 300px on the largest dimension.", tags = [OpenApiConfiguration.TagNames.BOOK_PAGES]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["api/v1/books/{bookId}/pages/{pageNumber}/thumbnail"], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookPageThumbnailByNumber( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: WebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { + return@let ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + try { + val pageContent = bookLifecycle.getBookPage(book, pageNumber, resizeTo = 300) + + ResponseEntity + .ok() + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .setNotModified(media) + .body(pageContent.bytes) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: ImageConversionException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, ex.message) + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get book's WebPub manifest", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = ["api/v1/books/{bookId}/manifest"], + produces = [MEDIATYPE_WEBPUB_JSON_VALUE, MEDIATYPE_DIVINA_JSON_VALUE], + ) + fun getBookWebPubManifest( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity { + val manifest = commonBookController.getWebPubManifestInternal(principal, bookId, webPubGenerator) + return ResponseEntity + .ok() + .contentType(manifest.mediaType) + .body(manifest) + } + + @Operation(summary = "List book's positions", description = "The Positions API is a proposed standard for OPDS 2 and Readium. It is used by the Epub Reader.", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = ["api/v1/books/{bookId}/positions"], + produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE], + ) + fun getBookPositions( + request: HttpServletRequest, + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + val media = mediaRepository.findById(book.id) + + if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) { + return ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(null) + } + + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + val extension = + mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + ResponseEntity + .ok() + .contentType(MEDIATYPE_POSITION_LIST_JSON) + .setNotModified(media) + .body(R2Positions(extension.positions.size, extension.positions)) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get book's WebPub manifest (Epub)", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = ["api/v1/books/{bookId}/manifest/epub"], + produces = [MEDIATYPE_WEBPUB_JSON_VALUE], + ) + fun getBookWebPubManifestEpub( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) + + @Operation(summary = "Get book's WebPub manifest (PDF)", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = ["api/v1/books/{bookId}/manifest/pdf"], + produces = [MEDIATYPE_WEBPUB_JSON_VALUE], + ) + fun getBookWebPubManifestPdf( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) + + @Operation(summary = "Get book's WebPub manifest (DiViNa)", tags = [OpenApiConfiguration.TagNames.BOOK_WEBPUB]) + @GetMapping( + value = ["api/v1/books/{bookId}/manifest/divina"], + produces = [MEDIATYPE_DIVINA_JSON_VALUE], + ) + fun getBookWebPubManifestDivina( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = commonBookController.getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) + + @Operation(summary = "Analyze book", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PostMapping("api/v1/books/{bookId}/analyze") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun bookAnalyze( + @PathVariable bookId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + taskEmitter.analyzeBook(book, HIGH_PRIORITY) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Refresh book metadata", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PostMapping("api/v1/books/{bookId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun bookRefreshMetadata( + @PathVariable bookId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + taskEmitter.refreshBookMetadata(book, priority = HIGH_PRIORITY) + taskEmitter.refreshBookLocalArtwork(book, priority = HIGH_PRIORITY) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Update book metadata", description = "Set a field to null to unset the metadata. You can omit fields you don't want to update.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PatchMapping("api/v1/books/{bookId}/metadata") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateBookMetadata( + @PathVariable bookId: String, + @Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") + @Valid + @RequestBody + newMetadata: BookMetadataUpdateDto, + ) = bookMetadataRepository.findByIdOrNull(bookId)?.let { existing -> + val updated = existing.patch(newMetadata) + bookMetadataRepository.update(updated) + + bookRepository.findByIdOrNull(bookId)?.let { updatedBook -> + taskEmitter.aggregateSeriesMetadata(updatedBook.seriesId) + updatedBook.let { eventPublisher.publishEvent(DomainEvent.BookUpdated(it)) } + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Update book metadata in bulk", description = "Set a field to null to unset the metadata. You can omit fields you don't want to update.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PatchMapping("api/v1/books/metadata") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateBookMetadataByBatch( + @Parameter(description = "A map of book IDs which values are the metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") + @Valid + @RequestBody + newMetadatas: Map, + ) { + val updatedBooks = + newMetadatas.mapNotNull { (bookId, newMetadata) -> + bookMetadataRepository.findByIdOrNull(bookId)?.let { existing -> + val updated = existing.patch(newMetadata) + bookMetadataRepository.update(updated) + + bookRepository.findByIdOrNull(bookId) + } + } + + updatedBooks.forEach { eventPublisher.publishEvent(DomainEvent.BookUpdated(it)) } + updatedBooks.map { it.seriesId }.distinct().forEach { taskEmitter.aggregateSeriesMetadata(it) } + } + + @Operation(summary = "Mark book's read progress", description = "Mark book as read and/or change page progress.", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @PatchMapping("api/v1/books/{bookId}/read-progress") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun markBookReadProgress( + @PathVariable bookId: String, + @Parameter(description = "page can be omitted if completed is set to true. completed can be omitted, and will be set accordingly depending on the page passed and the total number of pages in the book.") + @Valid + @RequestBody + readProgress: ReadProgressUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + try { + if (readProgress.completed != null && readProgress.completed) + bookLifecycle.markReadProgressCompleted(book.id, principal.user) + else + bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Mark book as unread", description = "Mark book as unread", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @DeleteMapping("api/v1/books/{bookId}/read-progress") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteBookReadProgress( + @PathVariable bookId: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestrictionBook(principal.user, book) + + bookLifecycle.deleteReadProgress(book, principal.user) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Import books", tags = [OpenApiConfiguration.TagNames.BOOK_IMPORT]) + @PostMapping("api/v1/books/import") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun importBooks( + @RequestBody bookImportBatch: BookImportBatchDto, + ) { + bookImportBatch.books.forEach { + try { + taskEmitter.importBook( + sourceFile = it.sourceFile, + seriesId = it.seriesId, + copyMode = bookImportBatch.copyMode, + destinationName = it.destinationName, + upgradeBookId = it.upgradeBookId, + priority = HIGHEST_PRIORITY, + ) + } catch (e: Exception) { + logger.error(e) { "Error while creating import task for: $it" } + } + } + } + + @Operation(summary = "Delete book file", tags = [OpenApiConfiguration.TagNames.BOOKS]) + @DeleteMapping("api/v1/books/{bookId}/file") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteBookFile( + @PathVariable bookId: String, + ) { + taskEmitter.deleteBook( + bookId = bookId, + priority = HIGHEST_PRIORITY, + ) + } + + @Operation(summary = "Regenerate books posters", tags = [OpenApiConfiguration.TagNames.BOOK_POSTER]) + @PutMapping("api/v1/books/thumbnails") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun booksRegenerateThumbnails( + @RequestParam(name = "for_bigger_result_only", required = false) forBiggerResultOnly: Boolean = false, + ) { + taskEmitter.findBookThumbnailsToRegenerate(forBiggerResultOnly, LOWEST_PRIORITY) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt new file mode 100644 index 000000000..0a8ec0f03 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt @@ -0,0 +1,62 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.interfaces.api.rest.dto.UserDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("api/v1/claim", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.CLAIM) +@Validated +@SecurityRequirements +class ClaimController( + private val userDetailsLifecycle: KomgaUserLifecycle, +) { + @GetMapping + @Operation(summary = "Retrieve claim status", description = "Check whether this server has already been claimed.") + fun getClaimStatus() = ClaimStatus(userDetailsLifecycle.countUsers() > 0) + + @PostMapping + @Operation(summary = "Claim server", description = "Creates an admin user with the provided credentials.") + fun claimServer( + @Email(regexp = ".+@.+\\..+") + @RequestHeader("X-Komga-Email") + email: String, + @NotBlank + @RequestHeader("X-Komga-Password") + password: String, + ): UserDto { + if (userDetailsLifecycle.countUsers() > 0) + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed") + + return userDetailsLifecycle + .createUser( + KomgaUser( + email = email, + password = password, + roles = UserRoles.entries.toSet(), + ), + ).toDto() + } + + data class ClaimStatus( + val isClaimed: Boolean, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsController.kt new file mode 100644 index 000000000..bc9116643 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsController.kt @@ -0,0 +1,176 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import org.gotson.komga.infrastructure.jooq.main.ClientSettingsDtoDao +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.ClientSettingDto +import org.gotson.komga.interfaces.api.rest.dto.ClientSettingGlobalUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.ClientSettingUserUpdateDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import io.swagger.v3.oas.annotations.parameters.RequestBody as OASRequestBody + +private const val KEY_REGEX = """^[a-z](?:[a-z0-9_-]*[a-z0-9])*(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9])*)*$""" + +@RestController +@RequestMapping(value = ["api/v1/client-settings"], produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.CLIENT_SETTINGS) +@Validated +class ClientSettingsController( + private val clientSettingsDtoDao: ClientSettingsDtoDao, +) { + @GetMapping("global/list") + @Operation(summary = "Retrieve global client settings", description = "For unauthenticated users, only settings with 'allowUnauthorized=true' will be returned.") + @SecurityRequirements + fun getGlobalSettings( + @AuthenticationPrincipal principal: KomgaPrincipal?, + ): Map = clientSettingsDtoDao.findAllGlobal(principal == null) + + @GetMapping("user/list") + @Operation(summary = "Retrieve user client settings") + fun getUserSettings( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): Map = clientSettingsDtoDao.findAllUser(principal.user.id) + + @PatchMapping("global") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Save global settings", description = "Setting key should be a valid lowercase namespace string like 'application.domain.key'") + @OASRequestBody( + content = [ + Content( + examples = [ + //language=JSON + ExampleObject( + value = + """{ + "application.key1": { + "value": "a string value", + "allowUnauthorized": true + }, + "application.key2": { + "value": "{\"json\":\"object\"}", + "allowUnauthorized": false + } + } + """, + ), + ], + ), + ], + ) + fun saveGlobalSetting( + @RequestBody newSettings: Map< + @Pattern(regexp = KEY_REGEX) + String, + @NotNull @Valid + ClientSettingGlobalUpdateDto, + >, + ) { + newSettings.forEach { (key, setting) -> + clientSettingsDtoDao.saveGlobal(key, setting.value, setting.allowUnauthorized) + } + } + + @PatchMapping("user") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Save user settings", description = "Setting key should be a valid lowercase namespace string like 'application.domain.key'") + @OASRequestBody( + content = [ + Content( + examples = [ + //language=JSON + ExampleObject( + value = + """{ + "application.key1": { + "value": "a string value" + }, + "application.key2": { + "value": "{\"json\":\"object\"}" + } + } + """, + ), + ], + ), + ], + ) + fun saveUserSetting( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody newSettings: Map< + @Pattern(regexp = KEY_REGEX) + String, + @NotNull @Valid + ClientSettingUserUpdateDto, + >, + ) { + newSettings.forEach { (key, setting) -> + clientSettingsDtoDao.saveForUser(principal.user.id, key, setting.value) + } + } + + @DeleteMapping("global") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete global settings", description = "Setting key should be a valid lowercase namespace string like 'application.domain.key'") + @OASRequestBody( + content = [ + Content( + examples = [ + //language=JSON + ExampleObject(value = """["application.key1", "application.key2"]"""), + ], + ), + ], + ) + fun deleteGlobalSettings( + @RequestBody keysToDelete: Set< + @Pattern(regexp = KEY_REGEX) + String, + >, + ) { + clientSettingsDtoDao.deleteGlobalByKeys(keysToDelete) + } + + @DeleteMapping("user") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete user settings", description = "Setting key should be a valid lowercase namespace string like 'application.domain.key'") + @OASRequestBody( + content = [ + Content( + examples = [ + //language=JSON + ExampleObject(value = """["application.key1", "application.key2"]"""), + ], + ), + ], + ) + fun deleteUserSettings( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody keysToDelete: Set< + @Pattern(regexp = KEY_REGEX) + String, + >, + ) { + clientSettingsDtoDao.deleteByUserIdAndKeys(principal.user.id, keysToDelete) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ErrorHandlingControllerAdvice.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ErrorHandlingControllerAdvice.kt new file mode 100644 index 000000000..c170d9275 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ErrorHandlingControllerAdvice.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.interfaces.api.rest + +import jakarta.validation.ConstraintViolationException +import org.gotson.komga.domain.model.EntityNotFoundException +import org.springframework.http.HttpStatus +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.ResponseStatus + +@ControllerAdvice +class ErrorHandlingControllerAdvice { + @ExceptionHandler(ConstraintViolationException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ResponseBody + fun onConstraintValidationException( + e: ConstraintViolationException, + ): ValidationErrorResponse = + ValidationErrorResponse( + e.constraintViolations.map { Violation(it.propertyPath.toString(), it.message) }, + ) + + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ResponseBody + fun onMethodArgumentNotValidException( + e: MethodArgumentNotValidException, + ): ValidationErrorResponse = + ValidationErrorResponse( + e.bindingResult.fieldErrors.map { Violation(it.field, it.defaultMessage) }, + ) + + @ExceptionHandler(EntityNotFoundException::class) + @ResponseStatus(HttpStatus.NOT_FOUND) + fun handleEntityNotFound() { + } +} + +data class ValidationErrorResponse( + val violations: List = emptyList(), +) + +data class Violation( + val fieldName: String? = null, + val message: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemController.kt new file mode 100644 index 000000000..da545feb3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemController.kt @@ -0,0 +1,90 @@ +package org.gotson.komga.interfaces.api.rest + +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isDirectory +import kotlin.streams.asSequence + +@RestController +@RequestMapping("api/v1/filesystem", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = OpenApiConfiguration.TagNames.FILE_SYSTEM) +class FileSystemController { + private val fs = FileSystems.getDefault() + + @PostMapping + @Operation( + summary = "Directory listing", + description = "List folders and files from the host server's file system. If no request body is passed then the root directories are returned.", + ) + fun getDirectoryListing( + @RequestBody(required = false) request: DirectoryRequestDto = DirectoryRequestDto(), + ): DirectoryListingDto = + if (request.path.isEmpty()) { + DirectoryListingDto( + directories = fs.rootDirectories.map { it.toDto() }, + files = emptyList(), + ) + } else { + val path = fs.getPath(request.path) + if (!path.isAbsolute) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Path must be absolute") + val directory = if (path.isDirectory()) path else path.parent + try { + val (directories, files) = + Files.list(directory).use { dirStream -> + dirStream + .asSequence() + .filter { !Files.isHidden(it) && (if (!request.showFiles) Files.isDirectory(it) else true) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.toString() }) + .map { it.toDto() } + .toList() + .partition { it.type == "directory" } + } + DirectoryListingDto( + parent = (path.parent ?: "").toString(), + directories = directories, + files = files, + ) + } catch (e: Exception) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Path does not exist") + } + } +} + +data class DirectoryRequestDto( + val path: String = "", + val showFiles: Boolean = false, +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class DirectoryListingDto( + val parent: String? = null, + val directories: List, + val files: List, +) + +data class PathDto( + val type: String, + val name: String, + val path: String, +) + +fun Path.toDto(): PathDto = + PathDto( + type = if (Files.isDirectory(this)) "directory" else "file", + name = (fileName ?: this).toString(), + path = toString(), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt new file mode 100644 index 000000000..ce97c11ad --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt @@ -0,0 +1,180 @@ +package org.gotson.komga.interfaces.api.rest + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.language.contains +import org.springframework.core.io.ByteArrayResource +import org.springframework.core.io.FileSystemResource +import org.springframework.core.io.Resource +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.isReadable +import kotlin.io.path.isRegularFile +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping(value = ["api/v1/fonts"], produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.BOOK_FONTS) +class FontsController( + komgaProperties: KomgaProperties, +) { + private val supportedExtensions = listOf("woff", "woff2", "ttf", "otf") + private final val fonts: Map> + + init { + val resolver = PathMatchingResourcePatternResolver() + val fontsEmbedded = + try { + resolver + .getResources("/embeddedFonts/**/*.*") + .filterNot { it.filename == null } + .filter { supportedExtensions.contains(FilenameUtils.getExtension(it.uri.toString()), true) } + .groupBy { + FilenameUtils.getName(FilenameUtils.getPathNoEndSeparator(it.uri.toString())) + } + } catch (e: Exception) { + logger.error(e) { "Could not load embedded fonts" } + emptyMap() + } + + val fontsDir = Path(komgaProperties.fonts.dataDirectory) + val fontsAdditional = + try { + if (fontsDir.isDirectory() && fontsDir.isReadable()) { + fontsDir + .listDirectoryEntries() + .filter { it.isDirectory() } + .associate { dir -> + dir.name to + dir + .listDirectoryEntries() + .filter { it.isRegularFile() } + .filter { it.isReadable() } + .filter { supportedExtensions.contains(it.extension, true) } + .map { FileSystemResource(it) } + } + } else { + emptyMap() + } + } catch (e: Exception) { + logger.error(e) { "Could not load additional fonts" } + emptyMap() + } + + fonts = fontsEmbedded + fontsAdditional + + logger.info { "Fonts embedded: $fontsEmbedded" } + logger.info { "Fonts discovered: $fontsAdditional" } + } + + @GetMapping("families") + @Operation(summary = "List font families", description = "List all available font families.") + fun getFonts(): Set = fonts.keys + + @GetMapping("resource/{fontFamily}/{fontFile}") + @Operation(summary = "Download font file") + @SecurityRequirements + fun getFontFile( + @PathVariable fontFamily: String, + @PathVariable fontFile: String, + ): ResponseEntity { + fonts[fontFamily]?.let { resources -> + val resource = resources.firstOrNull { it.filename == fontFile } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val mediaType = "font/${FilenameUtils.getExtension(resource.uri.toString()).lowercase()}" + return ResponseEntity + .ok() + .headers { + it.contentDisposition = + ContentDisposition + .attachment() + .filename(fontFile) + .build() + }.contentType(MediaType.parseMediaType(mediaType)) + .body(resource) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping("resource/{fontFamily}/css", produces = ["text/css"]) + @Operation(summary = "Download CSS file", description = "Download a CSS file with the @font-face block for the font family. This is used by the Epub Reader to change fonts.") + @SecurityRequirements + fun getFontFamilyAsCss( + @PathVariable fontFamily: String, + ): ResponseEntity { + fonts[fontFamily]?.let { files -> + val groups = files.groupBy { getFontCharacteristics(FilenameUtils.getName(it.uri.toString())) } + + val css = + groups + .map { (styleWeight, resources) -> buildFontFaceBlock(fontFamily, styleWeight, resources) } + .joinToString(separator = "\n") + + return ResponseEntity + .ok() + .headers { + it.contentDisposition = + ContentDisposition + .attachment() + .filename("$fontFamily.css") + .build() + }.body(ByteArrayResource(css.toByteArray())) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + private fun buildFontFaceBlock( + fontFamily: String, + styleAndWeight: FontCharacteristics, + fonts: List, + ): String { + val srcBlock = + fonts.joinToString(separator = ",", postfix = ";") { resource -> + val filename = FilenameUtils.getName(resource.uri.toString()) + val format = + when (val extension = FilenameUtils.getExtension(resource.uri.toString()).lowercase()) { + "ttf" -> "truetype" + "otf" -> "opentype" + else -> extension + } + """url('$filename') format('$format')""" + } + // language=CSS + return """ + @font-face { + font-family: '$fontFamily'; + src: $srcBlock + font-weight: ${styleAndWeight.weight}; + font-style: ${styleAndWeight.style}; + } + + """.trimIndent() + } + + private fun getFontCharacteristics(filename: String): FontCharacteristics { + val style = if (filename.contains("italic", true)) "italic" else "normal" + val weight = if (filename.contains("bold", true)) "bold" else "normal" + return FontCharacteristics(style, weight) + } + + private data class FontCharacteristics( + val style: String, + val weight: String, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt new file mode 100644 index 000000000..69179edd0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/HistoricalEventController.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableAsQueryParam +import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1/history", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = OpenApiConfiguration.TagNames.HISTORY) +class HistoricalEventController( + private val historicalEventDtoRepository: HistoricalEventDtoRepository, +) { + @GetMapping + @PageableAsQueryParam + @Operation(summary = "List historical events") + fun getHistoricalEvents( + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + if (page.sort.isSorted) + page.sort + else + Sort.by(Sort.Order.desc("timestamp")) + + val pageRequest = + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return historicalEventDtoRepository.findAll(pageRequest) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt new file mode 100644 index 000000000..be3eb3b89 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt @@ -0,0 +1,286 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.gotson.komga.application.tasks.HIGHEST_PRIORITY +import org.gotson.komga.application.tasks.HIGH_PRIORITY +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.filePathToUrl +import org.gotson.komga.interfaces.api.rest.dto.LibraryCreationDto +import org.gotson.komga.interfaces.api.rest.dto.LibraryDto +import org.gotson.komga.interfaces.api.rest.dto.LibraryUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.toDomain +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.io.FileNotFoundException + +@RestController +@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.LIBRARIES) +class LibraryController( + private val taskEmitter: TaskEmitter, + private val libraryLifecycle: LibraryLifecycle, + private val libraryRepository: LibraryRepository, + private val bookRepository: BookRepository, + private val seriesRepository: SeriesRepository, +) { + @GetMapping + @Operation( + summary = "List all libraries", + description = "The libraries are filtered based on the current user's permissions", + ) + fun getLibraries( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): List = + if (principal.user.canAccessAllLibraries()) { + libraryRepository.findAll() + } else { + libraryRepository.findAllByIds(principal.user.sharedLibrariesIds) + }.sortedBy { it.name.lowercase() }.map { it.toDto(includeRoot = principal.user.isAdmin) } + + @GetMapping("{libraryId}") + @Operation(summary = "Get details for a single library") + fun getLibraryById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable libraryId: String, + ): LibraryDto = + libraryRepository.findByIdOrNull(libraryId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + it.toDto(includeRoot = principal.user.isAdmin) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create a library") + fun addLibrary( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody + library: LibraryCreationDto, + ): LibraryDto = + try { + libraryLifecycle + .addLibrary( + Library( + name = library.name, + root = filePathToUrl(library.root), + importComicInfoBook = library.importComicInfoBook, + importComicInfoSeries = library.importComicInfoSeries, + importComicInfoCollection = library.importComicInfoCollection, + importComicInfoReadList = library.importComicInfoReadList, + importComicInfoSeriesAppendVolume = library.importComicInfoSeriesAppendVolume, + importEpubBook = library.importEpubBook, + importEpubSeries = library.importEpubSeries, + importMylarSeries = library.importMylarSeries, + importLocalArtwork = library.importLocalArtwork, + importBarcodeIsbn = library.importBarcodeIsbn, + scanForceModifiedTime = library.scanForceModifiedTime, + scanInterval = library.scanInterval.toDomain(), + scanOnStartup = library.scanOnStartup, + scanCbx = library.scanCbx, + scanPdf = library.scanPdf, + scanEpub = library.scanEpub, + scanDirectoryExclusions = library.scanDirectoryExclusions, + repairExtensions = library.repairExtensions, + convertToCbz = library.convertToCbz, + emptyTrashAfterScan = library.emptyTrashAfterScan, + seriesCover = library.seriesCover.toDomain(), + hashFiles = library.hashFiles, + hashPages = library.hashPages, + hashKoreader = library.hashKoreader, + analyzeDimensions = library.analyzeDimensions, + oneshotsDirectory = library.oneshotsDirectory?.ifBlank { null }, + ), + ).toDto(includeRoot = principal.user.isAdmin) + } catch (e: Exception) { + when (e) { + is FileNotFoundException, + is DirectoryNotFoundException, + is DuplicateNameException, + is PathContainedInPath, + -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + + else -> throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR) + } + } + + @PutMapping("/{libraryId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Deprecated("Use PATCH /v1/libraries/{libraryId} instead", ReplaceWith("patchOne")) + @Operation(summary = "Update a library", description = "Use PATCH /api/v1/libraries/{libraryId} instead. Deprecated since 1.3.0.", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun updateLibraryByIdDeprecated( + @PathVariable libraryId: String, + @Valid @RequestBody + library: LibraryUpdateDto, + ) { + updateLibraryById(libraryId, library) + } + + @PatchMapping("/{libraryId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Update a library", description = "You can omit fields you don't want to update") + fun updateLibraryById( + @PathVariable libraryId: String, + @Parameter(description = "Fields to update. You can omit fields you don't want to update.") + @Valid + @RequestBody + library: LibraryUpdateDto, + ) { + libraryRepository.findByIdOrNull(libraryId)?.let { existing -> + val toUpdate = + with(library) { + existing.copy( + id = libraryId, + name = name ?: existing.name, + root = root?.let { filePathToUrl(root!!) } ?: existing.root, + importComicInfoBook = importComicInfoBook ?: existing.importComicInfoBook, + importComicInfoSeries = importComicInfoSeries ?: existing.importComicInfoSeries, + importComicInfoCollection = importComicInfoCollection ?: existing.importComicInfoCollection, + importComicInfoReadList = importComicInfoReadList ?: existing.importComicInfoReadList, + importComicInfoSeriesAppendVolume = importComicInfoSeriesAppendVolume ?: existing.importComicInfoSeriesAppendVolume, + importEpubBook = importEpubBook ?: existing.importEpubBook, + importEpubSeries = importEpubSeries ?: existing.importEpubSeries, + importMylarSeries = importMylarSeries ?: existing.importMylarSeries, + importLocalArtwork = importLocalArtwork ?: existing.importLocalArtwork, + importBarcodeIsbn = importBarcodeIsbn ?: existing.importBarcodeIsbn, + scanForceModifiedTime = scanForceModifiedTime ?: existing.scanForceModifiedTime, + scanInterval = scanInterval?.toDomain() ?: existing.scanInterval, + scanOnStartup = scanOnStartup ?: existing.scanOnStartup, + scanCbx = scanCbx ?: existing.scanCbx, + scanPdf = scanPdf ?: existing.scanPdf, + scanEpub = scanEpub ?: existing.scanEpub, + scanDirectoryExclusions = if (isSet("scanDirectoryExclusions")) scanDirectoryExclusions ?: emptySet() else existing.scanDirectoryExclusions, + repairExtensions = repairExtensions ?: existing.repairExtensions, + convertToCbz = convertToCbz ?: existing.convertToCbz, + emptyTrashAfterScan = emptyTrashAfterScan ?: existing.emptyTrashAfterScan, + seriesCover = seriesCover?.toDomain() ?: existing.seriesCover, + hashFiles = hashFiles ?: existing.hashFiles, + hashPages = hashPages ?: existing.hashPages, + hashKoreader = hashKoreader ?: existing.hashKoreader, + analyzeDimensions = analyzeDimensions ?: existing.analyzeDimensions, + oneshotsDirectory = if (isSet("oneshotsDirectory")) oneshotsDirectory?.ifBlank { null } else existing.oneshotsDirectory, + ) + } + try { + libraryLifecycle.updateLibrary(toUpdate) + } catch (e: Exception) { + when (e) { + is FileNotFoundException, + is DirectoryNotFoundException, + is DuplicateNameException, + is PathContainedInPath, + -> + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + + else -> throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR) + } + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @DeleteMapping("/{libraryId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a library") + fun deleteLibraryById( + @PathVariable libraryId: String, + ) { + libraryRepository.findByIdOrNull(libraryId)?.let { + libraryLifecycle.deleteLibrary(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PostMapping("{libraryId}/scan") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "Scan a library") + fun libraryScan( + @PathVariable libraryId: String, + @RequestParam(required = false) deep: Boolean = false, + ) { + libraryRepository.findByIdOrNull(libraryId)?.let { library -> + taskEmitter.scanLibrary(library.id, deep, HIGHEST_PRIORITY) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PostMapping("{libraryId}/analyze") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "Analyze a library") + fun libraryAnalyze( + @PathVariable libraryId: String, + ) { + val books = + bookRepository + .findAll( + SearchCondition.LibraryId(SearchOperator.Is(libraryId)), + SearchContext.empty(), + Pageable.unpaged(), + ).content + taskEmitter.analyzeBook(books, HIGH_PRIORITY) + } + + @PostMapping("{libraryId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "Refresh metadata for a library") + fun libraryRefreshMetadata( + @PathVariable libraryId: String, + ) { + val books = + bookRepository + .findAll( + SearchCondition.LibraryId(SearchOperator.Is(libraryId)), + SearchContext.empty(), + Pageable.unpaged(), + ).content + taskEmitter.refreshBookMetadata(books, priority = HIGH_PRIORITY) + taskEmitter.refreshBookLocalArtwork(books, priority = HIGH_PRIORITY) + taskEmitter.refreshSeriesLocalArtwork(seriesRepository.findAllIdsByLibraryId(libraryId), priority = HIGH_PRIORITY) + } + + @PostMapping("{libraryId}/empty-trash") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "Empty trash for a library") + fun libraryEmptyTrash( + @PathVariable libraryId: String, + ) { + libraryRepository.findByIdOrNull(libraryId)?.let { library -> + taskEmitter.emptyTrash(library.id, HIGH_PRIORITY) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LoginController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LoginController.kt new file mode 100644 index 000000000..ef96f9543 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LoginController.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpSession +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.session.web.http.CookieSerializer +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.USER_SESSION) +class LoginController( + private val cookieSerializer: CookieSerializer, +) { + @Operation(summary = "Set cookie", description = "Forcefully return Set-Cookie header, even if the session is contained in the X-Auth-Token header.") + @GetMapping("api/v1/login/set-cookie") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun convertHeaderSessionToCookie( + request: HttpServletRequest, + response: HttpServletResponse, + session: HttpSession, + ) { + cookieSerializer.writeCookieValue(CookieSerializer.CookieValue(request, response, session.id)) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2Controller.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2Controller.kt new file mode 100644 index 000000000..abbae560d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2Controller.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.springframework.http.MediaType +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1/oauth2", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.OAUTH2) +@SecurityRequirements +class OAuth2Controller( + clientRegistrationRepository: InMemoryClientRegistrationRepository?, +) { + val registrationIds = + clientRegistrationRepository?.map { + OAuth2ClientDto(it.clientName, it.registrationId) + } ?: emptyList() + + @GetMapping("providers") + @Operation(summary = "List registered OAuth2 providers") + fun getOAuth2Providers() = registrationIds +} + +data class OAuth2ClientDto( + val name: String, + val registrationId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/PageHashController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/PageHashController.kt new file mode 100644 index 000000000..601fc5823 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/PageHashController.kt @@ -0,0 +1,168 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.BookPageNumbered +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.domain.persistence.PageHashRepository +import org.gotson.komga.domain.service.PageHashLifecycle +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableAsQueryParam +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.interfaces.api.rest.dto.PageHashCreationDto +import org.gotson.komga.interfaces.api.rest.dto.PageHashKnownDto +import org.gotson.komga.interfaces.api.rest.dto.PageHashMatchDto +import org.gotson.komga.interfaces.api.rest.dto.PageHashUnknownDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("api/v1/page-hashes", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = OpenApiConfiguration.TagNames.DUPLICATE_PAGES) +class PageHashController( + private val pageHashRepository: PageHashRepository, + private val pageHashLifecycle: PageHashLifecycle, + private val taskEmitter: TaskEmitter, +) { + @Operation(summary = "List known duplicates") + @GetMapping + @PageableAsQueryParam + fun getKnownPageHashes( + @RequestParam(name = "action", required = false) actions: List?, + @Parameter(hidden = true) page: Pageable, + ): Page = pageHashRepository.findAllKnown(actions, page).map { it.toDto() } + + @Operation(summary = "Get known duplicate image thumbnail") + @GetMapping("/{pageHash}/thumbnail", produces = [MediaType.IMAGE_JPEG_VALUE]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + fun getKnownPageHashThumbnail( + @PathVariable pageHash: String, + ): ByteArray = + pageHashRepository.getKnownThumbnail(pageHash) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "List unknown duplicates") + @GetMapping("/unknown") + @PageableAsQueryParam + fun getUnknownPageHashes( + @Parameter(hidden = true) page: Pageable, + ): Page = pageHashRepository.findAllUnknown(page).map { it.toDto() } + + @Operation(summary = "List duplicate matches") + @GetMapping("{pageHash}") + @PageableAsQueryParam + fun getPageHashMatches( + @PathVariable pageHash: String, + @Parameter(hidden = true) page: Pageable, + ): Page = + pageHashRepository + .findMatchesByHash( + pageHash, + page, + ).map { it.toDto() } + + @Operation(summary = "Get unknown duplicate image thumbnail") + @GetMapping("unknown/{pageHash}/thumbnail", produces = [MediaType.IMAGE_JPEG_VALUE]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + fun getUnknownPageHashThumbnail( + @PathVariable pageHash: String, + @RequestParam("resize") resize: Int? = null, + ): ResponseEntity = + pageHashLifecycle.getPage(pageHash, resize)?.let { + ResponseEntity + .ok() + .contentType(getMediaTypeOrDefault(it.mediaType)) + .body(it.bytes) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Mark duplicate page as known") + @PutMapping + @ResponseStatus(HttpStatus.ACCEPTED) + fun createOrUpdateKnownPageHash( + @Valid @RequestBody + pageHash: PageHashCreationDto, + ) { + try { + pageHashLifecycle.createOrUpdate( + PageHashKnown( + hash = pageHash.hash, + size = pageHash.size, + action = pageHash.action, + ), + ) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @Operation(summary = "Delete all duplicate pages by hash") + @PostMapping("{pageHash}/delete-all") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteDuplicatePagesByPageHash( + @PathVariable pageHash: String, + ) { + val toRemove = + pageHashRepository + .findMatchesByHash(pageHash, Pageable.unpaged()) + .groupBy( + { it.bookId }, + { + BookPageNumbered( + fileName = it.fileName, + mediaType = it.mediaType, + fileHash = pageHash, + fileSize = it.fileSize, + pageNumber = it.pageNumber, + ) + }, + ) + + taskEmitter.removeDuplicatePages(toRemove) + } + + @Operation(summary = "Delete specific duplicate page") + @PostMapping("{pageHash}/delete-match") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteSingleMatchByPageHash( + @PathVariable pageHash: String, + @RequestBody matchDto: PageHashMatchDto, + ) { + val toRemove = + Pair( + matchDto.bookId, + listOf( + BookPageNumbered( + fileName = matchDto.fileName, + mediaType = matchDto.mediaType, + fileHash = pageHash, + fileSize = matchDto.fileSize, + pageNumber = matchDto.pageNumber, + ), + ), + ) + + taskEmitter.removeDuplicatePages(toRemove.first, toRemove.second) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt new file mode 100644 index 000000000..51dbe80ad --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt @@ -0,0 +1,492 @@ +package org.gotson.komga.interfaces.api.rest + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.validation.Valid +import org.apache.commons.compress.archivers.zip.Zip64Mode +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.io.IOUtils +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.CodedException +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType.ZIP +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.ThumbnailReadList +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.ReadListLifecycle +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.openapi.AuthorsAsQueryParam +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.Authors +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.ReadListCreationDto +import org.gotson.komga.interfaces.api.rest.dto.ReadListDto +import org.gotson.komga.interfaces.api.rest.dto.ReadListRequestMatchDto +import org.gotson.komga.interfaces.api.rest.dto.ReadListUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressDto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailReadListDto +import org.gotson.komga.interfaces.api.rest.dto.restrictUrl +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.gotson.komga.language.toIndexedMap +import org.springframework.context.ApplicationEventPublisher +import org.springframework.core.io.FileSystemResource +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.CacheControl +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.OutputStream +import java.nio.charset.StandardCharsets.UTF_8 +import java.util.concurrent.TimeUnit +import java.util.zip.Deflater + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v1/readlists", produces = [MediaType.APPLICATION_JSON_VALUE]) +class ReadListController( + private val readListRepository: ReadListRepository, + private val readListLifecycle: ReadListLifecycle, + private val bookDtoRepository: BookDtoRepository, + private val bookRepository: BookRepository, + private val readProgressDtoRepository: ReadProgressDtoRepository, + private val thumbnailReadListRepository: ThumbnailReadListRepository, + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, + private val bookLifecycle: BookLifecycle, + private val eventPublisher: ApplicationEventPublisher, +) { + @Operation(summary = "List readlists", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @PageableWithoutSortAsQueryParam + @GetMapping + fun getReadLists( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + !searchTerm.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.by(Sort.Order.asc("name")) + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return readListRepository + .findAll(principal.user.getAuthorizedLibraryIds(libraryIds), principal.user.getAuthorizedLibraryIds(null), searchTerm, pageRequest, principal.user.restrictions) + .map { it.toDto() } + } + + @Operation(summary = "Get readlist details", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @GetMapping("{id}") + fun getReadListById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + ): ReadListDto = + readListRepository + .findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) + ?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get readlist's poster image", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + ): ResponseEntity { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { + return ResponseEntity + .ok() + .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate()) + .body(readListLifecycle.getThumbnailBytes(it)) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Get readlist poster image", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getReadListThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ): ByteArray { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.readListId != it.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + return poster.thumbnail + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List readlist's posters", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @GetMapping(value = ["{id}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getReadListThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + ): Collection { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { + return thumbnailReadListRepository.findAllByReadListId(id).map { it.toDto() } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Add readlist poster", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @PostMapping(value = ["{id}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('ADMIN')") + fun addUserUploadedReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ): ThumbnailReadListDto { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { readList -> + + val mediaType = file.inputStream.buffered().use { contentDetector.detectMediaType(it) } + if (!contentDetector.isImage(mediaType)) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + return readListLifecycle + .addThumbnail( + ThumbnailReadList( + readListId = readList.id, + thumbnail = file.bytes, + type = ThumbnailReadList.Type.USER_UPLOADED, + selected = selected, + fileSize = file.bytes.size.toLong(), + mediaType = mediaType, + dimension = imageAnalyzer.getDimension(file.inputStream.buffered()) ?: Dimension(0, 0), + ), + ).toDto() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Mark readlist poster as selected", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @PutMapping("{id}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markReadListThumbnailSelected( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { readList -> + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.readListId != readList.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + readListLifecycle.markSelectedThumbnail(poster) + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(poster.copy(selected = true))) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete readlist poster", tags = [OpenApiConfiguration.TagNames.READLIST_POSTER]) + @DeleteMapping("{id}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { readList -> + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.readListId != readList.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + readListLifecycle.deleteThumbnail(poster) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Create readlist", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun createReadList( + @Valid @RequestBody + readList: ReadListCreationDto, + ): ReadListDto = + try { + readListLifecycle + .addReadList( + ReadList( + name = readList.name, + summary = readList.summary, + ordered = readList.ordered, + bookIds = readList.bookIds.toIndexedMap(), + ), + ).toDto() + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + + @Operation(summary = "Match ComicRack list", tags = [OpenApiConfiguration.TagNames.COMICRACK]) + @PostMapping("match/comicrack", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('ADMIN')") + fun matchComicRackList( + @RequestParam("file") file: MultipartFile, + ): ReadListRequestMatchDto = + try { + readListLifecycle.matchComicRackList(file.bytes).toDto() + } catch (e: CodedException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code) + } + + @Operation(summary = "Update readlist", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @PatchMapping("{id}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateReadListById( + @PathVariable id: String, + @Valid @RequestBody + readList: ReadListUpdateDto, + ) { + readListRepository.findByIdOrNull(id)?.let { existing -> + val updated = + existing.copy( + name = readList.name ?: existing.name, + summary = readList.summary ?: existing.summary, + ordered = readList.ordered ?: existing.ordered, + bookIds = readList.bookIds?.toIndexedMap() ?: existing.bookIds, + ) + try { + readListLifecycle.updateReadList(updated) + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete readlist", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @DeleteMapping("{id}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteReadListById( + @PathVariable id: String, + ) { + readListRepository.findByIdOrNull(id)?.let { + readListLifecycle.deleteReadList(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List readlist's books", tags = [OpenApiConfiguration.TagNames.READLIST_BOOKS]) + @PageableWithoutSortAsQueryParam + @AuthorsAsQueryParam + @GetMapping("{id}/books") + fun getBooksByReadListId( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "read_status", required = false) readStatus: List?, + @RequestParam(name = "tag", required = false) tags: List?, + @RequestParam(name = "media_status", required = false) mediaStatus: List?, + @RequestParam(name = "deleted", required = false) deleted: Boolean?, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) @Authors authors: List?, + @Parameter(hidden = true) page: Pageable, + ): Page = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + val sort = + if (readList.ordered) + Sort.by(Sort.Order.asc("readList.number")) + else + Sort.by(Sort.Order.asc("metadata.releaseDate")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + val bookSearch = + BookSearch( + SearchCondition.AllOfBook( + buildList { + add(SearchCondition.ReadListId(SearchOperator.Is(readList.id))) + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfBook(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) })) + deleted?.let { add(SearchCondition.Deleted(if (deleted) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + ) + bookDtoRepository + .findAll(bookSearch, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get previous book in readlist", tags = [OpenApiConfiguration.TagNames.READLIST_BOOKS]) + @GetMapping("{id}/books/{bookId}/previous") + fun getBookSiblingPreviousInReadList( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @PathVariable bookId: String, + ): BookDto = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + bookDtoRepository + .findPreviousInReadListOrNull( + it, + bookId, + principal.user.id, + principal.user.getAuthorizedLibraryIds(null), + principal.user.restrictions, + )?.restrictUrl(!principal.user.isAdmin) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get next book in readlist", tags = [OpenApiConfiguration.TagNames.READLIST_BOOKS]) + @GetMapping("{id}/books/{bookId}/next") + fun getBookSiblingNextInReadList( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + @PathVariable bookId: String, + ): BookDto = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + bookDtoRepository + .findNextInReadListOrNull( + it, + bookId, + principal.user.id, + principal.user.getAuthorizedLibraryIds(null), + principal.user.restrictions, + )?.restrictUrl(!principal.user.isAdmin) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get readlist read progress (Mihon)", description = "Mihon specific, due to how read progress is handled in Mihon.", tags = [OpenApiConfiguration.TagNames.MIHON]) + @GetMapping("{id}/read-progress/tachiyomi") + fun getMihonReadProgressByReadListId( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ): TachiyomiReadProgressDto = + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + readProgressDtoRepository.findProgressByReadList(readList.id, principal.user.id) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Update readlist read progress (Mihon)", description = "Mihon specific, due to how read progress is handled in Mihon.", tags = [OpenApiConfiguration.TagNames.MIHON]) + @PutMapping("{id}/read-progress/tachiyomi") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateMihonReadProgressByReadListId( + @PathVariable id: String, + @Valid @RequestBody + readProgress: TachiyomiReadProgressUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + bookDtoRepository + .findAll(BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList.id))), SearchContext(principal.user), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))) + .filterIndexed { index, _ -> index < readProgress.lastBookRead } + .forEach { book -> + if (book.readProgress?.completed != true) + bookLifecycle.markReadProgressCompleted(book.id, principal.user) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Download readlist", description = "Download the whole readlist as a ZIP file.", tags = [OpenApiConfiguration.TagNames.READLISTS]) + @GetMapping("{id}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @PreAuthorize("hasRole('FILE_DOWNLOAD')") + fun downloadReadListAsZip( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + ): ResponseEntity { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + + val books = + readList.bookIds + .mapNotNull { bookRepository.findByIdOrNull(it.value)?.let { book -> it.key to book } } + .toMap() + + val streamingResponse = + StreamingResponseBody { responseStream: OutputStream -> + ZipArchiveOutputStream(responseStream).use { zipStream -> + zipStream.setMethod(ZipArchiveOutputStream.DEFLATED) + zipStream.setLevel(Deflater.NO_COMPRESSION) + zipStream.setUseZip64(Zip64Mode.Always) + books.forEach { (index, book) -> + val file = FileSystemResource(book.path) + if (!file.exists()) { + logger.warn { "Book file not found, skipping archive entry: ${file.path}" } + return@forEach + } + + logger.debug { "Adding file to zip archive: ${file.path}" } + file.inputStream.use { + zipStream.putArchiveEntry(ZipArchiveEntry("${index + 1} - ${file.filename}")) + IOUtils.copyLarge(it, zipStream, ByteArray(8192)) + zipStream.closeArchiveEntry() + } + } + } + } + + return ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("attachment") + .filename(readList.name + ".zip", UTF_8) + .build() + }, + ).contentType(MediaType.parseMediaType(ZIP.type)) + .body(streamingResponse) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV1Controller.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV1Controller.kt new file mode 100644 index 000000000..1e1953805 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV1Controller.kt @@ -0,0 +1,183 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.REFERENTIAL) +class ReferentialV1Controller( + private val referentialRepository: ReferentialRepository, +) { + @GetMapping("authors") + @Deprecated("Use GET /v2/authors instead") + @Operation(summary = "List authors", description = "Use GET /api/v2/authors instead. Deprecated since 1.20.0.", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getAuthorsDeprecated( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", defaultValue = "") search: String, + @RequestParam(name = "library_id", required = false) libraryId: String?, + @RequestParam(name = "collection_id", required = false) collectionId: String?, + @RequestParam(name = "series_id", required = false) seriesId: String?, + ): List = + + when { + libraryId != null -> referentialRepository.findAllAuthorsByNameAndLibrary(search, libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllAuthorsByNameAndCollection(search, collectionId, principal.user.getAuthorizedLibraryIds(null)) + seriesId != null -> referentialRepository.findAllAuthorsByNameAndSeries(search, seriesId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllAuthorsByName(search, principal.user.getAuthorizedLibraryIds(null)) + }.map { it.toDto() } + + @GetMapping("authors/names") + @Deprecated("Use GET /v2/authors/names instead") + @Operation(summary = "List authors' names", description = "Use GET /v2/authors/names instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getAuthorsNames( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", defaultValue = "") search: String, + ): List = referentialRepository.findAllAuthorsNamesByName(search, principal.user.getAuthorizedLibraryIds(null)) + + @GetMapping("authors/roles") + @Deprecated("Use GET /v2/authors/roles instead") + @Operation(summary = "List authors' roles", description = "Use GET /v2/authors/roles instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getAuthorsRoles( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): List = referentialRepository.findAllAuthorsRoles(principal.user.getAuthorizedLibraryIds(null)) + + @GetMapping("genres") + @Deprecated("Use GET /v2/genres instead") + @Operation(summary = "List genres", description = "Use GET /v2/genres instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getGenres( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllGenresByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllGenresByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllGenres(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("sharing-labels") + @Deprecated("Use GET /v2/sharing-labels instead") + @Operation(summary = "List sharing labels", description = "Use GET /v2/sharing-labels instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getSharingLabels( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllSharingLabelsByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSharingLabelsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSharingLabels(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("tags") + @Deprecated("Use GET /v2/tags instead") + @Operation(summary = "List tags", description = "Use GET /v2/tags instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllSeriesAndBookTagsByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesAndBookTagsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesAndBookTags(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("tags/book") + @Deprecated("Use GET /v2/tags instead") + @Operation(summary = "List book tags", description = "Use GET /v2/tags instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getBookTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "series_id", required = false) seriesId: String?, + @RequestParam(name = "readlist_id", required = false) readListId: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + ): Set = + when { + seriesId != null -> referentialRepository.findAllBookTagsBySeries(seriesId, principal.user.getAuthorizedLibraryIds(null)) + readListId != null -> referentialRepository.findAllBookTagsByReadList(readListId, principal.user.getAuthorizedLibraryIds(null)) + libraryIds.isNotEmpty() -> referentialRepository.findAllBookTags(principal.user.getAuthorizedLibraryIds(libraryIds)) + else -> referentialRepository.findAllBookTags(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("tags/series") + @Deprecated("Use GET /v2/tags instead") + @Operation(summary = "List series tags", description = "Use GET /v2/tags instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getSeriesTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryId: String?, + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryId != null -> referentialRepository.findAllSeriesTagsByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesTagsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesTags(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("languages") + @Deprecated("Use GET /v2/languages instead") + @Operation(summary = "List languages", description = "Use GET /v2/languages instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getLanguages( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllLanguagesByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllLanguagesByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllLanguages(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("publishers") + @Deprecated("Use GET /v2/publishers instead") + @Operation(summary = "List publishers", description = "Use GET /v2/publishers instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getPublishers( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllPublishersByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllPublishersByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("age-ratings") + @Deprecated("Use GET /v2/age-ratings instead") + @Operation(summary = "List age ratings", description = "Use GET /v2/age-ratings instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getAgeRatings( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllAgeRatingsByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllAgeRatingsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllAgeRatings(principal.user.getAuthorizedLibraryIds(null)) + }.map { it?.toString() ?: "None" }.toSet() + + @GetMapping("series/release-dates") + @Deprecated("Use GET /v2/series/release-years instead") + @Operation(summary = "List series release dates", description = "Use GET /v2/series/release-years instead. Deprecated since 1.26.0", tags = [OpenApiConfiguration.TagNames.DEPRECATED]) + fun getSeriesReleaseDates( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllSeriesReleaseDatesByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesReleaseDatesByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesReleaseDates(principal.user.getAuthorizedLibraryIds(null)) + }.map { it.year.toString() }.toSet() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV2Controller.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV2Controller.kt new file mode 100644 index 000000000..c34d9ad10 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialV2Controller.kt @@ -0,0 +1,353 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.domain.model.FilterBy +import org.gotson.komga.domain.model.FilterByEntity +import org.gotson.komga.domain.model.FilterTags +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v2", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.REFERENTIAL) +class ReferentialV2Controller( + private val referentialRepository: ReferentialRepository, +) { + @PageableWithoutSortAsQueryParam + @GetMapping("authors") + @Operation(summary = "List authors", description = "Can be filtered by various criteria") + fun getAuthors( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "role", required = false) role: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "series_id", required = false) seriesIds: Set = emptySet(), + @RequestParam(name = "readlist_id", required = false) readListIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + seriesIds.isNotEmpty() -> FilterBy(FilterByEntity.SERIES, seriesIds) + readListIds.isNotEmpty() -> FilterBy(FilterByEntity.READLIST, readListIds) + else -> null + } + + return referentialRepository.findAuthors(SearchContext(principal.user), search, role, filterBy, pageRequest).map { it.toDto() } + } + + @PageableWithoutSortAsQueryParam + @GetMapping("authors/roles") + @Operation(summary = "List authors roles", description = "Can be filtered by various criteria") + fun getAuthorsRoles( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "series_id", required = false) seriesIds: Set = emptySet(), + @RequestParam(name = "readlist_id", required = false) readListIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + seriesIds.isNotEmpty() -> FilterBy(FilterByEntity.SERIES, seriesIds) + readListIds.isNotEmpty() -> FilterBy(FilterByEntity.READLIST, readListIds) + else -> null + } + + return referentialRepository.findAuthorsRoles(SearchContext(principal.user), filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("authors/names") + @Operation(summary = "List authors names", description = "Can be filtered by various criteria") + fun getAuthorsNames( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "role", required = false) role: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "series_id", required = false) seriesIds: Set = emptySet(), + @RequestParam(name = "readlist_id", required = false) readListIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + seriesIds.isNotEmpty() -> FilterBy(FilterByEntity.SERIES, seriesIds) + readListIds.isNotEmpty() -> FilterBy(FilterByEntity.READLIST, readListIds) + else -> null + } + + return referentialRepository.findAuthorsNames(SearchContext(principal.user), search, role, filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("genres") + @Operation(summary = "List genres", description = "Can be filtered by various criteria") + fun getGenres( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findGenres(SearchContext(principal.user), search, filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("sharing-labels") + @Operation(summary = "List sharing labels", description = "Can be filtered by various criteria") + fun getSharingLabels( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findSharingLabels(SearchContext(principal.user), search, filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("languages") + @Operation(summary = "List languages", description = "Can be filtered by various criteria") + fun getLanguages( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findLanguages(SearchContext(principal.user), search, filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("publishers") + @Operation(summary = "List publishers", description = "Can be filtered by various criteria") + fun getPublishers( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findPublishers(SearchContext(principal.user), search, filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("tags") + @Operation(summary = "List tags", description = "Can be filtered by various criteria") + fun getTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) search: String?, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "series_id", required = false) seriesIds: Set = emptySet(), + @RequestParam(name = "readlist_id", required = false) readListIds: Set = emptySet(), + @RequestParam(name = "include", required = false) includeTags: FilterTags = FilterTags.BOTH, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + seriesIds.isNotEmpty() -> FilterBy(FilterByEntity.SERIES, seriesIds) + readListIds.isNotEmpty() -> FilterBy(FilterByEntity.READLIST, readListIds) + else -> null + } + + return referentialRepository.findTags(SearchContext(principal.user), search, filterBy, includeTags, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("series/release-years") + @Operation(summary = "List series release years", description = "Can be filtered by various criteria") + fun getSeriesReleaseYears( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findSeriesReleaseYears(SearchContext(principal.user), filterBy, pageRequest) + } + + @PageableWithoutSortAsQueryParam + @GetMapping("age-ratings") + @Operation(summary = "List age ratings", description = "Can be filtered by various criteria") + fun getAgeRatings( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionIds: Set = emptySet(), + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val pageRequest = + if (unpaged) + Pageable.unpaged() + else + PageRequest.of( + page.pageNumber, + page.pageSize, + ) + + val filterBy = + when { + libraryIds.isNotEmpty() -> FilterBy(FilterByEntity.LIBRARY, libraryIds) + + collectionIds.isNotEmpty() -> FilterBy(FilterByEntity.COLLECTION, collectionIds) + else -> null + } + + return referentialRepository.findAgeRatings(SearchContext(principal.user), filterBy, pageRequest) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReleaseController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReleaseController.kt new file mode 100644 index 000000000..4b360df34 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReleaseController.kt @@ -0,0 +1,68 @@ +package org.gotson.komga.interfaces.api.rest + +import com.github.benmanes.caffeine.cache.Caffeine +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.interfaces.api.rest.dto.GithubReleaseDto +import org.gotson.komga.interfaces.api.rest.dto.ReleaseDto +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException +import java.util.concurrent.TimeUnit + +private const val GITHUB_API = "https://api.github.com/repos/gotson/komga/releases" + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("api/v1/releases", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.RELEASES) +class ReleaseController( + webClientBuilder: WebClient.Builder, +) { + private val webClient = webClientBuilder.baseUrl(GITHUB_API).build() + + private val cache = + Caffeine + .newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build>() + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List releases") + fun getReleases(): List = + cache + .get("releases") { fetchGitHubReleases() } + ?.let { releases -> + releases.mapIndexed { index, ghRel -> + ReleaseDto( + ghRel.tagName, + ghRel.publishedAt, + ghRel.htmlUrl, + index == 0, + ghRel.prerelease, + ghRel.body, + ) + } + } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun fetchGitHubReleases(): List { + val response = + webClient + .get() + .uri { + it.queryParam("per_page", 20).build() + }.retrieve() + .toEntity(object : ParameterizedTypeReference>() {}) + .block() + return response?.body ?: emptyList() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt new file mode 100644 index 000000000..848cc6fd1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt @@ -0,0 +1,358 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.validation.Valid +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.ThumbnailSeriesCollection +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository +import org.gotson.komga.domain.service.SeriesCollectionLifecycle +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.openapi.AuthorsAsQueryParam +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.Authors +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.CollectionCreationDto +import org.gotson.komga.interfaces.api.rest.dto.CollectionDto +import org.gotson.komga.interfaces.api.rest.dto.CollectionUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailSeriesCollectionDto +import org.gotson.komga.interfaces.api.rest.dto.restrictUrl +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.CacheControl +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit + +@RestController +@RequestMapping("api/v1/collections", produces = [MediaType.APPLICATION_JSON_VALUE]) +class SeriesCollectionController( + private val collectionRepository: SeriesCollectionRepository, + private val collectionLifecycle: SeriesCollectionLifecycle, + private val seriesDtoRepository: SeriesDtoRepository, + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, + private val thumbnailSeriesCollectionRepository: ThumbnailSeriesCollectionRepository, + private val eventPublisher: ApplicationEventPublisher, +) { + @Operation(summary = "List collections", tags = [OpenApiConfiguration.TagNames.COLLECTIONS]) + @PageableWithoutSortAsQueryParam + @GetMapping + fun getCollections( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + !searchTerm.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.by(Sort.Order.asc("name")) + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return collectionRepository + .findAll(principal.user.getAuthorizedLibraryIds(libraryIds), principal.user.getAuthorizedLibraryIds(null), searchTerm, pageRequest, principal.user.restrictions) + .map { it.toDto() } + } + + @Operation(summary = "Get collection details", tags = [OpenApiConfiguration.TagNames.COLLECTIONS]) + @GetMapping("{id}") + fun getCollectionById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + ): CollectionDto = + collectionRepository + .findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) + ?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Get collection's poster image", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable id: String, + ): ResponseEntity { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { + return ResponseEntity + .ok() + .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate()) + .body(collectionLifecycle.getThumbnailBytes(it, principal.user.id)) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Get collection poster image", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getCollectionThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ): ByteArray { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { collection -> + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.collectionId != collection.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + return poster.thumbnail + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List collection's posters", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @GetMapping(value = ["{id}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getCollectionThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + ): Collection { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { + return thumbnailSeriesCollectionRepository.findAllByCollectionId(id).map { it.toDto() } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Add collection poster", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @PostMapping(value = ["{id}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('ADMIN')") + fun addUserUploadedCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ): ThumbnailSeriesCollectionDto { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + + val mediaType = file.inputStream.buffered().use { contentDetector.detectMediaType(it) } + if (!contentDetector.isImage(mediaType)) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + return collectionLifecycle + .addThumbnail( + ThumbnailSeriesCollection( + collectionId = collection.id, + thumbnail = file.bytes, + type = ThumbnailSeriesCollection.Type.USER_UPLOADED, + selected = selected, + fileSize = file.bytes.size.toLong(), + mediaType = mediaType, + dimension = imageAnalyzer.getDimension(file.inputStream.buffered()) ?: Dimension(0, 0), + ), + ).toDto() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Mark collection poster as selected", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @PutMapping("{id}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markCollectionThumbnailSelected( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.collectionId != collection.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + collectionLifecycle.markSelectedThumbnail(poster) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(poster.copy(selected = true))) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete collection poster", tags = [OpenApiConfiguration.TagNames.COLLECTION_POSTER]) + @DeleteMapping("{id}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.collectionId != collection.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + collectionLifecycle.deleteThumbnail(poster) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Create collection", tags = [OpenApiConfiguration.TagNames.COLLECTIONS]) + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun createCollection( + @Valid @RequestBody + collection: CollectionCreationDto, + ): CollectionDto = + try { + collectionLifecycle + .addCollection( + SeriesCollection( + name = collection.name, + ordered = collection.ordered, + seriesIds = collection.seriesIds, + ), + ).toDto() + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + + @Operation(summary = "Update collection", tags = [OpenApiConfiguration.TagNames.COLLECTIONS]) + @PatchMapping("{id}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateCollectionById( + @PathVariable id: String, + @Valid @RequestBody + collection: CollectionUpdateDto, + ) { + collectionRepository.findByIdOrNull(id)?.let { existing -> + val updated = + existing.copy( + name = collection.name ?: existing.name, + ordered = collection.ordered ?: existing.ordered, + seriesIds = collection.seriesIds ?: existing.seriesIds, + ) + try { + collectionLifecycle.updateCollection(updated) + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete collection", tags = [OpenApiConfiguration.TagNames.COLLECTIONS]) + @DeleteMapping("{id}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteCollectionById( + @PathVariable id: String, + ) { + collectionRepository.findByIdOrNull(id)?.let { + collectionLifecycle.deleteCollection(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List collection's series", tags = [OpenApiConfiguration.TagNames.COLLECTION_SERIES]) + @PageableWithoutSortAsQueryParam + @AuthorsAsQueryParam + @GetMapping("{id}/series") + fun getSeriesByCollectionId( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "status", required = false) metadataStatus: List?, + @RequestParam(name = "read_status", required = false) readStatus: List?, + @RequestParam(name = "publisher", required = false) publishers: List?, + @RequestParam(name = "language", required = false) languages: List?, + @RequestParam(name = "genre", required = false) genres: List?, + @RequestParam(name = "tag", required = false) tags: List?, + @RequestParam(name = "age_rating", required = false) ageRatings: List?, + @RequestParam(name = "release_year", required = false) releaseYears: List?, + @RequestParam(name = "deleted", required = false) deleted: Boolean?, + @RequestParam(name = "complete", required = false) complete: Boolean?, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) @Authors authors: List?, + @Parameter(hidden = true) page: Pageable, + ): Page = + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { collection -> + val sort = + if (collection.ordered) + Sort.by(Sort.Order.asc("collection.number")) + else + Sort.by(Sort.Order.asc("metadata.titleSort")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + add(SearchCondition.CollectionId(SearchOperator.Is(collection.id))) + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) })) + if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) })) + if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) })) + if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) })) + if (!releaseYears.isNullOrEmpty()) + add( + SearchCondition.AnyOfSeries( + releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear -> + SearchCondition.AllOfSeries( + SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))), + SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))), + ) + }, + ), + ) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) })) + }, + ), + ) + + seriesDtoRepository + .findAll(search, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt new file mode 100644 index 000000000..b72476a3c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt @@ -0,0 +1,866 @@ +package org.gotson.komga.interfaces.api.rest + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.validation.Valid +import org.apache.commons.compress.archivers.zip.Zip64Mode +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.io.IOUtils +import org.gotson.komga.application.tasks.HIGHEST_PRIORITY +import org.gotson.komga.application.tasks.HIGH_PRIORITY +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.AlternateTitle +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.EntityNotFoundException +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType.ZIP +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchField +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.openapi.AuthorsAsQueryParam +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.openapi.PageableAsQueryParam +import org.gotson.komga.infrastructure.openapi.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.Authors +import org.gotson.komga.infrastructure.web.DelimitedPair +import org.gotson.komga.interfaces.api.ContentRestrictionChecker +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.CollectionDto +import org.gotson.komga.interfaces.api.rest.dto.GroupCountDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesMetadataUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressUpdateV2Dto +import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressV2Dto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailSeriesDto +import org.gotson.komga.interfaces.api.rest.dto.restrictUrl +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.context.ApplicationEventPublisher +import org.springframework.core.io.FileSystemResource +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.OutputStream +import java.net.URI +import java.nio.charset.StandardCharsets.UTF_8 +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.zip.Deflater + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api", produces = [MediaType.APPLICATION_JSON_VALUE]) +class SeriesController( + private val taskEmitter: TaskEmitter, + private val seriesRepository: SeriesRepository, + private val seriesLifecycle: SeriesLifecycle, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val seriesDtoRepository: SeriesDtoRepository, + private val bookLifecycle: BookLifecycle, + private val bookRepository: BookRepository, + private val bookDtoRepository: BookDtoRepository, + private val collectionRepository: SeriesCollectionRepository, + private val readProgressDtoRepository: ReadProgressDtoRepository, + private val eventPublisher: ApplicationEventPublisher, + private val contentDetector: ContentDetector, + private val imageAnalyzer: ImageAnalyzer, + private val thumbnailsSeriesRepository: ThumbnailSeriesRepository, + private val contentRestrictionChecker: ContentRestrictionChecker, +) { + @Operation(summary = "List series", description = "Use POST /api/v1/series/list instead. Deprecated since 1.19.0.", tags = [OpenApiConfiguration.TagNames.SERIES, OpenApiConfiguration.TagNames.DEPRECATED]) + @Deprecated("use /v1/series/list instead") + @PageableAsQueryParam + @AuthorsAsQueryParam + @Parameters( + Parameter( + description = "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.", + `in` = ParameterIn.QUERY, + name = "search_regex", + schema = Schema(type = "string"), + ), + ) + @GetMapping("v1/series") + fun getSeriesDeprecated( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String? = null, + @Parameter(hidden = true) + @DelimitedPair("search_regex") + searchRegex: Pair? = null, + @RequestParam(name = "library_id", required = false) libraryIds: List? = null, + @RequestParam(name = "collection_id", required = false) collectionIds: List? = null, + @RequestParam(name = "status", required = false) metadataStatus: List? = null, + @RequestParam(name = "read_status", required = false) readStatus: List? = null, + @RequestParam(name = "publisher", required = false) publishers: List? = null, + @RequestParam(name = "language", required = false) languages: List? = null, + @RequestParam(name = "genre", required = false) genres: List? = null, + @RequestParam(name = "tag", required = false) tags: List? = null, + @RequestParam(name = "age_rating", required = false) ageRatings: List? = null, + @RequestParam(name = "release_year", required = false) releaseYears: List? = null, + @RequestParam(name = "sharing_label", required = false) sharingLabels: List? = null, + @RequestParam(name = "deleted", required = false) deleted: Boolean? = null, + @RequestParam(name = "complete", required = false) complete: Boolean? = null, + @RequestParam(name = "oneshot", required = false) oneshot: Boolean? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) @Authors authors: List? = null, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + !searchTerm.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.unsorted() + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + val seriesSearch = + SeriesSearch( + condition = + SearchCondition.AllOfSeries( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + if (!collectionIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(collectionIds.map { SearchCondition.CollectionId(SearchOperator.Is(it)) })) + if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) })) + if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) })) + if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) })) + if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) })) + if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) })) + if (!releaseYears.isNullOrEmpty()) + add( + SearchCondition.AnyOfSeries( + releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear -> + SearchCondition.AllOfSeries( + SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))), + SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))), + ) + }, + ), + ) + + if (!sharingLabels.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(sharingLabels.map { SearchCondition.SharingLabel(SearchOperator.Is(it)) })) + oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + fullTextSearch = searchTerm, + regexSearch = + searchRegex?.let { + when (it.second.lowercase()) { + "title" -> Pair(it.first, SearchField.TITLE) + "title_sort" -> Pair(it.first, SearchField.TITLE_SORT) + else -> null + } + }, + ) + + return seriesDtoRepository + .findAll(seriesSearch, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List series", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PageableAsQueryParam + @PostMapping("v1/series/list") + fun getSeries( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody search: SeriesSearch, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + when { + page.sort.isSorted -> page.sort + !search.fullTextSearch.isNullOrBlank() -> Sort.by("relevance") + else -> Sort.unsorted() + } + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return seriesDtoRepository + .findAll(search, SearchContext(principal.user), pageRequest) + .map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List series groups", description = "Use POST /api/v1/series/list/alphabetical-groups instead. Deprecated since 1.19.0.", tags = [OpenApiConfiguration.TagNames.SERIES, OpenApiConfiguration.TagNames.DEPRECATED]) + @Deprecated("use /v1/series/list/alphabetical-groups instead") + @AuthorsAsQueryParam + @Parameters( + Parameter( + description = "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.", + `in` = ParameterIn.QUERY, + name = "search_regex", + schema = Schema(type = "string"), + ), + ) + @GetMapping("v1/series/alphabetical-groups") + fun getSeriesAlphabeticalGroupsDeprecated( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @Parameter(hidden = true) + @DelimitedPair("search_regex") + searchRegex: Pair?, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "collection_id", required = false) collectionIds: List?, + @RequestParam(name = "status", required = false) metadataStatus: List?, + @RequestParam(name = "read_status", required = false) readStatus: List?, + @RequestParam(name = "publisher", required = false) publishers: List?, + @RequestParam(name = "language", required = false) languages: List?, + @RequestParam(name = "genre", required = false) genres: List?, + @RequestParam(name = "tag", required = false) tags: List?, + @RequestParam(name = "age_rating", required = false) ageRatings: List?, + @RequestParam(name = "release_year", required = false) releaseYears: List?, + @RequestParam(name = "sharing_label", required = false) sharingLabels: List? = null, + @RequestParam(name = "deleted", required = false) deleted: Boolean?, + @RequestParam(name = "complete", required = false) complete: Boolean?, + @RequestParam(name = "oneshot", required = false) oneshot: Boolean? = null, + @Parameter(hidden = true) @Authors authors: List?, + @Parameter(hidden = true) page: Pageable, + ): List { + val seriesSearch = + SeriesSearch( + condition = + SearchCondition.AllOfSeries( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + if (!collectionIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(collectionIds.map { SearchCondition.CollectionId(SearchOperator.Is(it)) })) + if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) })) + if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) })) + if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) })) + if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) })) + if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) })) + if (!releaseYears.isNullOrEmpty()) + add( + SearchCondition.AnyOfSeries( + releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear -> + SearchCondition.AllOfSeries( + SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))), + SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))), + ) + }, + ), + ) + + if (!sharingLabels.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(sharingLabels.map { SearchCondition.SharingLabel(SearchOperator.Is(it)) })) + oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + fullTextSearch = searchTerm, + regexSearch = + searchRegex?.let { + when (it.second.lowercase()) { + "title" -> Pair(it.first, SearchField.TITLE) + "title_sort" -> Pair(it.first, SearchField.TITLE_SORT) + else -> null + } + }, + ) + + return seriesDtoRepository.countByFirstCharacter(seriesSearch, SearchContext(principal.user)) + } + + @Operation(summary = "List series groups", description = "List series grouped by the first character of their sort title.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PostMapping("v1/series/list/alphabetical-groups") + fun getSeriesAlphabeticalGroups( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody search: SeriesSearch, + ): List = seriesDtoRepository.countByFirstCharacter(search, SearchContext(principal.user)) + + @Operation(summary = "List latest series", description = "Return recently added or updated series.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PageableWithoutSortAsQueryParam + @GetMapping("v1/series/latest") + fun getSeriesLatest( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + @RequestParam(name = "deleted", required = false) deleted: Boolean?, + @RequestParam(name = "oneshot", required = false) oneshot: Boolean? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = Sort.by(Sort.Order.desc("lastModified")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return seriesDtoRepository + .findAll( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + ), + SearchContext(principal.user), + pageRequest, + ).map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List new series", description = "Return newly added series.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PageableWithoutSortAsQueryParam + @GetMapping("v1/series/new") + fun getSeriesNew( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List? = null, + @RequestParam(name = "deleted", required = false) deleted: Boolean? = null, + @RequestParam(name = "oneshot", required = false) oneshot: Boolean? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = Sort.by(Sort.Order.desc("created")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return seriesDtoRepository + .findAll( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + ), + SearchContext(principal.user), + pageRequest, + ).map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List updated series", description = "Return recently updated series, but not newly added ones.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PageableWithoutSortAsQueryParam + @GetMapping("v1/series/updated") + fun getSeriesUpdated( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: List? = null, + @RequestParam(name = "deleted", required = false) deleted: Boolean? = null, + @RequestParam(name = "oneshot", required = false) oneshot: Boolean? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = Sort.by(Sort.Order.desc("lastModified")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return seriesDtoRepository + .findAllRecentlyUpdated( + SeriesSearch( + SearchCondition.AllOfSeries( + buildList { + if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) })) + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + ), + SearchContext(principal.user), + pageRequest, + ).map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "Get series details", tags = [OpenApiConfiguration.TagNames.SERIES]) + @GetMapping("v1/series/{seriesId}") + @Throws(EntityNotFoundException::class) + fun getSeriesById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") id: String, + ): SeriesDto = + seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, it) + it.restrictUrl(!principal.user.isAdmin) + } ?: throw EntityNotFoundException() + + @Operation(summary = "Get series' poster image", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["v1/series/{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getSeriesThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") seriesId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + return seriesLifecycle.getThumbnailBytes(seriesId, principal.user.id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Get series poster image", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["v1/series/{seriesId}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getSeriesThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") seriesId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ): ByteArray { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + contentRestrictionChecker.checkContentRestrictionSeriesThumbnail(principal.user, thumbnailId) + + return seriesLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List series posters", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @GetMapping(value = ["v1/series/{seriesId}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getSeriesThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") seriesId: String, + ): Collection { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + return thumbnailsSeriesRepository + .findAllBySeriesId(seriesId) + .map { it.toDto() } + } + + @Operation(summary = "Add series poster", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @PostMapping(value = ["v1/series/{seriesId}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('ADMIN')") + fun addUserUploadedSeriesThumbnail( + @PathVariable(name = "seriesId") seriesId: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ): ThumbnailSeriesDto { + val series = seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + if (series.oneshot) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + + val mediaType = file.inputStream.buffered().use { contentDetector.detectMediaType(it) } + if (!contentDetector.isImage(mediaType)) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + return seriesLifecycle + .addThumbnailForSeries( + ThumbnailSeries( + seriesId = series.id, + thumbnail = file.bytes, + type = ThumbnailSeries.Type.USER_UPLOADED, + fileSize = file.bytes.size.toLong(), + mediaType = mediaType, + dimension = imageAnalyzer.getDimension(file.inputStream.buffered()) ?: Dimension(0, 0), + ), + if (selected) MarkSelectedPreference.YES else MarkSelectedPreference.NO, + ).toDto() + } + + @Operation(summary = "Mark series poster as selected", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @PutMapping("v1/series/{seriesId}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markSeriesThumbnailSelected( + @PathVariable(name = "seriesId") seriesId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + seriesRepository.findByIdOrNull(seriesId)?.let { series -> + thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.seriesId != series.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + thumbnailsSeriesRepository.markSelected(poster) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(poster.copy(selected = true))) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "Delete series poster", tags = [OpenApiConfiguration.TagNames.SERIES_POSTER]) + @DeleteMapping("v1/series/{seriesId}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedSeriesThumbnail( + @PathVariable(name = "seriesId") seriesId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + seriesRepository.findByIdOrNull(seriesId)?.let { series -> + thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let { poster -> + if (poster.seriesId != series.id) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + try { + seriesLifecycle.deleteThumbnailForSeries(poster) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @Operation(summary = "List series' books", description = "Use POST /api/v1/books/list instead. Deprecated since 1.19.0.", tags = [OpenApiConfiguration.TagNames.SERIES, OpenApiConfiguration.TagNames.DEPRECATED]) + @Deprecated("use /v1/books/list instead") + @PageableAsQueryParam + @AuthorsAsQueryParam + @GetMapping("v1/series/{seriesId}/books") + fun getBooksBySeriesId( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") seriesId: String, + @RequestParam(name = "media_status", required = false) mediaStatus: List? = null, + @RequestParam(name = "read_status", required = false) readStatus: List? = null, + @RequestParam(name = "tag", required = false) tags: List? = null, + @RequestParam(name = "deleted", required = false) deleted: Boolean? = null, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) @Authors authors: List? = null, + @Parameter(hidden = true) page: Pageable, + ): Page { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + val sort = + if (page.sort.isSorted) + page.sort + else + Sort.by(Sort.Order.asc("metadata.numberSort")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + val search = + BookSearch( + SearchCondition.AllOfBook( + buildList { + add(SearchCondition.SeriesId(SearchOperator.Is(seriesId))) + if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) })) + if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) })) + if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) })) + if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfBook(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) })) + deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) } + }, + ), + ) + return bookDtoRepository + .findAll( + search, + SearchContext(principal.user), + pageRequest, + ).map { it.restrictUrl(!principal.user.isAdmin) } + } + + @Operation(summary = "List series' collections", tags = [OpenApiConfiguration.TagNames.SERIES]) + @GetMapping("v1/series/{seriesId}/collections") + fun getCollectionsBySeriesId( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") seriesId: String, + ): List { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + return collectionRepository + .findAllContainingSeriesId(seriesId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) + .map { it.toDto() } + } + + @Operation(summary = "Analyze series", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PostMapping("v1/series/{seriesId}/analyze") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun seriesAnalyze( + @PathVariable seriesId: String, + ) { + taskEmitter.analyzeBook(bookRepository.findAllBySeriesId(seriesId), HIGH_PRIORITY) + } + + @Operation(summary = "Refresh series metadata", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PostMapping("v1/series/{seriesId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun seriesRefreshMetadata( + @PathVariable seriesId: String, + ) { + val books = bookRepository.findAllBySeriesId(seriesId) + taskEmitter.refreshBookMetadata(books, priority = HIGH_PRIORITY) + taskEmitter.refreshBookLocalArtwork(books, priority = HIGH_PRIORITY) + taskEmitter.refreshSeriesLocalArtwork(seriesId, priority = HIGH_PRIORITY) + } + + @Operation(summary = "Update series metadata", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PatchMapping("v1/series/{seriesId}/metadata") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateSeriesMetadata( + @PathVariable seriesId: String, + @Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") + @Valid + @RequestBody + newMetadata: SeriesMetadataUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) = seriesMetadataRepository.findByIdOrNull(seriesId)?.let { existing -> + val updated = + with(newMetadata) { + existing.copy( + status = status ?: existing.status, + statusLock = statusLock ?: existing.statusLock, + title = title ?: existing.title, + titleLock = titleLock ?: existing.titleLock, + titleSort = titleSort ?: existing.titleSort, + titleSortLock = titleSortLock ?: existing.titleSortLock, + summary = summary ?: existing.summary, + summaryLock = summaryLock ?: existing.summaryLock, + language = language ?: existing.language, + languageLock = languageLock ?: existing.languageLock, + readingDirection = if (isSet("readingDirection")) readingDirection else existing.readingDirection, + readingDirectionLock = readingDirectionLock ?: existing.readingDirectionLock, + publisher = publisher ?: existing.publisher, + publisherLock = publisherLock ?: existing.publisherLock, + ageRating = if (isSet("ageRating")) ageRating else existing.ageRating, + ageRatingLock = ageRatingLock ?: existing.ageRatingLock, + genres = + if (isSet("genres")) { + if (genres != null) genres!! else emptySet() + } else { + existing.genres + }, + genresLock = genresLock ?: existing.genresLock, + tags = + if (isSet("tags")) { + if (tags != null) tags!! else emptySet() + } else { + existing.tags + }, + tagsLock = tagsLock ?: existing.tagsLock, + totalBookCount = if (isSet("totalBookCount")) totalBookCount else existing.totalBookCount, + totalBookCountLock = totalBookCountLock ?: existing.totalBookCountLock, + sharingLabels = + if (isSet("sharingLabels")) { + if (sharingLabels != null) sharingLabels!! else emptySet() + } else { + existing.sharingLabels + }, + sharingLabelsLock = sharingLabelsLock ?: existing.sharingLabelsLock, + links = + if (isSet("links")) { + if (links != null) links!!.map { WebLink(it.label!!, URI(it.url!!)) } else emptyList() + } else { + existing.links + }, + linksLock = linksLock ?: existing.linksLock, + alternateTitles = + if (isSet("alternateTitles")) { + if (alternateTitles != null) alternateTitles!!.map { AlternateTitle(it.label!!, it.title!!) } else emptyList() + } else { + existing.alternateTitles + }, + alternateTitlesLock = alternateTitlesLock ?: existing.alternateTitlesLock, + ) + } + seriesMetadataRepository.update(updated) + + seriesRepository.findByIdOrNull(seriesId)?.let { eventPublisher.publishEvent(DomainEvent.SeriesUpdated(it)) } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @Operation(summary = "Mark series as read", description = "Mark all book for series as read", tags = [OpenApiConfiguration.TagNames.SERIES]) + @PostMapping("v1/series/{seriesId}/read-progress") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun markSeriesAsRead( + @PathVariable seriesId: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + seriesLifecycle.markReadProgressCompleted(seriesId, principal.user) + } + + @Operation(summary = "Mark series as unread", description = "Mark all book for series as unread", tags = [OpenApiConfiguration.TagNames.SERIES]) + @DeleteMapping("v1/series/{seriesId}/read-progress") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun markSeriesAsUnread( + @PathVariable seriesId: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + seriesLifecycle.deleteReadProgress(seriesId, principal.user) + } + + @Operation(summary = "Get series read progress (Mihon)", description = "Mihon specific, due to how read progress is handled in Mihon.", tags = [OpenApiConfiguration.TagNames.MIHON]) + @GetMapping("v2/series/{seriesId}/read-progress/tachiyomi") + fun getMihonReadProgressBySeriesId( + @PathVariable seriesId: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ): TachiyomiReadProgressV2Dto { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + return readProgressDtoRepository.findProgressV2BySeries(seriesId, principal.user.id) + } + + @Operation(summary = "Update series read progress (Mihon)", description = "Mihon specific, due to how read progress is handled in Mihon.", tags = [OpenApiConfiguration.TagNames.MIHON]) + @PutMapping("v2/series/{seriesId}/read-progress/tachiyomi") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateMihonReadProgressBySeriesId( + @PathVariable seriesId: String, + @RequestBody readProgress: TachiyomiReadProgressUpdateV2Dto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + bookDtoRepository + .findAll( + BookSearch(SearchCondition.SeriesId(SearchOperator.Is(seriesId))), + SearchContext(principal.user), + UnpagedSorted(Sort.by(Sort.Order.asc("metadata.numberSort"))), + ).toList() + .filter { book -> book.metadata.numberSort <= readProgress.lastBookNumberSortRead } + .forEach { book -> + if (book.readProgress?.completed != true) + bookLifecycle.markReadProgressCompleted(book.id, principal.user) + } + } + + @Operation(summary = "Download series", description = "Download the whole series as a ZIP file.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @GetMapping("v1/series/{seriesId}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @PreAuthorize("hasRole('FILE_DOWNLOAD')") + fun downloadSeriesAsZip( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable seriesId: String, + ): ResponseEntity { + contentRestrictionChecker.checkContentRestrictionSeries(principal.user, seriesId) + + val books = bookRepository.findAllBySeriesId(seriesId) + + val streamingResponse = + StreamingResponseBody { responseStream: OutputStream -> + ZipArchiveOutputStream(responseStream).use { zipStream -> + zipStream.setMethod(ZipArchiveOutputStream.DEFLATED) + zipStream.setLevel(Deflater.NO_COMPRESSION) + zipStream.setUseZip64(Zip64Mode.Always) + books.forEach { book -> + val file = FileSystemResource(book.path) + if (!file.exists()) { + logger.warn { "Book file not found, skipping archive entry: ${file.path}" } + return@forEach + } + + logger.debug { "Adding file to zip archive: ${file.path}" } + file.inputStream.use { + zipStream.putArchiveEntry(ZipArchiveEntry(file.filename)) + IOUtils.copyLarge(it, zipStream, ByteArray(8192)) + zipStream.closeArchiveEntry() + } + } + } + } + + return ResponseEntity + .ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("attachment") + .filename(seriesMetadataRepository.findById(seriesId).title + ".zip", UTF_8) + .build() + }, + ).contentType(MediaType.parseMediaType(ZIP.type)) + .body(streamingResponse) + } + + @Operation(summary = "Delete series files", description = "Delete all of the series' books files on disk.", tags = [OpenApiConfiguration.TagNames.SERIES]) + @DeleteMapping("v1/series/{seriesId}/file") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteSeriesFile( + @PathVariable seriesId: String, + ) { + taskEmitter.deleteSeries( + seriesId = seriesId, + priority = HIGHEST_PRIORITY, + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt new file mode 100644 index 000000000..d3d63d0cb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt @@ -0,0 +1,77 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.kobo.KepubConverter +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.web.WebServerEffectiveSettings +import org.gotson.komga.interfaces.api.rest.dto.SettingMultiSource +import org.gotson.komga.interfaces.api.rest.dto.SettingsDto +import org.gotson.komga.interfaces.api.rest.dto.SettingsUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.toDomain +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import kotlin.time.Duration.Companion.days + +@RestController +@RequestMapping(value = ["api/v1/settings"], produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = OpenApiConfiguration.TagNames.SERVER_SETTINGS) +class SettingsController( + private val komgaSettingsProvider: KomgaSettingsProvider, + @param:Value($$"${server.port:#{null}}") private val configServerPort: Int?, + @param:Value($$"${server.servlet.context-path:#{null}}") private val configServerContextPath: String?, + private val serverSettings: WebServerEffectiveSettings, + private val kepubConverter: KepubConverter, +) { + @GetMapping + @Operation(summary = "Retrieve server settings") + fun getServerSettings(): SettingsDto = + SettingsDto( + komgaSettingsProvider.deleteEmptyCollections, + komgaSettingsProvider.deleteEmptyReadLists, + komgaSettingsProvider.rememberMeDuration.inWholeDays, + komgaSettingsProvider.thumbnailSize.toDto(), + komgaSettingsProvider.taskPoolSize, + SettingMultiSource(configServerPort, komgaSettingsProvider.serverPort, serverSettings.effectiveServerPort), + SettingMultiSource(configServerContextPath, komgaSettingsProvider.serverContextPath, serverSettings.effectiveServletContextPath), + komgaSettingsProvider.koboProxy, + komgaSettingsProvider.koboPort, + SettingMultiSource(kepubConverter.kepubifyConfigurationPath, komgaSettingsProvider.kepubifyPath, kepubConverter.kepubifyPath?.toString()), + ) + + @PatchMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Update server settings", description = "You can omit fields you don't want to update") + fun updateServerSettings( + @Valid @RequestBody + @Parameter(description = "Fields to update. You can omit fields you don't want to update.") + newSettings: SettingsUpdateDto, + ) { + newSettings.deleteEmptyCollections?.let { komgaSettingsProvider.deleteEmptyCollections = it } + newSettings.deleteEmptyReadLists?.let { komgaSettingsProvider.deleteEmptyReadLists = it } + newSettings.rememberMeDurationDays?.let { komgaSettingsProvider.rememberMeDuration = it.days } + if (newSettings.renewRememberMeKey == true) komgaSettingsProvider.renewRememberMeKey() + newSettings.thumbnailSize?.let { komgaSettingsProvider.thumbnailSize = it.toDomain() } + newSettings.taskPoolSize?.let { komgaSettingsProvider.taskPoolSize = it } + + if (newSettings.isSet("serverPort")) komgaSettingsProvider.serverPort = newSettings.serverPort + if (newSettings.isSet("serverContextPath")) komgaSettingsProvider.serverContextPath = newSettings.serverContextPath + + newSettings.koboProxy?.let { komgaSettingsProvider.koboProxy = it } + if (newSettings.isSet("koboPort")) komgaSettingsProvider.koboPort = newSettings.koboPort + if (newSettings.isSet("kepubifyPath")) komgaSettingsProvider.kepubifyPath = newSettings.kepubifyPath + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt new file mode 100644 index 000000000..0cef96ffe --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt @@ -0,0 +1,38 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1/syncpoints", produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.SYNCPOINTS) +class SyncPointController( + private val syncPointRepository: SyncPointRepository, +) { + @DeleteMapping("me") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Delete all sync points", + description = "If an API Key ID is passed, deletes only the sync points associated with that API Key. Deleting sync points will allow a Kobo to sync from scratch upon the next sync.", + ) + fun deleteSyncPointsForCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(required = false, name = "key_id") keyIds: Collection?, + ) { + if (keyIds.isNullOrEmpty()) + syncPointRepository.deleteByUserId(principal.user.id) + else + syncPointRepository.deleteByUserIdAndApiKeyIds(principal.user.id, keyIds) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TaskController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TaskController.kt new file mode 100644 index 000000000..e790c5b5a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TaskController.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.application.tasks.TasksRepository +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +@Tag(name = OpenApiConfiguration.TagNames.TASKS) +class TaskController( + private val tasksRepository: TasksRepository, +) { + @DeleteMapping("api/v1/tasks") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Clear task queue", description = "Cancel all tasks queued") + fun emptyTaskQueue(): Int = tasksRepository.deleteAllWithoutOwner() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt new file mode 100644 index 000000000..0322d8afc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt @@ -0,0 +1,140 @@ +package org.gotson.komga.interfaces.api.rest + +import com.jakewharton.byteunits.BinaryByteUnit +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.gotson.komga.domain.model.CodedException +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.TransientBook +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.service.TransientBookLifecycle +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.api.rest.dto.PageDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.nio.file.NoSuchFileException +import java.time.LocalDateTime + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v1/transient-books", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = OpenApiConfiguration.TagNames.BOOK_IMPORT) +class TransientBooksController( + private val transientBookLifecycle: TransientBookLifecycle, + private val transientBookRepository: TransientBookRepository, + private val bookAnalyzer: BookAnalyzer, +) { + @PostMapping + @Operation(summary = "Scan folder for transient books", description = "Scan provided folder for transient books.") + fun scanTransientBooks( + @RequestBody request: ScanRequestDto, + ): List = + try { + transientBookLifecycle + .scanAndPersist(request.path) + .sortedBy { it.book.path } + .map { it.toDto() } + } catch (e: CodedException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code) + } + + @PostMapping("{id}/analyze") + @Operation(summary = "Analyze transient book") + fun analyzeTransientBook( + @PathVariable id: String, + ): TransientBookDto = + transientBookRepository.findByIdOrNull(id)?.let { + transientBookLifecycle.analyzeAndPersist(it).toDto() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping( + value = ["{id}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE], + ) + @Operation(summary = "Get transient book page") + fun getPageByTransientBookId( + @PathVariable id: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + transientBookRepository.findByIdOrNull(id)?.let { + try { + val pageContent = transientBookLifecycle.getBookPage(it, pageNumber) + + ResponseEntity + .ok() + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .body(pageContent.bytes) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found}" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + private fun TransientBook.toDto(): TransientBookDto { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + return TransientBookDto( + id = book.id, + name = book.name, + url = book.url.toFilePath(), + fileLastModified = book.fileLastModified, + sizeBytes = book.fileSize, + status = media.status.toString(), + mediaType = media.mediaType ?: "", + pages = + pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + sizeBytes = bookPage.fileSize, + ) + }, + files = media.files.map { it.fileName }, + comment = media.comment ?: "", + number = metadata.number, + seriesId = metadata.seriesId, + ) + } +} + +data class ScanRequestDto( + val path: String, +) + +data class TransientBookDto( + val id: String, + val name: String, + val url: String, + val fileLastModified: LocalDateTime, + val sizeBytes: Long, + val size: String = BinaryByteUnit.format(sizeBytes), + val status: String, + val mediaType: String, + val pages: List, + val files: List, + val comment: String, + val number: Float?, + val seriesId: String?, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt new file mode 100644 index 000000000..1b5be42d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt @@ -0,0 +1,318 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import jakarta.validation.Valid +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto +import org.gotson.komga.interfaces.api.rest.dto.ApiKeyDto +import org.gotson.komga.interfaces.api.rest.dto.ApiKeyRequestDto +import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto +import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto +import org.gotson.komga.interfaces.api.rest.dto.UserDto +import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.redacted +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.springdoc.core.converters.models.PageableAsQueryParam +import org.springframework.core.env.Environment +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("api/v2/users", produces = [MediaType.APPLICATION_JSON_VALUE]) +class UserController( + private val userLifecycle: KomgaUserLifecycle, + private val userRepository: KomgaUserRepository, + private val libraryRepository: LibraryRepository, + private val authenticationActivityRepository: AuthenticationActivityRepository, + env: Environment, +) { + private val demo = env.activeProfiles.contains("demo") + + @GetMapping("me") + @Operation(summary = "Retrieve current user", tags = [TagNames.CURRENT_USER]) + fun getCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "remember-me", required = false) rememberMe: Boolean?, + ): UserDto = principal.toDto() + + @PatchMapping("me/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Update current user's password", tags = [TagNames.CURRENT_USER]) + fun updatePasswordForCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody + newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByEmailIgnoreCaseOrNull(principal.username)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, false) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List users", tags = [TagNames.USERS]) + fun getUsers(): List = userRepository.findAll().map { it.toDto() } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create user", tags = [TagNames.USERS]) + fun addUser( + @Valid @RequestBody + newUser: UserCreationDto, + ): UserDto = + try { + userLifecycle + .createUser( + with(newUser) { + KomgaUser( + email, + password, + roles = UserRoles.Companion.valuesOf(roles), + // keep existing behaviour before those properties were added, by default new user has access to all libraries + sharedAllLibraries = sharedLibraries == null || sharedLibraries.all, + sharedLibrariesIds = + if (sharedLibraries == null || sharedLibraries.all) + emptySet() + else + libraryRepository.findAllByIds(sharedLibraries.libraryIds).map { it.id }.toSet(), + // keep existing behaviour before those properties were added, by default no restrictions are applied + restrictions = + ContentRestrictions( + ageRestriction = + ageRestriction.let { + if (it == null || it.restriction == AllowExcludeDto.NONE) + null + else + AgeRestriction(it.age, it.restriction.toDomain()) + }, + labelsAllow = labelsAllow ?: emptySet(), + labelsExclude = labelsExclude ?: emptySet(), + ), + ) + }, + ).toDto() + } catch (_: UserEmailAlreadyExistsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") + } + + @DeleteMapping("{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN') and #principal.user.id != #id") + @Operation(summary = "Delete user", tags = [TagNames.USERS]) + fun deleteUserById( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { + userLifecycle.deleteUser(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN') and #principal.user.id != #id") + @Operation(summary = "Update user", tags = [TagNames.USERS]) + fun updateUserById( + @PathVariable id: String, + @Valid @RequestBody + patch: UserUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { existing -> + val updatedUser = + with(patch) { + existing.copy( + roles = if (isSet("roles")) UserRoles.valuesOf(roles!!) else existing.roles, + sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries, + sharedLibrariesIds = + if (isSet("sharedLibraries")) { + if (sharedLibraries!!.all) + emptySet() + else + libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet() + } else { + existing.sharedLibrariesIds + }, + restrictions = + ContentRestrictions( + ageRestriction = + if (isSet("ageRestriction")) { + if (ageRestriction == null || ageRestriction?.restriction == AllowExcludeDto.NONE) + null + else + AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction.toDomain()) + } else { + existing.restrictions.ageRestriction + }, + labelsAllow = + if (isSet("labelsAllow")) + labelsAllow + ?: emptySet() + else + existing.restrictions.labelsAllow, + labelsExclude = + if (isSet("labelsExclude")) + labelsExclude + ?: emptySet() + else + existing.restrictions.labelsExclude, + ), + ) + } + userLifecycle.updateUser(updatedUser) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("{id}/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN') or #principal.user.id == #id") + @Operation(summary = "Update user's password", tags = [TagNames.USERS]) + fun updatePasswordByUserId( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody + newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByIdOrNull(id)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, user.id != principal.user.id) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping("me/authentication-activity") + @PageableAsQueryParam + @Operation(summary = "Retrieve authentication activity for the current user", tags = [TagNames.CURRENT_USER]) + fun getAuthenticationActivityForCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + if (demo && !principal.user.isAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN) + val sort = + if (page.sort.isSorted) + page.sort + else + Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() } + } + + @GetMapping("authentication-activity") + @PageableAsQueryParam + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Retrieve authentication activity", tags = [TagNames.USERS]) + fun getAuthenticationActivity( + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + if (page.sort.isSorted) + page.sort + else + Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) + UnpagedSorted(sort) + else + PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() } + } + + @GetMapping("{id}/authentication-activity/latest") + @PreAuthorize("hasRole('ADMIN') or #principal.user.id == #id") + @Operation(summary = "Retrieve latest authentication activity for a user", tags = [TagNames.USERS]) + fun getLatestAuthenticationActivityByUserId( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(required = false, name = "apikey_id") apiKeyId: String?, + ): AuthenticationActivityDto = + userRepository.findByIdOrNull(id)?.let { user -> + authenticationActivityRepository.findMostRecentByUser(user, apiKeyId)?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping("me/api-keys") + @Operation(summary = "Retrieve API keys", tags = [TagNames.API_KEYS]) + fun getApiKeysForCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): Collection { + if (demo && !principal.user.isAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN) + return userRepository.findApiKeyByUserId(principal.user.id).map { it.toDto().redacted() } + } + + @PostMapping("me/api-keys") + @Operation(summary = "Create API key", tags = [TagNames.API_KEYS]) + fun createApiKeyForCurrentUser( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody apiKeyRequest: ApiKeyRequestDto, + ): ApiKeyDto { + if (demo && !principal.user.isAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN) + return try { + userLifecycle.createApiKey(principal.user, apiKeyRequest.comment)?.toDto() + } catch (e: DuplicateNameException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code) + } + ?: throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Failed to generate API key") + } + + @DeleteMapping("me/api-keys/{keyId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete API key", tags = [TagNames.API_KEYS]) + fun deleteApiKeyByKeyId( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable keyId: String, + ) { + if (!userRepository.existsApiKeyByIdAndUserId(keyId, principal.user.id)) + throw ResponseStatusException(HttpStatus.NOT_FOUND) + userRepository.deleteApiKeyByIdAndUserId(keyId, principal.user.id) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleDto.kt new file mode 100644 index 000000000..4756e49db --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.AlternateTitle + +data class AlternateTitleDto( + val label: String, + val title: String, +) + +fun AlternateTitle.toDto() = AlternateTitleDto(label, title) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleUpdateDto.kt new file mode 100644 index 000000000..2f29900a8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AlternateTitleUpdateDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank + +class AlternateTitleUpdateDto { + @get:NotBlank + val label: String? = null + + @get:NotBlank + val title: String? = null +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt new file mode 100644 index 000000000..296416aa9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ApiKey +import org.gotson.komga.language.toUTCZoned +import java.time.ZonedDateTime + +data class ApiKeyDto( + val id: String, + val userId: String, + val key: String, + val comment: String, + val createdDate: ZonedDateTime, + val lastModifiedDate: ZonedDateTime, +) + +fun ApiKey.toDto() = + ApiKeyDto( + id = id, + userId = userId, + key = key, + comment = comment, + createdDate = createdDate.toUTCZoned(), + lastModifiedDate = createdDate.toUTCZoned(), + ) + +fun ApiKeyDto.redacted() = copy(key = "*".repeat(6)) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt new file mode 100644 index 000000000..d76db837d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank + +data class ApiKeyRequestDto( + @get:NotBlank + val comment: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt new file mode 100644 index 000000000..4f4906228 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gotson.komga.domain.model.AuthenticationActivity +import org.gotson.komga.language.toUTC +import java.time.LocalDateTime + +data class AuthenticationActivityDto( + val userId: String?, + val email: String?, + val apiKeyId: String? = null, + val apiKeyComment: String? = null, + val ip: String?, + val userAgent: String?, + val success: Boolean, + val error: String?, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val dateTime: LocalDateTime, + val source: String?, +) + +fun AuthenticationActivity.toDto() = + AuthenticationActivityDto( + userId = userId, + email = email, + apiKeyId = apiKeyId, + apiKeyComment = apiKeyComment, + ip = ip, + userAgent = userAgent, + success = success, + error = error, + dateTime = dateTime.toUTC(), + source = source, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthorDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthorDto.kt new file mode 100644 index 000000000..f91cf3773 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthorDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.Author + +data class AuthorDto( + val name: String, + val role: String, +) + +fun Author.toDto() = AuthorDto(name, role) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt new file mode 100644 index 000000000..1b451e701 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt @@ -0,0 +1,84 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.jakewharton.byteunits.BinaryByteUnit +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.MediaType +import java.time.LocalDate +import java.time.LocalDateTime + +data class BookDto( + val id: String, + val seriesId: String, + val seriesTitle: String, + val libraryId: String, + val name: String, + val url: String, + val number: Int, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val fileLastModified: LocalDateTime, + val sizeBytes: Long, + val size: String = BinaryByteUnit.format(sizeBytes), + val media: MediaDto, + val metadata: BookMetadataDto, + val readProgress: ReadProgressDto? = null, + val deleted: Boolean, + val fileHash: String, + val oneshot: Boolean, +) + +fun BookDto.restrictUrl(restrict: Boolean) = if (restrict) copy(url = FilenameUtils.getName(url)) else this + +data class MediaDto( + val status: String, + val mediaType: String, + val pagesCount: Int, + val comment: String, + val epubDivinaCompatible: Boolean, + val epubIsKepub: Boolean, +) { + val mediaProfile: String by lazy { MediaType.fromMediaType(mediaType)?.profile?.name ?: "" } +} + +data class BookMetadataDto( + val title: String, + val titleLock: Boolean, + val summary: String, + val summaryLock: Boolean, + val number: String, + val numberLock: Boolean, + val numberSort: Float, + val numberSortLock: Boolean, + @JsonFormat(pattern = "yyyy-MM-dd") + val releaseDate: LocalDate?, + val releaseDateLock: Boolean, + val authors: List, + val authorsLock: Boolean, + val tags: Set, + val tagsLock: Boolean, + val isbn: String, + val isbnLock: Boolean, + val links: List, + val linksLock: Boolean, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, +) + +data class ReadProgressDto( + val page: Int, + val completed: Boolean, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val readDate: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, + val deviceId: String, + val deviceName: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookImportBatchDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookImportBatchDto.kt new file mode 100644 index 000000000..53cf6b1d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookImportBatchDto.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.CopyMode + +data class BookImportBatchDto( + val books: List = emptyList(), + val copyMode: CopyMode, +) + +data class BookImportDto( + val sourceFile: String, + val seriesId: String, + val upgradeBookId: String? = null, + val destinationName: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt new file mode 100644 index 000000000..cc17ef2ac --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt @@ -0,0 +1,133 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.infrastructure.validation.NullOrBlankOrISBN +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import org.hibernate.validator.constraints.URL +import java.net.URI +import java.time.LocalDate +import kotlin.properties.Delegates + +class BookMetadataUpdateDto { + private val isSet = mutableMapOf() + + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + + @get:NullOrNotBlank + var title: String? = null + + var titleLock: Boolean? = null + + var summary: String? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var summaryLock: Boolean? = null + + @get:NullOrNotBlank + var number: String? = null + + var numberLock: Boolean? = null + + var numberSort: Float? = null + + var numberSortLock: Boolean? = null + + var releaseDate: LocalDate? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var releaseDateLock: Boolean? = null + + @get:Valid + var authors: List? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var authorsLock: Boolean? = null + + var tags: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var tagsLock: Boolean? = null + + @get:NullOrBlankOrISBN + var isbn: String? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var isbnLock: Boolean? = null + + @get:Valid + var links: List? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var linksLock: Boolean? = null +} + +class AuthorUpdateDto { + @get:NotBlank + val name: String? = null + + @get:NotBlank + val role: String? = null +} + +class WebLinkUpdateDto { + @get:NotBlank + val label: String? = null + + @get:URL + val url: String? = null +} + +fun BookMetadata.patch(patch: BookMetadataUpdateDto) = + patch.let { + this.copy( + title = patch.title ?: this.title, + titleLock = patch.titleLock ?: this.titleLock, + summary = if (patch.isSet("summary")) patch.summary ?: "" else this.summary, + summaryLock = patch.summaryLock ?: this.summaryLock, + number = patch.number ?: this.number, + numberLock = patch.numberLock ?: this.numberLock, + numberSort = patch.numberSort ?: this.numberSort, + numberSortLock = patch.numberSortLock ?: this.numberSortLock, + releaseDate = if (patch.isSet("releaseDate")) patch.releaseDate else this.releaseDate, + releaseDateLock = patch.releaseDateLock ?: this.releaseDateLock, + authors = + if (patch.isSet("authors")) { + if (patch.authors != null) patch.authors!!.map { Author(it.name ?: "", it.role ?: "") } else emptyList() + } else { + this.authors + }, + authorsLock = patch.authorsLock ?: this.authorsLock, + tags = + if (patch.isSet("tags")) { + if (patch.tags != null) patch.tags!! else emptySet() + } else { + this.tags + }, + tagsLock = patch.tagsLock ?: this.tagsLock, + isbn = if (patch.isSet("isbn")) patch.isbn?.filter { it.isDigit() } ?: "" else this.isbn, + isbnLock = patch.isbnLock ?: this.isbnLock, + links = + if (patch.isSet("links")) { + if (patch.links != null) patch.links!!.map { WebLink(it.label!!, URI(it.url!!)) } else emptyList() + } else { + this.links + }, + linksLock = patch.linksLock ?: this.linksLock, + ) + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ClientSettingDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ClientSettingDto.kt new file mode 100644 index 000000000..b227fca0b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ClientSettingDto.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import jakarta.validation.constraints.NotBlank + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ClientSettingDto( + val value: String, + val allowUnauthorized: Boolean?, +) + +data class ClientSettingGlobalUpdateDto( + @get:NotBlank + val value: String, + val allowUnauthorized: Boolean, +) + +data class ClientSettingUserUpdateDto( + @get:NotBlank + val value: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionCreationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionCreationDto.kt new file mode 100644 index 000000000..fd45748b4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionCreationDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import org.hibernate.validator.constraints.UniqueElements + +data class CollectionCreationDto( + @get:NotBlank val name: String, + val ordered: Boolean, + @get:NotEmpty @get:UniqueElements + val seriesIds: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionDto.kt new file mode 100644 index 000000000..c7ef16fe0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionDto.kt @@ -0,0 +1,29 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.language.toUTC +import java.time.LocalDateTime + +data class CollectionDto( + val id: String, + val name: String, + val ordered: Boolean, + val seriesIds: List, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val createdDate: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModifiedDate: LocalDateTime, + val filtered: Boolean, +) + +fun SeriesCollection.toDto() = + CollectionDto( + id = id, + name = name, + ordered = ordered, + seriesIds = seriesIds, + createdDate = createdDate.toUTC(), + lastModifiedDate = lastModifiedDate.toUTC(), + filtered = filtered, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionUpdateDto.kt new file mode 100644 index 000000000..dec0d2d43 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/CollectionUpdateDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import org.gotson.komga.infrastructure.validation.NullOrNotEmpty +import org.hibernate.validator.constraints.UniqueElements + +data class CollectionUpdateDto( + @get:NullOrNotBlank val name: String?, + val ordered: Boolean?, + @get:NullOrNotEmpty @get:UniqueElements + val seriesIds: List?, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GithubReleaseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GithubReleaseDto.kt new file mode 100644 index 000000000..e914f45e4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GithubReleaseDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class GithubReleaseDto( + val htmlUrl: String, + val tagName: String, + val publishedAt: ZonedDateTime, + val body: String, + val prerelease: Boolean, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GroupCountDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GroupCountDto.kt new file mode 100644 index 000000000..2fa2066c1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/GroupCountDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.api.rest.dto + +data class GroupCountDto( + val group: String, + val count: Int, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt new file mode 100644 index 000000000..9ded351c9 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/HistoricalEventDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import java.time.LocalDateTime + +data class HistoricalEventDto( + val id: String, + val type: String, + val timestamp: LocalDateTime, + val bookId: String?, + val seriesId: String?, + val properties: Map, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/JsonFeedDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/JsonFeedDto.kt new file mode 100644 index 000000000..bacd64aa7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/JsonFeedDto.kt @@ -0,0 +1,108 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.OffsetDateTime + +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema( + //language=JSON + example = + """ + { + "version": "https://jsonfeed.org/version/1", + "title": "Announcements", + "home_page_url": "https://komga.org/blog", + "description": "Latest Komga announcements", + "items": [ + { + "id": "https://komga.org/blog/ebook-drop2", + "url": "https://komga.org/blog/ebook-drop2", + "title": "eBook drop 2", + "summary": "Version 1.9.0 contains the second feature drop for Ebooks support.", + "content_html": "

A longer text…

", + "date_modified": "2023-12-15T00:00:00Z", + "author": { + "name": "gotson", + "url": "https://github.com/gotson" + }, + "tags": [ + "upgrade", + "komga" + ], + "_komga": { + "read": false + } + }, + { + "id": "https://komga.org/blog/ebook-support", + "url": "https://komga.org/blog/ebook-support", + "title": "eBook support", + "summary": "Version 1.8.0 is bringing a long awaited feature: proper eBook support!", + "content_html": "

A longer text…

", + "date_modified": "2023-11-29T00:00:00Z", + "author": { + "name": "gotson", + "url": "https://github.com/gotson" + }, + "tags": [ + "upgrade", + "komga" + ], + "_komga": { + "read": true + } + } + ] +} +""", +) +data class JsonFeedDto( + @Schema(description = "URL of the version of the format the feed uses", example = "https://jsonfeed.org/version/1") + val version: String, + @Schema(description = "Name of the feed", example = "Announcements") + val title: String, + @field:JsonProperty("home_page_url") + @Schema(description = "URL of the resource that the feed describes", example = "https://komga.org/blog") + val homePageUrl: String?, + @Schema(description = "Provides more detail on what the feed is about", example = "Latest Komga announcements") + val description: String?, + val items: List = emptyList(), +) { + data class ItemDto( + @Schema(description = "Unique for that item for that feed over time", example = "https://komga.org/blog/ebook-drop2") + val id: String, + @Schema(description = "URL of the resource described by the item", example = "https://komga.org/blog/ebook-drop2") + val url: String?, + @Schema(description = "Plain text title", example = "eBook drop 2") + val title: String?, + @Schema(description = "A plain text sentence or two describing the item", example = "Version 1.9.0 contains the second feature drop for Ebooks support.") + val summary: String?, + @field:JsonProperty("content_html") + @Schema(description = "HTML of the item", example = "

A longer text…

") + val contentHtml: String?, + @field:JsonProperty("date_modified") + @Schema(description = "Modification date in RFC 3339 format", example = "2023-12-15T00:00:00Z") + val dateModified: OffsetDateTime?, + @Schema(description = "Author of the item") + val author: ItemAuthorDto?, + @Schema(description = "Tags describing the item", examples = ["upgrade", "komga"]) + val tags: Set = emptySet(), + @field:JsonProperty("_komga") + @Schema(description = "Additional fields for the item") + val komgaExtension: KomgaExtensionDto?, + ) + + data class ItemAuthorDto( + @Schema(description = "Author's name", example = "gotson") + val name: String?, + @Schema(description = "URL of a site owned by the author", example = "https://github.com/gotson") + val url: String?, + ) + + data class KomgaExtensionDto( + @Schema(description = "Whether the current item has been marked read by the current user", example = "false") + val read: Boolean, + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt new file mode 100644 index 000000000..9309195c4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank + +data class LibraryCreationDto( + @get:NotBlank val name: String, + @get:NotBlank val root: String, + val importComicInfoBook: Boolean = true, + val importComicInfoSeries: Boolean = true, + val importComicInfoCollection: Boolean = true, + val importComicInfoReadList: Boolean = true, + val importComicInfoSeriesAppendVolume: Boolean = true, + val importEpubBook: Boolean = true, + val importEpubSeries: Boolean = true, + val importMylarSeries: Boolean = true, + val importLocalArtwork: Boolean = true, + val importBarcodeIsbn: Boolean = true, + val scanForceModifiedTime: Boolean = false, + val scanInterval: ScanIntervalDto = ScanIntervalDto.EVERY_6H, + val scanOnStartup: Boolean = false, + val scanCbx: Boolean = true, + val scanPdf: Boolean = true, + val scanEpub: Boolean = true, + val scanDirectoryExclusions: Set = emptySet(), + val repairExtensions: Boolean = false, + val convertToCbz: Boolean = false, + val emptyTrashAfterScan: Boolean = false, + val seriesCover: SeriesCoverDto = SeriesCoverDto.FIRST, + val hashFiles: Boolean = true, + val hashPages: Boolean = false, + val hashKoreader: Boolean = false, + val analyzeDimensions: Boolean = true, + val oneshotsDirectory: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt new file mode 100644 index 000000000..9f45e831d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt @@ -0,0 +1,71 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.Library +import org.gotson.komga.infrastructure.web.toFilePath + +data class LibraryDto( + val id: String, + val name: String, + val root: String, + val importComicInfoBook: Boolean, + val importComicInfoSeries: Boolean, + val importComicInfoCollection: Boolean, + val importComicInfoReadList: Boolean, + val importComicInfoSeriesAppendVolume: Boolean, + val importEpubBook: Boolean, + val importEpubSeries: Boolean, + val importMylarSeries: Boolean, + val importLocalArtwork: Boolean, + val importBarcodeIsbn: Boolean, + val scanForceModifiedTime: Boolean, + val scanInterval: ScanIntervalDto, + val scanOnStartup: Boolean, + val scanCbx: Boolean, + val scanPdf: Boolean, + val scanEpub: Boolean, + val scanDirectoryExclusions: Set, + val repairExtensions: Boolean, + val convertToCbz: Boolean, + val emptyTrashAfterScan: Boolean, + val seriesCover: SeriesCoverDto, + val hashFiles: Boolean, + val hashPages: Boolean, + val hashKoreader: Boolean, + val analyzeDimensions: Boolean, + val oneshotsDirectory: String?, + val unavailable: Boolean, +) + +fun Library.toDto(includeRoot: Boolean) = + LibraryDto( + id = id, + name = name, + root = if (includeRoot) this.root.toFilePath() else "", + importComicInfoBook = importComicInfoBook, + importComicInfoSeries = importComicInfoSeries, + importComicInfoCollection = importComicInfoCollection, + importComicInfoReadList = importComicInfoReadList, + importComicInfoSeriesAppendVolume = importComicInfoSeriesAppendVolume, + importEpubBook = importEpubBook, + importEpubSeries = importEpubSeries, + importMylarSeries = importMylarSeries, + importLocalArtwork = importLocalArtwork, + importBarcodeIsbn = importBarcodeIsbn, + scanForceModifiedTime = scanForceModifiedTime, + scanInterval = scanInterval.toDto(), + scanOnStartup = scanOnStartup, + scanCbx = scanCbx, + scanPdf = scanPdf, + scanEpub = scanEpub, + scanDirectoryExclusions = scanDirectoryExclusions, + repairExtensions = repairExtensions, + convertToCbz = convertToCbz, + emptyTrashAfterScan = emptyTrashAfterScan, + seriesCover = seriesCover.toDto(), + hashFiles = hashFiles, + hashPages = hashPages, + hashKoreader = hashKoreader, + analyzeDimensions = analyzeDimensions, + oneshotsDirectory = oneshotsDirectory, + unavailable = unavailableDate != null, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt new file mode 100644 index 000000000..54e4731e1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt @@ -0,0 +1,51 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import kotlin.properties.Delegates + +class LibraryUpdateDto { + private val isSet = mutableMapOf() + + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + + @get:NullOrNotBlank + val name: String? = null + + @get:NullOrNotBlank + val root: String? = null + + val importComicInfoBook: Boolean? = null + val importComicInfoSeries: Boolean? = null + val importComicInfoCollection: Boolean? = null + val importComicInfoReadList: Boolean? = null + val importComicInfoSeriesAppendVolume: Boolean? = null + val importEpubBook: Boolean? = null + val importEpubSeries: Boolean? = null + val importMylarSeries: Boolean? = null + val importLocalArtwork: Boolean? = null + val importBarcodeIsbn: Boolean? = null + + val scanForceModifiedTime: Boolean? = null + val scanInterval: ScanIntervalDto? = null + val scanOnStartup: Boolean? = null + val scanCbx: Boolean? = null + val scanPdf: Boolean? = null + val scanEpub: Boolean? = null + var scanDirectoryExclusions: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + val repairExtensions: Boolean? = null + val convertToCbz: Boolean? = null + val emptyTrashAfterScan: Boolean? = null + val seriesCover: SeriesCoverDto? = null + val hashFiles: Boolean? = null + val hashPages: Boolean? = null + val hashKoreader: Boolean? = null + val analyzeDimensions: Boolean? = null + var oneshotsDirectory: String? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageDto.kt new file mode 100644 index 000000000..856de0ccc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageDto.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.jakewharton.byteunits.BinaryByteUnit + +data class PageDto( + val number: Int, + val fileName: String, + val mediaType: String, + val width: Int?, + val height: Int?, + val sizeBytes: Long?, + val size: String = sizeBytes?.let { BinaryByteUnit.format(it) } ?: "", +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashCreationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashCreationDto.kt new file mode 100644 index 000000000..20a79d1d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashCreationDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank +import org.gotson.komga.domain.model.PageHashKnown + +data class PageHashCreationDto( + @get:NotBlank val hash: String, + val size: Long? = null, + val action: PageHashKnown.Action, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashKnownDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashKnownDto.kt new file mode 100644 index 000000000..6285bb791 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashKnownDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.language.toUTC +import java.time.LocalDateTime + +data class PageHashKnownDto( + val hash: String, + val size: Long?, + val action: PageHashKnown.Action, + val deleteCount: Int, + val matchCount: Int, + val created: LocalDateTime, + val lastModified: LocalDateTime, +) + +fun PageHashKnown.toDto() = + PageHashKnownDto( + hash = hash, + size = size, + action = action, + deleteCount = deleteCount, + matchCount = matchCount, + created = createdDate.toUTC(), + lastModified = lastModifiedDate.toUTC(), + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashMatchDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashMatchDto.kt new file mode 100644 index 000000000..451f58418 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashMatchDto.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.PageHashMatch +import org.gotson.komga.infrastructure.web.toFilePath + +data class PageHashMatchDto( + val bookId: String, + val url: String, + val pageNumber: Int, + val fileName: String, + val fileSize: Long, + val mediaType: String, +) + +fun PageHashMatch.toDto() = + PageHashMatchDto( + bookId = bookId, + url = url.toFilePath(), + pageNumber = pageNumber, + fileName = fileName, + fileSize = fileSize, + mediaType = mediaType, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashUnknownDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashUnknownDto.kt new file mode 100644 index 000000000..c3a779994 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PageHashUnknownDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.PageHashUnknown + +data class PageHashUnknownDto( + val hash: String, + val size: Long?, + val matchCount: Int, +) + +fun PageHashUnknown.toDto() = + PageHashUnknownDto( + hash = hash, + size = size, + matchCount = matchCount, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt new file mode 100644 index 000000000..e15c0d86b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank + +data class PasswordUpdateDto( + @get:NotBlank val password: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt new file mode 100644 index 000000000..263192304 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.R2Locator + +data class R2Positions( + val total: Int, + val positions: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListCreationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListCreationDto.kt new file mode 100644 index 000000000..bb3da2866 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListCreationDto.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import org.hibernate.validator.constraints.UniqueElements + +data class ReadListCreationDto( + @get:NotBlank val name: String, + val summary: String = "", + val ordered: Boolean = true, + @get:NotEmpty @get:UniqueElements + val bookIds: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListDto.kt new file mode 100644 index 000000000..59058a459 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListDto.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.language.toUTC +import java.time.LocalDateTime + +data class ReadListDto( + val id: String, + val name: String, + val summary: String, + val ordered: Boolean, + val bookIds: List, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val createdDate: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModifiedDate: LocalDateTime, + val filtered: Boolean, +) + +fun ReadList.toDto() = + ReadListDto( + id = id, + name = name, + summary = summary, + ordered = ordered, + bookIds = bookIds.values.toList(), + createdDate = createdDate.toUTC(), + lastModifiedDate = lastModifiedDate.toUTC(), + filtered = filtered, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListRequestMatchDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListRequestMatchDto.kt new file mode 100644 index 000000000..f8d55d182 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListRequestMatchDto.kt @@ -0,0 +1,67 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gotson.komga.domain.model.ReadListMatch +import org.gotson.komga.domain.model.ReadListRequestBook +import org.gotson.komga.domain.model.ReadListRequestMatch +import java.time.LocalDate + +data class ReadListRequestMatchDto( + val readListMatch: ReadListMatchDto, + val requests: Collection, + val errorCode: String = "", +) + +fun ReadListRequestMatch.toDto() = + ReadListRequestMatchDto( + readListMatch.toDto(), + requests.map { request -> + ReadListRequestBookMatchesDto( + request.request.toDto(), + request.matches.entries.map { (series, books) -> + ReadListRequestBookMatchDto(ReadListRequestBookMatchSeriesDto(series.id, series.title, series.releaseDate), books.map { ReadListRequestBookMatchBookDto(it.id, it.number, it.title) }) + }, + ) + }, + ) + +data class ReadListMatchDto( + val name: String, + val errorCode: String = "", +) + +fun ReadListMatch.toDto() = ReadListMatchDto(name, errorCode) + +data class ReadListRequestBookMatchesDto( + val request: ReadListRequestBookDto, + val matches: List, +) + +data class ReadListRequestBookDto( + val series: Set, + val number: String, +) + +fun ReadListRequestBook.toDto() = + ReadListRequestBookDto( + series = series, + number = number, + ) + +data class ReadListRequestBookMatchDto( + val series: ReadListRequestBookMatchSeriesDto, + val books: Collection, +) + +data class ReadListRequestBookMatchSeriesDto( + val seriesId: String, + val title: String, + @JsonFormat(pattern = "yyyy-MM-dd") + val releaseDate: LocalDate?, +) + +data class ReadListRequestBookMatchBookDto( + val bookId: String, + val number: String, + val title: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListUpdateDto.kt new file mode 100644 index 000000000..517daa31a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadListUpdateDto.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import org.gotson.komga.infrastructure.validation.NullOrNotEmpty +import org.hibernate.validator.constraints.UniqueElements + +data class ReadListUpdateDto( + @get:NullOrNotBlank val name: String?, + val summary: String?, + @get:NullOrNotEmpty @get:UniqueElements + val bookIds: List?, + val ordered: Boolean?, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadProgressUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadProgressUpdateDto.kt new file mode 100644 index 000000000..253387a52 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReadProgressUpdateDto.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.constraints.Positive +import kotlin.reflect.KClass + +@ReadProgressUpdateDtoConstraint +data class ReadProgressUpdateDto( + @get:Positive val page: Int?, + val completed: Boolean?, +) + +@Constraint(validatedBy = [ReadProgressUpdateDtoValidator::class]) +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ReadProgressUpdateDtoConstraint( + val message: String = "page must be specified if completed is false or null", + val groups: Array> = [], + val payload: Array> = [], +) + +class ReadProgressUpdateDtoValidator : ConstraintValidator { + override fun isValid( + value: ReadProgressUpdateDto?, + context: ConstraintValidatorContext?, + ): Boolean = + value != null && + ( + value.page != null || (value.completed != null && value.completed) + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReleaseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReleaseDto.kt new file mode 100644 index 000000000..5865cc252 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ReleaseDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import java.time.ZonedDateTime + +data class ReleaseDto( + val version: String, + val releaseDate: ZonedDateTime, + val url: String, + val latest: Boolean, + val preRelease: Boolean, + val description: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ScanIntervalDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ScanIntervalDto.kt new file mode 100644 index 000000000..eaf0c8e54 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ScanIntervalDto.kt @@ -0,0 +1,32 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.Library + +enum class ScanIntervalDto { + DISABLED, + HOURLY, + EVERY_6H, + EVERY_12H, + DAILY, + WEEKLY, +} + +fun Library.ScanInterval.toDto() = + when (this) { + Library.ScanInterval.DISABLED -> ScanIntervalDto.DISABLED + Library.ScanInterval.HOURLY -> ScanIntervalDto.HOURLY + Library.ScanInterval.EVERY_6H -> ScanIntervalDto.EVERY_6H + Library.ScanInterval.EVERY_12H -> ScanIntervalDto.EVERY_12H + Library.ScanInterval.DAILY -> ScanIntervalDto.DAILY + Library.ScanInterval.WEEKLY -> ScanIntervalDto.WEEKLY + } + +fun ScanIntervalDto.toDomain() = + when (this) { + ScanIntervalDto.DISABLED -> Library.ScanInterval.DISABLED + ScanIntervalDto.HOURLY -> Library.ScanInterval.HOURLY + ScanIntervalDto.EVERY_6H -> Library.ScanInterval.EVERY_6H + ScanIntervalDto.EVERY_12H -> Library.ScanInterval.EVERY_12H + ScanIntervalDto.DAILY -> Library.ScanInterval.DAILY + ScanIntervalDto.WEEKLY -> Library.ScanInterval.WEEKLY + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesCoverDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesCoverDto.kt new file mode 100644 index 000000000..ed8dadedd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesCoverDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.Library + +enum class SeriesCoverDto { + FIRST, + FIRST_UNREAD_OR_FIRST, + FIRST_UNREAD_OR_LAST, + LAST, +} + +fun Library.SeriesCover.toDto() = + when (this) { + Library.SeriesCover.FIRST -> SeriesCoverDto.FIRST + Library.SeriesCover.FIRST_UNREAD_OR_FIRST -> SeriesCoverDto.FIRST_UNREAD_OR_FIRST + Library.SeriesCover.FIRST_UNREAD_OR_LAST -> SeriesCoverDto.FIRST_UNREAD_OR_LAST + Library.SeriesCover.LAST -> SeriesCoverDto.LAST + } + +fun SeriesCoverDto.toDomain() = + when (this) { + SeriesCoverDto.FIRST -> Library.SeriesCover.FIRST + SeriesCoverDto.FIRST_UNREAD_OR_FIRST -> Library.SeriesCover.FIRST_UNREAD_OR_FIRST + SeriesCoverDto.FIRST_UNREAD_OR_LAST -> Library.SeriesCover.FIRST_UNREAD_OR_LAST + SeriesCoverDto.LAST -> Library.SeriesCover.LAST + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt new file mode 100644 index 000000000..1f3cdf125 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt @@ -0,0 +1,76 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import java.time.LocalDate +import java.time.LocalDateTime + +data class SeriesDto( + val id: String, + val libraryId: String, + val name: String, + val url: String, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val fileLastModified: LocalDateTime, + val booksCount: Int, + val booksReadCount: Int, + val booksUnreadCount: Int, + val booksInProgressCount: Int, + val metadata: SeriesMetadataDto, + val booksMetadata: BookMetadataAggregationDto, + val deleted: Boolean, + val oneshot: Boolean, +) + +fun SeriesDto.restrictUrl(restrict: Boolean) = if (restrict) copy(url = "") else this + +data class SeriesMetadataDto( + val status: String, + val statusLock: Boolean, + val title: String, + val titleLock: Boolean, + val titleSort: String, + val titleSortLock: Boolean, + val summary: String, + val summaryLock: Boolean, + val readingDirection: String, + val readingDirectionLock: Boolean, + val publisher: String, + val publisherLock: Boolean, + val ageRating: Int?, + val ageRatingLock: Boolean, + val language: String, + val languageLock: Boolean, + val genres: Set, + val genresLock: Boolean, + val tags: Set, + val tagsLock: Boolean, + val totalBookCount: Int?, + val totalBookCountLock: Boolean, + val sharingLabels: Set, + val sharingLabelsLock: Boolean, + val links: List, + val linksLock: Boolean, + val alternateTitles: List, + val alternateTitlesLock: Boolean, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, +) + +data class BookMetadataAggregationDto( + val authors: List = emptyList(), + val tags: Set = emptySet(), + @JsonFormat(pattern = "yyyy-MM-dd") + val releaseDate: LocalDate?, + val summary: String, + val summaryNumber: String, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val created: LocalDateTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + val lastModified: LocalDateTime, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesMetadataUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesMetadataUpdateDto.kt new file mode 100644 index 000000000..e8536ec3b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesMetadataUpdateDto.kt @@ -0,0 +1,102 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Valid +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.PositiveOrZero +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.infrastructure.validation.NullOrBlankOrBCP47 +import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import kotlin.properties.Delegates + +class SeriesMetadataUpdateDto { + private val isSet = mutableMapOf() + + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + + val status: SeriesMetadata.Status? = null + + val statusLock: Boolean? = null + + @get:NullOrNotBlank + val title: String? = null + + val titleLock: Boolean? = null + + @get:NullOrNotBlank + val titleSort: String? = null + + val titleSortLock: Boolean? = null + + var summary: String? = null + + var summaryLock: Boolean? = null + + var publisher: String? = null + + var publisherLock: Boolean? = null + + var readingDirection: SeriesMetadata.ReadingDirection? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var readingDirectionLock: Boolean? = null + + @get:PositiveOrZero + var ageRating: Int? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var ageRatingLock: Boolean? = null + + @get:NullOrBlankOrBCP47 + var language: String? = null + + var languageLock: Boolean? = null + + var genres: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var genresLock: Boolean? = null + + var tags: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var tagsLock: Boolean? = null + + @get:Positive + var totalBookCount: Int? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var totalBookCountLock: Boolean? = null + + var sharingLabels: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var sharingLabelsLock: Boolean? = null + + @get:Valid + var links: List? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var linksLock: Boolean? = null + + @get:Valid + var alternateTitles: List? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var alternateTitlesLock: Boolean? = null +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt new file mode 100644 index 000000000..1da5f8c0f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.interfaces.api.rest.dto + +data class SettingsDto( + val deleteEmptyCollections: Boolean, + val deleteEmptyReadLists: Boolean, + val rememberMeDurationDays: Long, + val thumbnailSize: ThumbnailSizeDto, + val taskPoolSize: Int, + val serverPort: SettingMultiSource, + val serverContextPath: SettingMultiSource, + val koboProxy: Boolean, + val koboPort: Int?, + val kepubifyPath: SettingMultiSource, +) + +data class SettingMultiSource( + val configurationSource: T?, + val databaseSource: T?, + val effectiveValue: T?, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt new file mode 100644 index 000000000..cd5f5b53f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Positive +import kotlin.properties.Delegates + +class SettingsUpdateDto { + private val isSet = mutableMapOf() + + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + + var deleteEmptyCollections: Boolean? = null + + var deleteEmptyReadLists: Boolean? = null + + @get:Positive + var rememberMeDurationDays: Long? = null + + var renewRememberMeKey: Boolean? = null + + var thumbnailSize: ThumbnailSizeDto? = null + + @get:Positive + var taskPoolSize: Int? = null + + @get:Positive + @get:Max(65535) + var serverPort: Int? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + @get:Pattern(regexp = "^/[\\w-/]*[a-zA-Z0-9]$") + var serverContextPath: String? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var koboProxy: Boolean? = null + + @get:Positive + @get:Max(65535) + var koboPort: Int? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + @Deprecated("Will be removed in a future version") + var kepubifyPath: String? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressDto.kt new file mode 100644 index 000000000..791d22168 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressDto.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.rest.dto + +data class TachiyomiReadProgressDto( + val booksCount: Int, + val booksReadCount: Int, + val booksUnreadCount: Int, + val booksInProgressCount: Int, + val lastReadContinuousIndex: Int, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressUpdateDto.kt new file mode 100644 index 000000000..f088007d7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressUpdateDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.PositiveOrZero + +data class TachiyomiReadProgressUpdateDto( + @get:PositiveOrZero val lastBookRead: Int, +) + +data class TachiyomiReadProgressUpdateV2Dto( + val lastBookNumberSortRead: Float, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressV2Dto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressV2Dto.kt new file mode 100644 index 000000000..684196248 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/TachiyomiReadProgressV2Dto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.rest.dto + +data class TachiyomiReadProgressV2Dto( + val booksCount: Int, + val booksReadCount: Int, + val booksUnreadCount: Int, + val booksInProgressCount: Int, + val lastReadContinuousNumberSort: Float, + val maxNumberSort: Float, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt new file mode 100644 index 000000000..dc35c3161 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailBook + +data class ThumbnailBookDto( + val id: String, + val bookId: String, + val type: String, + val selected: Boolean, + val mediaType: String, + val fileSize: Long, + val width: Int, + val height: Int, +) + +fun ThumbnailBook.toDto() = + ThumbnailBookDto( + id = id, + bookId = bookId, + type = type.toString(), + selected = selected, + mediaType = mediaType, + fileSize = fileSize, + width = dimension.width, + height = dimension.height, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt new file mode 100644 index 000000000..81aea23cb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailReadList + +data class ThumbnailReadListDto( + val id: String, + val readListId: String, + val type: String, + val selected: Boolean, + val mediaType: String, + val fileSize: Long, + val width: Int, + val height: Int, +) + +fun ThumbnailReadList.toDto() = + ThumbnailReadListDto( + id = id, + readListId = readListId, + type = type.toString(), + selected = selected, + mediaType = mediaType, + fileSize = fileSize, + width = dimension.width, + height = dimension.height, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt new file mode 100644 index 000000000..94e4f6ece --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailSeriesCollection + +data class ThumbnailSeriesCollectionDto( + val id: String, + val collectionId: String, + val type: String, + val selected: Boolean, + val mediaType: String, + val fileSize: Long, + val width: Int, + val height: Int, +) + +fun ThumbnailSeriesCollection.toDto() = + ThumbnailSeriesCollectionDto( + id = id, + collectionId = collectionId, + type = type.toString(), + selected = selected, + mediaType = mediaType, + fileSize = fileSize, + width = dimension.width, + height = dimension.height, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesDto.kt new file mode 100644 index 000000000..0475448f0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailSeries + +data class ThumbnailSeriesDto( + val id: String, + val seriesId: String, + val type: String, + val selected: Boolean, + val mediaType: String, + val fileSize: Long, + val width: Int, + val height: Int, +) + +fun ThumbnailSeries.toDto() = + ThumbnailSeriesDto( + id = id, + seriesId = seriesId, + type = type.toString(), + selected = selected, + mediaType = mediaType, + fileSize = fileSize, + width = dimension.width, + height = dimension.height, + ) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt new file mode 100644 index 000000000..1193fe257 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailSize + +enum class ThumbnailSizeDto { + DEFAULT, + MEDIUM, + LARGE, + XLARGE, +} + +fun ThumbnailSize.toDto() = + when (this) { + ThumbnailSize.DEFAULT -> ThumbnailSizeDto.DEFAULT + ThumbnailSize.MEDIUM -> ThumbnailSizeDto.MEDIUM + ThumbnailSize.LARGE -> ThumbnailSizeDto.LARGE + ThumbnailSize.XLARGE -> ThumbnailSizeDto.XLARGE + } + +fun ThumbnailSizeDto.toDomain() = + when (this) { + ThumbnailSizeDto.DEFAULT -> ThumbnailSize.DEFAULT + ThumbnailSizeDto.MEDIUM -> ThumbnailSize.MEDIUM + ThumbnailSizeDto.LARGE -> ThumbnailSize.LARGE + ThumbnailSizeDto.XLARGE -> ThumbnailSize.XLARGE + } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt new file mode 100644 index 000000000..99e6ae433 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class UserCreationDto( + @get:Email(regexp = ".+@.+\\..+") val email: String, + @get:NotBlank val password: String, + val roles: List = emptyList(), + // new fields supported + @get:Valid val ageRestriction: AgeRestrictionUpdateDto?, + val labelsAllow: Set?, + val labelsExclude: Set?, + val sharedLibraries: SharedLibrariesUpdateDto?, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt new file mode 100644 index 000000000..026f9776c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt @@ -0,0 +1,40 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.infrastructure.security.KomgaPrincipal + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class UserDto( + val id: String, + val email: String, + val roles: Set, + val sharedAllLibraries: Boolean, + val sharedLibrariesIds: Set, + val labelsAllow: Set, + val labelsExclude: Set, + val ageRestriction: AgeRestrictionDto?, +) + +data class AgeRestrictionDto( + val age: Int, + val restriction: AllowExclude, +) + +fun AgeRestriction.toDto() = AgeRestrictionDto(age, restriction) + +fun KomgaUser.toDto() = + UserDto( + id = id, + email = email, + roles = roles.map { it.name }.toSet() + "USER", + sharedAllLibraries = sharedAllLibraries, + sharedLibrariesIds = sharedLibrariesIds, + labelsAllow = restrictions.labelsAllow, + labelsExclude = restrictions.labelsExclude, + ageRestriction = restrictions.ageRestriction?.toDto(), + ) + +fun KomgaPrincipal.toDto() = user.toDto() diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt new file mode 100644 index 000000000..8c26b3a0a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt @@ -0,0 +1,63 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Valid +import jakarta.validation.constraints.PositiveOrZero +import org.gotson.komga.domain.model.AllowExclude +import kotlin.properties.Delegates + +class UserUpdateDto { + private val isSet = mutableMapOf() + + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + + @get:Valid + var ageRestriction: AgeRestrictionUpdateDto? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var labelsAllow: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var labelsExclude: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var roles: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var sharedLibraries: SharedLibrariesUpdateDto? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } +} + +data class AgeRestrictionUpdateDto( + @get:PositiveOrZero + val age: Int, + val restriction: AllowExcludeDto, +) + +data class SharedLibrariesUpdateDto( + val all: Boolean, + val libraryIds: Set, +) + +enum class AllowExcludeDto { + ALLOW_ONLY, + EXCLUDE, + NONE, + ; + + fun toDomain() = + when (this) { + ALLOW_ONLY -> AllowExclude.ALLOW_ONLY + EXCLUDE -> AllowExclude.EXCLUDE + NONE -> throw IllegalArgumentException() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/WebLinkDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/WebLinkDto.kt new file mode 100644 index 000000000..fb2c00f43 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/WebLinkDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.WebLink + +data class WebLinkDto( + val label: String, + val url: String, +) + +fun WebLink.toDto() = WebLinkDto(label, url.toString()) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/ListUsersRunner.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/ListUsersRunner.kt new file mode 100644 index 000000000..de5c86127 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/ListUsersRunner.kt @@ -0,0 +1,26 @@ +package org.gotson.komga.interfaces.apprunner + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Profile("!test") +@Component +class ListUsersRunner( + private val userRepository: KomgaUserRepository, +) : ApplicationRunner { + override fun run(args: ApplicationArguments) { + if (args.getOptionValues("list-users") != null) { + val emails = userRepository.findAll().map { it.email } + if (emails.isNotEmpty()) + logger.info { "Here is a list of all users: $emails" } + else + logger.info { "No users exist yet" } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/PasswordResetRunner.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/PasswordResetRunner.kt new file mode 100644 index 000000000..8e8ae643f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/apprunner/PasswordResetRunner.kt @@ -0,0 +1,42 @@ +package org.gotson.komga.interfaces.apprunner + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Profile("!test") +@Component +class PasswordResetRunner( + private val userRepository: KomgaUserRepository, + private val userLifecycle: KomgaUserLifecycle, +) : ApplicationRunner { + private val resetFor = "reset" + private val resetTo = "newpassword" + + override fun run(args: ApplicationArguments) { + val newPassword = args.getOptionValues(resetTo)?.firstOrNull() + val resetFor = args.getOptionValues(resetFor)?.toSet() ?: emptySet() + + if (resetFor.isEmpty() xor (newPassword == null)) + return logger.warn { "You need to specify both '--${this.resetFor}=user@domain.com' and '--$resetTo=YourNewPassword'" } + + if (resetFor.isEmpty()) return + + if (newPassword.isNullOrBlank()) + return logger.warn { "The new password must not be blank" } + + resetFor + .forEach { arg -> + userRepository.findByEmailIgnoreCaseOrNull(arg)?.let { user -> + logger.info { "Reset password for user: ${user.email}" } + userLifecycle.updatePassword(user, newPassword, true) + } ?: logger.warn { "User does not exist: $arg" } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/IndexController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/IndexController.kt new file mode 100644 index 000000000..610c7d757 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/IndexController.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.interfaces.mvc + +import jakarta.servlet.ServletContext +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping + +@Controller +class IndexController( + servletContext: ServletContext, +) { + private val baseUrl: String = "${servletContext.contextPath}/" + + @GetMapping("/") + fun index(model: Model): String { + model.addAttribute("baseUrl", baseUrl) + return "index" + } + + @GetMapping("/next") + fun indexNext(model: Model): String { + model.addAttribute("baseUrl", baseUrl) + return "index-next" + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundController.kt new file mode 100644 index 000000000..89ab0a2a3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundController.kt @@ -0,0 +1,21 @@ +package org.gotson.komga.interfaces.mvc + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.NoHandlerFoundException + +@Component +@ControllerAdvice +class ResourceNotFoundController { + val apis = listOf("/api", "/opds", "/sse") + + @ExceptionHandler(NoHandlerFoundException::class) + fun notFound(request: HttpServletRequest): String { + if (apis.any { request.requestURI.startsWith(it, true) }) throw ResponseStatusException(HttpStatus.NOT_FOUND) + return "forward:/" + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/AuthenticationActivityCleanupController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/AuthenticationActivityCleanupController.kt new file mode 100644 index 000000000..32ea0e27d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/AuthenticationActivityCleanupController.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.interfaces.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.springframework.context.annotation.Profile +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId + +private val logger = KotlinLogging.logger {} + +@Profile("!test") +@Component +class AuthenticationActivityCleanupController( + private val authenticationActivityRepository: AuthenticationActivityRepository, +) { + // Run every day + @Scheduled(fixedRate = 86_400_000) + fun cleanup() { + val olderThan = LocalDateTime.now(ZoneId.of("Z")).minusMonths(1) + logger.info { "Remove authentication activity older than $olderThan (UTC)" } + authenticationActivityRepository.deleteOlderThan(olderThan) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt new file mode 100644 index 000000000..73b88f1d6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt @@ -0,0 +1,56 @@ +package org.gotson.komga.interfaces.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Profile("!test & noclaim") +@Component +class InitialUserController( + private val userLifecycle: KomgaUserLifecycle, + private val initialUsers: List, +) { + @EventListener(ApplicationReadyEvent::class) + fun createInitialUserOnStartupIfNoneExist() { + if (userLifecycle.countUsers() == 0L) { + logger.info { "No users exist in database, creating initial users" } + + initialUsers + .forEach { + userLifecycle.createUser(it) + logger.info { "Initial user created. Login: ${it.email}, Password: ${it.password}" } + } + } + } +} + +@Configuration +@Profile("dev") +class InitialUsersDevConfiguration { + @Bean + fun initialUsers(): List = + listOf( + KomgaUser("admin@example.org", "admin", roles = UserRoles.entries.toSet()), + KomgaUser("user@example.org", "user"), + ) +} + +@Configuration +@Profile("!dev") +class InitialUsersProdConfiguration { + @Bean + fun initialUsers(): List = + listOf( + KomgaUser("admin@example.org", RandomStringUtils.secure().nextAlphanumeric(12), roles = UserRoles.entries.toSet()), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/MetricsPublisherController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/MetricsPublisherController.kt new file mode 100644 index 000000000..bd3782689 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/MetricsPublisherController.kt @@ -0,0 +1,124 @@ +package org.gotson.komga.interfaces.scheduler + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.MultiGauge +import io.micrometer.core.instrument.MultiGauge.Row +import io.micrometer.core.instrument.Tags +import io.micrometer.core.instrument.Timer +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SidecarRepository +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Profile +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.util.concurrent.atomic.AtomicLong + +private const val LIBRARIES = "libraries" +private const val SERIES = "series" +private const val BOOKS = "books" +private const val BOOKS_FILESIZE = "books.filesize" +private const val COLLECTIONS = "collections" +private const val READLISTS = "readlists" +private const val SIDECARS = "sidecars" + +const val METER_TASKS_EXECUTION = "komga.tasks.execution" +const val METER_TASKS_FAILURE = "komga.tasks.failure" + +@Profile("!test") +@Component +class MetricsPublisherController( + private val libraryRepository: LibraryRepository, + private val bookRepository: BookRepository, + private val seriesRepository: SeriesRepository, + private val collectionRepository: SeriesCollectionRepository, + private val readListRepository: ReadListRepository, + private val sidecarRepository: SidecarRepository, + private val meterRegistry: MeterRegistry, +) { + init { + Timer + .builder(METER_TASKS_EXECUTION) + .description("Task execution time") + .register(meterRegistry) + + Counter + .builder(METER_TASKS_FAILURE) + .description("Count of failed tasks") + .register(meterRegistry) + } + + private final val entitiesMultiTag = listOf(SERIES, BOOKS, BOOKS_FILESIZE, SIDECARS) + private final val entitiesNoTags = listOf(LIBRARIES, COLLECTIONS, READLISTS) + private final val allEntities = entitiesMultiTag + entitiesNoTags + + val multiGauges = + entitiesMultiTag.associateWith { entity -> + MultiGauge + .builder("komga.$entity") + .description("The number of $entity") + .baseUnit("count") + .register(meterRegistry) + } + + val noTagGauges = + entitiesNoTags.associateWith { entity -> + AtomicLong(0).also { value -> + Gauge + .builder("komga.$entity", value) { value.get().toDouble() } + .description("The number of $entity") + .baseUnit("count") + .register(meterRegistry) + } + } + + val bookFileSizeGauge = + MultiGauge + .builder("komga.$BOOKS_FILESIZE") + .description("The cumulated filesize of books") + .baseUnit("bytes") + .register(meterRegistry) + + @EventListener + private fun pushMetricsOnEvent(event: DomainEvent) { + when (event) { + is DomainEvent.LibraryScanned -> entitiesMultiTag.forEach { pushMetricsCount(it) } + + is DomainEvent.LibraryAdded -> noTagGauges[LIBRARIES]?.incrementAndGet() + is DomainEvent.LibraryDeleted -> { + noTagGauges[LIBRARIES]?.decrementAndGet() + entitiesMultiTag.forEach { pushMetricsCount(it) } + } + is DomainEvent.CollectionAdded -> noTagGauges[COLLECTIONS]?.incrementAndGet() + is DomainEvent.CollectionDeleted -> noTagGauges[COLLECTIONS]?.decrementAndGet() + is DomainEvent.ReadListAdded -> noTagGauges[READLISTS]?.incrementAndGet() + is DomainEvent.ReadListDeleted -> noTagGauges[READLISTS]?.decrementAndGet() + + else -> Unit + } + } + + @EventListener(ApplicationReadyEvent::class) + fun pushAllMetrics() { + allEntities.forEach { pushMetricsCount(it) } + } + + private fun pushMetricsCount(entity: String) { + when (entity) { + LIBRARIES -> noTagGauges[LIBRARIES]?.set(libraryRepository.count()) + COLLECTIONS -> noTagGauges[COLLECTIONS]?.set(collectionRepository.count()) + READLISTS -> noTagGauges[READLISTS]?.set(readListRepository.count()) + + SERIES -> multiGauges[SERIES]?.register(seriesRepository.countGroupedByLibraryId().map { Row.of(Tags.of("library", it.key), it.value) }, true) + BOOKS -> multiGauges[BOOKS]?.register(bookRepository.countGroupedByLibraryId().map { Row.of(Tags.of("library", it.key), it.value) }, true) + BOOKS_FILESIZE -> bookFileSizeGauge.register(bookRepository.getFilesizeGroupedByLibraryId().map { Row.of(Tags.of("library", it.key), it.value) }, true) + SIDECARS -> multiGauges[SIDECARS]?.register(sidecarRepository.countGroupedByLibraryId().map { Row.of(Tags.of("library", it.key), it.value) }, true) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/PeriodicScannerController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/PeriodicScannerController.kt new file mode 100644 index 000000000..14b46b05a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/PeriodicScannerController.kt @@ -0,0 +1,38 @@ +package org.gotson.komga.interfaces.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.scheduler.LibraryScanScheduler +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.persistence.LibraryRepository +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Profile +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Profile("!test") +@Component +class PeriodicScannerController( + private val taskEmitter: TaskEmitter, + private val libraryRepository: LibraryRepository, + private val libraryScanScheduler: LibraryScanScheduler, +) { + @EventListener(classes = [ApplicationReadyEvent::class]) + fun scanOnStartup() { + libraryRepository + .findAll() + .filter { it.scanOnStartup } + .forEach { + logger.info { "Scan on startup for library: ${it.name}" } + taskEmitter.scanLibrary(it.id) + } + } + + @EventListener(classes = [ApplicationReadyEvent::class]) + fun scheduleScans() { + libraryRepository + .findAll() + .forEach { libraryScanScheduler.scheduleScan(it) } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/SearchIndexController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/SearchIndexController.kt new file mode 100644 index 000000000..d14b95ff1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/SearchIndexController.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.interfaces.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.HIGHEST_PRIORITY +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.infrastructure.search.LuceneEntity +import org.gotson.komga.infrastructure.search.LuceneHelper +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Profile +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Profile("!test") +@Component +class SearchIndexController( + private val luceneHelper: LuceneHelper, + private val taskEmitter: TaskEmitter, +) { + @EventListener(ApplicationReadyEvent::class) + fun createIndexIfNoneExist() { + if (!luceneHelper.indexExists()) { + logger.info { "Lucene index not found, trigger rebuild" } + taskEmitter.rebuildIndex(HIGHEST_PRIORITY) + } else { + val indexVersion = luceneHelper.getIndexVersion() + logger.info { "Lucene index version: $indexVersion" } + when { + indexVersion < 6 -> { + taskEmitter.upgradeIndex(HIGHEST_PRIORITY) // upgrade index to Lucene 9.x + taskEmitter.rebuildIndex(HIGHEST_PRIORITY, setOf(LuceneEntity.Series)) + } + + indexVersion < 8 -> taskEmitter.rebuildIndex(HIGHEST_PRIORITY, setOf(LuceneEntity.Series)) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt new file mode 100644 index 000000000..4ddc57f9e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt @@ -0,0 +1,162 @@ +package org.gotson.komga.interfaces.sse + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.application.tasks.TasksRepository +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.sse.dto.BookImportSseDto +import org.gotson.komga.interfaces.sse.dto.BookSseDto +import org.gotson.komga.interfaces.sse.dto.CollectionSseDto +import org.gotson.komga.interfaces.sse.dto.LibrarySseDto +import org.gotson.komga.interfaces.sse.dto.ReadListSseDto +import org.gotson.komga.interfaces.sse.dto.ReadProgressSeriesSseDto +import org.gotson.komga.interfaces.sse.dto.ReadProgressSseDto +import org.gotson.komga.interfaces.sse.dto.SeriesSseDto +import org.gotson.komga.interfaces.sse.dto.SessionExpiredDto +import org.gotson.komga.interfaces.sse.dto.TaskQueueSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailBookSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailReadListSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailSeriesCollectionSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailSeriesSseDto +import org.springframework.context.SmartLifecycle +import org.springframework.context.event.EventListener +import org.springframework.http.MediaType +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.io.IOException +import java.util.Collections + +private val logger = KotlinLogging.logger {} + +@Controller +class SseController( + private val bookRepository: BookRepository, + private val tasksRepository: TasksRepository, +) : SmartLifecycle { + private var acceptingConnections = true + private val emitters = Collections.synchronizedMap(HashMap()) + + @GetMapping("sse/v1/events") + fun sse( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): SseEmitter { + if (!acceptingConnections) throw IllegalStateException("Server is shutting down, not accepting new SSE connections") + val emitter = SseEmitter() + emitter.onCompletion { synchronized(emitters) { emitters.remove(emitter) } } + emitter.onTimeout { synchronized(emitters) { emitters.remove(emitter) } } + emitter.onError { synchronized(emitters) { emitters.remove(emitter) } } + emitters[emitter] = principal.user + return emitter + } + + @Scheduled(fixedRate = 15_000) + fun heartbeat() { + if (emitters.isNotEmpty()) + synchronized(emitters) { + emitters.forEach { (emitter, _) -> + try { + emitter.send(SseEmitter.event().comment("heartbeat")) + } catch (_: IOException) { + } + } + } + } + + @Scheduled(fixedRate = 10_000) + fun taskCount() { + if (emitters.isNotEmpty()) { + val tasksCount = tasksRepository.countBySimpleType() + emitSse("TaskQueueStatus", TaskQueueSseDto(tasksCount.values.sum(), tasksCount), adminOnly = true) + } + } + + @EventListener + fun handleSseEvent(event: DomainEvent) { + when (event) { + is DomainEvent.LibraryAdded -> emitSse("LibraryAdded", LibrarySseDto(event.library.id)) + is DomainEvent.LibraryUpdated -> emitSse("LibraryChanged", LibrarySseDto(event.library.id)) + is DomainEvent.LibraryDeleted -> emitSse("LibraryDeleted", LibrarySseDto(event.library.id)) + is DomainEvent.LibraryScanned -> Unit + + is DomainEvent.SeriesAdded -> emitSse("SeriesAdded", SeriesSseDto(event.series.id, event.series.libraryId)) + is DomainEvent.SeriesUpdated -> emitSse("SeriesChanged", SeriesSseDto(event.series.id, event.series.libraryId)) + is DomainEvent.SeriesDeleted -> emitSse("SeriesDeleted", SeriesSseDto(event.series.id, event.series.libraryId)) + + is DomainEvent.BookAdded -> emitSse("BookAdded", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId)) + is DomainEvent.BookUpdated -> emitSse("BookChanged", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId)) + is DomainEvent.BookDeleted -> emitSse("BookDeleted", BookSseDto(event.book.id, event.book.seriesId, event.book.libraryId)) + is DomainEvent.BookImported -> emitSse("BookImported", BookImportSseDto(event.book?.id, event.sourceFile.toFilePath(), event.success, event.message), adminOnly = true) + + is DomainEvent.ReadListAdded -> emitSse("ReadListAdded", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value })) + is DomainEvent.ReadListUpdated -> emitSse("ReadListChanged", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value })) + is DomainEvent.ReadListDeleted -> emitSse("ReadListDeleted", ReadListSseDto(event.readList.id, event.readList.bookIds.map { it.value })) + + is DomainEvent.CollectionAdded -> emitSse("CollectionAdded", CollectionSseDto(event.collection.id, event.collection.seriesIds)) + is DomainEvent.CollectionUpdated -> emitSse("CollectionChanged", CollectionSseDto(event.collection.id, event.collection.seriesIds)) + is DomainEvent.CollectionDeleted -> emitSse("CollectionDeleted", CollectionSseDto(event.collection.id, event.collection.seriesIds)) + + is DomainEvent.ReadProgressChanged -> emitSse("ReadProgressChanged", ReadProgressSseDto(event.progress.bookId, event.progress.userId), userIdOnly = event.progress.userId) + is DomainEvent.ReadProgressDeleted -> emitSse("ReadProgressDeleted", ReadProgressSseDto(event.progress.bookId, event.progress.userId), userIdOnly = event.progress.userId) + is DomainEvent.ReadProgressSeriesChanged -> emitSse("ReadProgressSeriesChanged", ReadProgressSeriesSseDto(event.seriesId, event.userId), userIdOnly = event.userId) + is DomainEvent.ReadProgressSeriesDeleted -> emitSse("ReadProgressSeriesDeleted", ReadProgressSeriesSseDto(event.seriesId, event.userId), userIdOnly = event.userId) + + is DomainEvent.ThumbnailBookAdded -> emitSse("ThumbnailBookAdded", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty(), event.thumbnail.selected)) + is DomainEvent.ThumbnailBookDeleted -> emitSse("ThumbnailBookDeleted", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty(), event.thumbnail.selected)) + is DomainEvent.ThumbnailSeriesAdded -> emitSse("ThumbnailSeriesAdded", ThumbnailSeriesSseDto(event.thumbnail.seriesId, event.thumbnail.selected)) + is DomainEvent.ThumbnailSeriesDeleted -> emitSse("ThumbnailSeriesDeleted", ThumbnailSeriesSseDto(event.thumbnail.seriesId, event.thumbnail.selected)) + is DomainEvent.ThumbnailSeriesCollectionAdded -> emitSse("ThumbnailSeriesCollectionAdded", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId, event.thumbnail.selected)) + is DomainEvent.ThumbnailSeriesCollectionDeleted -> emitSse("ThumbnailSeriesCollectionDeleted", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId, event.thumbnail.selected)) + is DomainEvent.ThumbnailReadListAdded -> emitSse("ThumbnailReadListAdded", ThumbnailReadListSseDto(event.thumbnail.readListId, event.thumbnail.selected)) + is DomainEvent.ThumbnailReadListDeleted -> emitSse("ThumbnailReadListDeleted", ThumbnailReadListSseDto(event.thumbnail.readListId, event.thumbnail.selected)) + + is DomainEvent.UserUpdated -> if (event.expireSession) emitSse("SessionExpired", SessionExpiredDto(event.user.id), userIdOnly = event.user.id) + is DomainEvent.UserDeleted -> emitSse("SessionExpired", SessionExpiredDto(event.user.id), userIdOnly = event.user.id) + } + } + + private fun emitSse( + name: String, + data: Any, + adminOnly: Boolean = false, + userIdOnly: String? = null, + ) { + logger.debug { "Publish SSE: '$name':$data" } + + synchronized(emitters) { + emitters + .filter { if (adminOnly) it.value.isAdmin else true } + .filter { if (userIdOnly != null) it.value.id == userIdOnly else true } + .forEach { (emitter, _) -> + try { + emitter.send( + SseEmitter + .event() + .name(name) + .data(data, MediaType.APPLICATION_JSON), + ) + } catch (_: IOException) { + } + } + } + } + + override fun start() = Unit + + override fun stop() { + logger.debug { "Closing all SSE connections" } + acceptingConnections = false + synchronized(emitters) { + emitters.forEach { (emitter, _) -> emitter.complete() } + } + } + + override fun isRunning(): Boolean = true + + override fun getPhase(): Int = SmartLifecycle.DEFAULT_PHASE +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookImportSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookImportSseDto.kt new file mode 100644 index 000000000..d37a95474 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookImportSseDto.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.sse.dto + +data class BookImportSseDto( + val bookId: String?, + val sourceFile: String, + val success: Boolean, + val message: String? = null, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookSseDto.kt new file mode 100644 index 000000000..834d05fea --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/BookSseDto.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.interfaces.sse.dto + +data class BookSseDto( + val bookId: String, + val seriesId: String, + val libraryId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/CollectionSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/CollectionSseDto.kt new file mode 100644 index 000000000..b3d6db3cb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/CollectionSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class CollectionSseDto( + val collectionId: String, + val seriesIds: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/LibrarySseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/LibrarySseDto.kt new file mode 100644 index 000000000..afc3aa0ab --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/LibrarySseDto.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.sse.dto + +data class LibrarySseDto( + val libraryId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadListSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadListSseDto.kt new file mode 100644 index 000000000..788b245fb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadListSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ReadListSseDto( + val readListId: String, + val bookIds: List, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSeriesSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSeriesSseDto.kt new file mode 100644 index 000000000..b0a5642b1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSeriesSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ReadProgressSeriesSseDto( + val seriesId: String, + val userId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSseDto.kt new file mode 100644 index 000000000..023c75781 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ReadProgressSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ReadProgressSseDto( + val bookId: String, + val userId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SeriesSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SeriesSseDto.kt new file mode 100644 index 000000000..63620302d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SeriesSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class SeriesSseDto( + val seriesId: String, + val libraryId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SessionExpiredDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SessionExpiredDto.kt new file mode 100644 index 000000000..a879669f2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/SessionExpiredDto.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.sse.dto + +data class SessionExpiredDto( + val userId: String, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/TaskQueueSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/TaskQueueSseDto.kt new file mode 100644 index 000000000..07dd6258b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/TaskQueueSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class TaskQueueSseDto( + val count: Int, + val countByType: Map, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailBookSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailBookSseDto.kt new file mode 100644 index 000000000..ba0a20715 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailBookSseDto.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailBookSseDto( + val bookId: String, + val seriesId: String, + val selected: Boolean, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt new file mode 100644 index 000000000..83f0b8921 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailReadListSseDto( + val readListId: String, + val selected: Boolean, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt new file mode 100644 index 000000000..74e111ce1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailSeriesCollectionSseDto( + val collectionId: String, + val selected: Boolean, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesSseDto.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesSseDto.kt new file mode 100644 index 000000000..aca2c7e1e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesSseDto.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailSeriesSseDto( + val seriesId: String, + val selected: Boolean, +) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt new file mode 100644 index 000000000..15587220b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt @@ -0,0 +1,78 @@ +package org.gotson.komga.language + +import org.apache.commons.lang3.StringUtils +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalUnit +import java.util.Date +import java.util.Enumeration +import java.util.SortedMap + +fun List.toIndexedMap(): SortedMap = mapIndexed { i, e -> i to e }.toMap().toSortedMap() + +fun List.toEnumeration(): Enumeration { + return object : Enumeration { + var count = 0 + + override fun hasMoreElements(): Boolean = this.count < size + + override fun nextElement(): T { + if (this.count < size) { + return get(this.count++) + } + throw NoSuchElementException("List enumeration asked for more elements than present") + } + } +} + +fun Iterable.mostFrequent(transform: (T) -> R?): R? = + this + .mapNotNull(transform) + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + +fun Iterable.lowerNotBlank() = this.map { it.lowercase().trim() }.filter { it.isNotBlank() } + +fun LocalDateTime.notEquals( + other: LocalDateTime, + precision: TemporalUnit = ChronoUnit.MILLIS, +) = this.truncatedTo(precision) != other.truncatedTo(precision) + +/** + * Warning: This affects the Unicode code points of Korean Hangul. + */ +fun String.stripAccents(): String = StringUtils.stripAccents(this) + +fun LocalDate.toDate(): Date = Date.from(this.atStartOfDay(ZoneId.of("Z")).toInstant()) + +/** + * Converts a LocalDateTime (current timezone) to a LocalDateTime (UTC) + * Warning: this is not idempotent + */ +fun LocalDateTime.toUTC(): LocalDateTime = atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() + +/** + * Converts a LocalDateTime (current timezone) to a ZonedDateTime + */ +fun LocalDateTime.toUTCZoned(): ZonedDateTime = atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC) + +/** + * Converts a LocalDateTime (UTC) to a ZonedDateTime + */ +fun LocalDateTime.toZonedDateTime(): ZonedDateTime = this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault()) + +/** + * Converts a LocalDateTime (UTC) to a LocalDateTime (current timezone) + */ +fun LocalDateTime.toCurrentTimeZone(): LocalDateTime = this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + +fun Iterable.contains( + s: String, + ignoreCase: Boolean = false, +): Boolean = any { it.equals(s, ignoreCase) } diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-dev.yml b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-dev.yml new file mode 100644 index 000000000..dbb6b6324 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-dev.yml @@ -0,0 +1,39 @@ +komga: + database: + file: "file:database?mode=memory" + tasks-db: + file: "file:tasks?mode=memory" + cors.allowed-origins: + - http://localhost:8081 + - http://localhost:3000 + - http://localhost:8085 + - http://localhost:4173 + - https://komga.org + oauth2-account-creation: false + config-dir: ${rootDir}/config-dir +logging: + level: + org.apache.activemq.audit.message: WARN + org.gotson.komga: DEBUG +# org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG +# reactor.netty.http.client: DEBUG +# org.jooq: DEBUG +# org.jooq.tools.LoggerListener: DEBUG +# web: DEBUG +# com.zaxxer.hikari: DEBUG +# org.springframework.boot.autoconfigure: DEBUG +# org.springframework.security.web.FilterChainProxy: DEBUG +# org.springframework.security.web.authentication.rememberme: DEBUG +# org.springframework.boot.context.config.ConfigDataEnvironment: TRACE +# org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor: TRACE + logback: + rollingpolicy: + max-history: 1 +springdoc: + cache: + disabled: true + +server: + port: 8080 +# servlet: +# context-path: /komga diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-docker.yml b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-docker.yml new file mode 100644 index 000000000..ffac7ce98 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-docker.yml @@ -0,0 +1,2 @@ +komga: + kobo.kepubify-path: /usr/bin/kepubify diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-localdb.yml b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-localdb.yml new file mode 100644 index 000000000..2b03572aa --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application-localdb.yml @@ -0,0 +1,8 @@ +komga: + workspace: localdb + database: + file: \${komga.config-dir}/\${komga.workspace}.sqlite + lucene: + data-directory: \${komga.config-dir}/lucene/\${komga.workspace} + tasks-db: + file: \${komga.config-dir}/\${komga.workspace}-tasks.sqlite diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application.yml b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application.yml new file mode 100644 index 000000000..5188b0650 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/application.yml @@ -0,0 +1,103 @@ +application.version: ${version} + +logging: + logback: + rollingpolicy: + max-history: 7 + total-size-cap: 1GB + clean-history-on-start: true + max-file-size: 10MB + file: + name: \${komga.config-dir}/logs/komga.log + level: + org.apache.activemq.audit: WARN + org.apache.fontbox.cff.Type1CharString: ERROR + org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: ERROR + +komga: + database: + file: \${komga.config-dir}/database.sqlite + lucene: + data-directory: \${komga.config-dir}/lucene + fonts: + data-directory: \${komga.config-dir}/fonts + config-dir: \${user.home}/.komga + tasks-db: + file: \${komga.config-dir}/tasks.sqlite + +spring: + flyway: + enabled: true + locations: classpath:db/migration/{vendor} + mixed: true + placeholders: + library-file-hashing: \${komga.file-hashing:true} + library-scan-startup: \${komga.libraries-scan-startup:false} + delete-empty-collections: \${komga.delete-empty-collections:true} + delete-empty-read-lists: \${komga.delete-empty-read-lists:true} + thymeleaf: + prefix: classpath:/public/ + mvc: + async: + request-timeout: 1h + web: + resources: + add-mappings: false + jackson: + deserialization: + FAIL_ON_NULL_FOR_PRIMITIVES: true + mapper: + accept-case-insensitive-properties: true + accept-case-insensitive-values: true + config: + import: + - "optional:file:\${komga.config-dir}/application.yml" + - "optional:file:\${komga.config-dir}/application.yaml" + - "optional:file:\${komga.config-dir}/application.properties" + http: + codecs: + max-in-memory-size: 10MB + +server: + servlet.session.timeout: 7d + forward-headers-strategy: framework + shutdown: graceful + error: + include-message: always + port: 25600 + tomcat: + relaxed-query-chars: + - '{' + - '}' + - '[' + - ']' + +management: + endpoints.web.exposure.include: "*" + endpoint: + configprops: + roles: ADMIN + show-values: when_authorized + env: + roles: ADMIN + show-values: when_authorized + health: + roles: ADMIN + show-details: when_authorized + shutdown: + access: unrestricted + info: + java: + enabled: true + os: + enabled: true + simple: + metrics: + export: + enabled: true + step: 24h +springdoc: + swagger-ui: + disable-swagger-default-url: true + paths-to-match: "/api/**" + writer-with-order-by-keys: true diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/banner.txt b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/banner.txt new file mode 100644 index 000000000..771650d34 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/banner.txt @@ -0,0 +1,8 @@ + ____ __. +| |/ _|____ _____ _________ +| < / _ \ / \ / ___\__ \ +| | ( <_> ) Y Y \/ /_/ > __ \_ +|____|__ \____/|__|_| /\___ (____ / + \/ \//_____/ \/ + +Version: ${application.version} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff new file mode 100644 index 000000000..3948189b4 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 new file mode 100644 index 000000000..aa7bcdea9 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff new file mode 100644 index 000000000..41886ae9d Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 new file mode 100644 index 000000000..2f04ad119 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff new file mode 100644 index 000000000..a23797c40 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 new file mode 100644 index 000000000..00c19082d Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff new file mode 100644 index 000000000..26a2934d6 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 new file mode 100644 index 000000000..47e26d82a Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/AutowiringTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/AutowiringTest.kt new file mode 100644 index 000000000..e888ec3d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/AutowiringTest.kt @@ -0,0 +1,23 @@ +package org.gotson.komga + +import org.assertj.core.api.Assertions.assertThat +import org.jooq.DSLContext +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import javax.sql.DataSource + +@SpringBootTest +class AutowiringTest( + @Autowired private val dataSources: List, + @Autowired private val dslContexts: List, +) { + @Test + fun `Application loads properly with test properties`() = Unit + + @Test + fun `Application has 4 dsl contexts`() { + assertThat(dataSources).hasSize(4) + assertThat(dslContexts).hasSize(4) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/Utils.kt new file mode 100644 index 000000000..ccdc14400 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/Utils.kt @@ -0,0 +1,7 @@ +package org.gotson.komga + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.ScanResult +import org.gotson.komga.domain.model.Series + +fun Map>.toScanResult() = ScanResult(this, emptyList()) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskProcessorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskProcessorTest.kt new file mode 100644 index 000000000..91f307ad2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskProcessorTest.kt @@ -0,0 +1,75 @@ +package org.gotson.komga.application.tasks + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@SpringBootTest +class TaskProcessorTest( + @Autowired private val taskEmitter: TaskEmitter, + @Autowired private val taskProcessor: TaskProcessor, +) { + @MockkBean + private lateinit var mockBookLifecycle: BookLifecycle + + @MockkBean + private lateinit var mockBookRepository: BookRepository + + fun testTasks( + sleep: Duration = 3.seconds, + block: () -> Unit, + ) { + taskProcessor.processTasks = false + block() + taskProcessor.processTasks = true + taskProcessor.processAvailableTask() + Thread.sleep(sleep.inWholeMilliseconds) + } + + @Test + fun `when similar tasks are submitted then only one is executed`() { + every { mockBookRepository.findByIdOrNull(any()) } returns makeBook("id") + every { mockBookLifecycle.analyzeAndPersist(any()) } returns emptySet() + + val book = makeBook("book") + + testTasks { + repeat(100) { + taskEmitter.analyzeBook(book) + } + } + + verify(exactly = 1) { mockBookLifecycle.analyzeAndPersist(any()) } + } + + @Test + fun `when high priority tasks are submitted then they are executed first`() { + val slot = slot() + val calls = mutableListOf() + every { mockBookRepository.findByIdOrNull(capture(slot)) } answers { + Thread.sleep(1_00) + makeBook(slot.captured) + } + every { mockBookLifecycle.analyzeAndPersist(capture(calls)) } returns emptySet() + + testTasks { + (0..9).forEach { + taskEmitter.analyzeBook(makeBook("$it", id = "$it"), it) + } + } + + verify(exactly = 10) { mockBookLifecycle.analyzeAndPersist(any()) } + assertThat(calls.map { it.name }).containsExactlyElementsOf((9 downTo 0).map { "$it" }) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/CodingRulesTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/CodingRulesTest.kt new file mode 100644 index 000000000..71652a813 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/CodingRulesTest.kt @@ -0,0 +1,36 @@ +package org.gotson.komga.architecture + +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.junit.AnalyzeClasses +import com.tngtech.archunit.junit.ArchTest +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS +import com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS +import com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION +import com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING +import com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_USE_JODATIME +import org.gotson.komga.Application + +@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) +class CodingRulesTest { + @ArchTest + private val noAccessToStandardStreams = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS + + @ArchTest + private val noGenericExceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS + + @ArchTest + private val noJodatime = NO_CLASSES_SHOULD_USE_JODATIME + + @ArchTest + private val noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING + + @ArchTest + private val noFieldInjection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION +} + +@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.OnlyIncludeTests::class]) +class TestCodingRulesTest { + @ArchTest + private val noJunitAssertions = noClasses().should().dependOnClassesThat().haveFullyQualifiedName("org.junit.jupiter.api.Assertions") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt new file mode 100644 index 000000000..221d5a4d2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.architecture + +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.junit.AnalyzeClasses +import com.tngtech.archunit.junit.ArchTest +import com.tngtech.archunit.lang.ArchRule +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import org.gotson.komga.Application + +@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) +class DomainDrivenDesignRulesTest { + @ArchTest + val domainModelShouldNotAccessOtherPackages: ArchRule = + noClasses() + .that() + .resideInAPackage("..domain..model..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..infrastructure..", + "..interfaces..", + "..domain.persistence..", + "..domain.service..", + ) + + @ArchTest + var classesNamedControllerShouldBeInAnInterfacesPackage: ArchRule = + classes() + .that() + .haveSimpleNameContaining("Controller") + .should() + .resideInAPackage("..interfaces..") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/NamingConventionTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/NamingConventionTest.kt new file mode 100644 index 000000000..ab2dc9c33 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/NamingConventionTest.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.architecture + +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.junit.AnalyzeClasses +import com.tngtech.archunit.junit.ArchTest +import com.tngtech.archunit.lang.ArchRule +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import org.gotson.komga.Application +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RestController + +@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) +class NamingConventionTest { + @ArchTest + val servicesShouldNotHaveNamesContainingServiceOrManager: ArchRule = + noClasses() + .that() + .resideInAnyPackage("..domain..service..", "..application..service..") + .should() + .haveSimpleNameContaining("service") + .orShould() + .haveSimpleNameContaining("Service") + .orShould() + .haveSimpleNameContaining("manager") + .orShould() + .haveSimpleNameContaining("Manager") + .because("it doesn't bear any intent") + + @ArchTest + val controllersShouldBeSuffixed: ArchRule = + classes() + .that() + .areAnnotatedWith(RestController::class.java) + .or() + .areAnnotatedWith(Controller::class.java) + .should() + .haveSimpleNameEndingWith("Controller") +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/SlicesIsolationRulesTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/SlicesIsolationRulesTest.kt new file mode 100644 index 000000000..8d405ab84 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/architecture/SlicesIsolationRulesTest.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.architecture + +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.junit.AnalyzeClasses +import com.tngtech.archunit.junit.ArchTest +import com.tngtech.archunit.lang.ArchRule +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices +import org.gotson.komga.Application + +@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) +class SlicesIsolationRulesTest { + @ArchTest + val interfacesShouldOnlyUseTheirOwnSlice: ArchRule = + slices() + .matching("..interfaces.(*)..") + .namingSlices("Interface $1") + .`as`("Interfaces") + .should() + .notDependOnEachOther() +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt new file mode 100644 index 000000000..bdcabd02a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AuthorTest { + @Test + fun `given untrimmed parameters when creating object then fields are trimmed and role is lowercase`() { + val author = Author(name = " name ", role = " Role ") + + assertThat(author.name).isEqualTo("name") + assertThat(author.role).isEqualTo("role") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BCP47TagValidatorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BCP47TagValidatorTest.kt new file mode 100644 index 000000000..32670e669 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BCP47TagValidatorTest.kt @@ -0,0 +1,52 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class BCP47TagValidatorTest { + @ParameterizedTest + @MethodSource("languagesNormalized") + fun `given source languageTag when normalizing then result is expected`( + source: String?, + expected: String, + ) { + assertThat(BCP47TagValidator.normalize(source)).isEqualTo(expected) + } + + private fun languagesNormalized(): Stream = + Stream.of( + Arguments.of(null, ""), + Arguments.of("", ""), + Arguments.of("fra", "fr"), + Arguments.of("fra-be", "fr-BE"), + Arguments.of("JA", "ja"), + Arguments.of("en-us", "en-US"), + Arguments.of("zh-Hans", "zh-Hans"), + Arguments.of("zh-HK", "zh-HK"), + ) + + @ParameterizedTest + @MethodSource("languagesValid") + fun `given source languageTag when validating then result is expected`( + source: String?, + expected: Boolean, + ) { + assertThat(BCP47TagValidator.isValid(source)).isEqualTo(expected) + } + + private fun languagesValid(): Stream = + Stream.of( + Arguments.of(null, false), + Arguments.of("", false), + Arguments.of("fra", true), + Arguments.of("fra-BE", true), + Arguments.of("en-us", true), + Arguments.of("JA", true), + Arguments.of("jp-JP", false), + Arguments.of("ja-JP", true), + Arguments.of("zh-Hans", true), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt new file mode 100644 index 000000000..100450c6a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class BookMetadataTest { + @Test + fun `given untrimmed parameters when creating object then fields are trimmed`() { + val metadata = BookMetadata(title = " title ", number = " number ", numberSort = 1F) + + assertThat(metadata.title).isEqualTo("title") + assertThat(metadata.number).isEqualTo("number") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookSearchTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookSearchTest.kt new file mode 100644 index 000000000..f61233a1a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/BookSearchTest.kt @@ -0,0 +1,83 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.SearchCondition.AuthorMatch +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Duration +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@SpringBootTest +class BookSearchTest( + @Autowired private val mapper: ObjectMapper, +) { + private val writer = mapper.writerWithDefaultPrettyPrinter() + + @Test + fun `given bookSearch entity when serializing then it looks alright`() { + val search = + BookSearch( + SearchCondition.AllOfBook( + SearchCondition.AnyOfBook( + SearchCondition.LibraryId(SearchOperator.Is("library1")), + SearchCondition.LibraryId(SearchOperator.IsNot("library1")), + ), + SearchCondition.SeriesId(SearchOperator.Is("series1")), + SearchCondition.SeriesId(SearchOperator.IsNot("series1")), + SearchCondition.ReadListId(SearchOperator.Is("readList1")), + SearchCondition.ReadListId(SearchOperator.IsNot("readList1")), + SearchCondition.Deleted(SearchOperator.IsFalse), + SearchCondition.Deleted(SearchOperator.IsTrue), + SearchCondition.Title(SearchOperator.BeginsWith("abc")), + SearchCondition.Title(SearchOperator.EndsWith("abc")), + SearchCondition.Title(SearchOperator.Is("abc")), + SearchCondition.Title(SearchOperator.IsNot("abc")), + SearchCondition.Title(SearchOperator.Contains("abc")), + SearchCondition.Title(SearchOperator.DoesNotContain("abc")), + SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))), + SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))), + SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(5))), + SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(5))), + SearchCondition.ReleaseDate(SearchOperator.IsNotNull), + SearchCondition.ReleaseDate(SearchOperator.IsNull), + SearchCondition.NumberSort(SearchOperator.GreaterThan(5F)), + SearchCondition.NumberSort(SearchOperator.LessThan(5F)), + SearchCondition.NumberSort(SearchOperator.Is(5F)), + SearchCondition.NumberSort(SearchOperator.IsNot(5F)), + SearchCondition.Tag(SearchOperator.Is("fiction")), + SearchCondition.Tag(SearchOperator.IsNot("fantasy")), + SearchCondition.Tag(SearchOperator.IsNullT()), + SearchCondition.Tag(SearchOperator.IsNotNullT()), + SearchCondition.ReadStatus(SearchOperator.IsNot(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.MediaStatus(SearchOperator.IsNot(Media.Status.READY)), + SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.PDF)), + SearchCondition.MediaProfile(SearchOperator.IsNot(MediaProfile.PDF)), + SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "writer"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jack", "writer"))), + SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(name = "jim"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch())), + SearchCondition.OneShot(SearchOperator.IsFalse), + SearchCondition.OneShot(SearchOperator.IsTrue), + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch(type = SearchCondition.PosterMatch.Type.GENERATED, selected = false))), + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch(selected = true))), + SearchCondition.Poster(SearchOperator.IsNot(SearchCondition.PosterMatch(type = SearchCondition.PosterMatch.Type.SIDECAR))), + SearchCondition.Poster(SearchOperator.IsNot(SearchCondition.PosterMatch())), + ), + ) + + val json = writer.writeValueAsString(search) + + println(json) + + val entity = mapper.readValue(json) + + assertThat(entity).isEqualTo(search) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ContentRestrictionsTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ContentRestrictionsTest.kt new file mode 100644 index 000000000..1f257ccc5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ContentRestrictionsTest.kt @@ -0,0 +1,87 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ContentRestrictionsTest { + @Test + fun `given no arguments when creating restriction then all restrictions are null`() { + val restriction = ContentRestrictions() + + assertThat(restriction.ageRestriction).isNull() + assertThat(restriction.labelsAllow).isEmpty() + assertThat(restriction.labelsExclude).isEmpty() + } + + @Test + fun `given AllowOnlyUnder restriction only when creating restriction then label restrictions are null`() { + val restriction = ContentRestrictions(AgeRestriction(10, AllowExclude.ALLOW_ONLY)) + + assertThat(restriction.ageRestriction).isNotNull + assertThat(restriction.ageRestriction!!.age).isEqualTo(10) + assertThat(restriction.ageRestriction!!.restriction).isEqualTo(AllowExclude.ALLOW_ONLY) + + assertThat(restriction.labelsAllow).isEmpty() + assertThat(restriction.labelsExclude).isEmpty() + } + + @Test + fun `given ExcludeOver only when creating restriction then label restrictions are null`() { + val restriction = ContentRestrictions(AgeRestriction(10, AllowExclude.EXCLUDE)) + + assertThat(restriction.ageRestriction).isNotNull + assertThat(restriction.ageRestriction!!.age).isEqualTo(10) + assertThat(restriction.ageRestriction!!.restriction).isEqualTo(AllowExclude.EXCLUDE) + + assertThat(restriction.labelsAllow).isEmpty() + assertThat(restriction.labelsExclude).isEmpty() + } + + @Test + fun `given empty labels when creating restriction then label restrictions are normalized`() { + val restriction = + ContentRestrictions( + labelsAllow = setOf("", " "), + labelsExclude = setOf("", " "), + ) + + assertThat(restriction.labelsAllow).isEmpty() + assertThat(restriction.labelsExclude).isEmpty() + } + + @Test + fun `given labels with duplicate values when creating restriction then label restrictions are normalized`() { + val restriction = + ContentRestrictions( + labelsAllow = setOf("a", "b", "B", "b ", "b", " B "), + labelsExclude = setOf("c", "d", "D", "d ", "d", " D "), + ) + + assertThat(restriction.labelsAllow).containsExactlyInAnyOrder("a", "b") + assertThat(restriction.labelsExclude).containsExactlyInAnyOrder("c", "d") + } + + @Test + fun `given labels with same value in both allow and exclude when creating restriction then exclude labels are removed from allow labels`() { + val restriction = + ContentRestrictions( + labelsAllow = setOf("a", "b", "B", "b ", "b", " B "), + labelsExclude = setOf(" A ", "d", "D", "d ", "d", " D "), + ) + + assertThat(restriction.labelsAllow).containsExactlyInAnyOrder("b") + assertThat(restriction.labelsExclude).containsExactlyInAnyOrder("a", "d") + } + + @Test + fun `given allow labels with all values in exclude labels when creating restriction then allow labels is null`() { + val restriction = + ContentRestrictions( + labelsAllow = setOf("a", "b", "B", "b ", "b", " B "), + labelsExclude = setOf(" A ", "b", "B", "B ", "b", " B "), + ) + + assertThat(restriction.labelsAllow).isEmpty() + assertThat(restriction.labelsExclude).containsExactlyInAnyOrder("a", "b") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt new file mode 100644 index 000000000..4cda00027 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt @@ -0,0 +1,117 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class KomgaUserTest { + val defaultUser = KomgaUser("user@example.org", "aPassword") + + @Nested + inner class ContentRestriction { + @Test + fun `given user with age AllowOnlyUnder restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = ContentRestrictions(AgeRestriction(5, AllowExclude.ALLOW_ONLY))) + + assertThat(user.isContentAllowed(ageRating = 3)).isTrue + assertThat(user.isContentAllowed(ageRating = 5)).isTrue + assertThat(user.isContentAllowed(ageRating = 8)).isFalse + assertThat(user.isContentAllowed(ageRating = null)).isFalse + } + + @Test + fun `given user with age ExcludeOver restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = ContentRestrictions(AgeRestriction(16, AllowExclude.EXCLUDE))) + + assertThat(user.isContentAllowed(ageRating = 10)).`as`("age 10 is allowed").isTrue + assertThat(user.isContentAllowed(ageRating = null)).`as`("age null is allowed").isTrue + assertThat(user.isContentAllowed(ageRating = 16)).`as`("age 16 is not allowed").isFalse + assertThat(user.isContentAllowed(ageRating = 18)).`as`("age 18 is not allowed").isFalse + } + + @Test + fun `given user with sharing label AllowOnly restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = ContentRestrictions(labelsAllow = setOf("allow", "this"))) + + assertThat(user.isContentAllowed(sharingLabels = setOf("allow"))).`as`("any tag is fine: allow").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("this"))).`as`("any tag is fine: this").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("allow", "this"))).`as`("both tags are fine").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("other"))).`as`("no allowed tags: other").isFalse + assertThat(user.isContentAllowed(sharingLabels = emptySet())).`as`("no allowed tags: emptySet").isFalse + } + + @Test + fun `given user with sharing label Exclude restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = ContentRestrictions(labelsExclude = setOf("exclude", "this"))) + + assertThat(user.isContentAllowed(sharingLabels = emptySet())).`as`("no label so no exclusion").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("allow"))).`as`("label allow is not in exclusion list").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("other", "this"))).`as`("label this is in exclusion list, other label is ignored").isFalse + assertThat(user.isContentAllowed(sharingLabels = setOf("this"))).`as`("label this is in exclusion list").isFalse + } + + @Test + fun `given user with both sharing label AllowOnly and Exclude restriction when checking for content restriction then it is accurate`() { + val user = + defaultUser.copy( + restrictions = + ContentRestrictions( + labelsAllow = setOf("allow", "both"), + labelsExclude = setOf("exclude", "both"), + ), + ) + + assertThat(user.isContentAllowed(sharingLabels = setOf("allow"))).isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("allow", "other"))).isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("allow", "both"))).isFalse + assertThat(user.isContentAllowed(sharingLabels = setOf("exclude"))).isFalse + assertThat(user.isContentAllowed(sharingLabels = emptySet())).isFalse + } + + @Test + fun `given user with both age AllowOnlyUnder restriction and sharing label AllowOnly restriction when checking for content restriction then it is accurate`() { + val user = + defaultUser.copy( + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(10, AllowExclude.ALLOW_ONLY), + labelsAllow = setOf("allow"), + ), + ) + + assertThat(user.isContentAllowed(ageRating = 5)).`as`("age 5 only is sufficient").isTrue + assertThat(user.isContentAllowed(ageRating = 15)).`as`("age 15 is not allowed").isFalse + assertThat(user.isContentAllowed(ageRating = null)).`as`("missing age and no allowed label: age null").isFalse + assertThat(user.isContentAllowed(sharingLabels = setOf("allow"))).`as`("allowed tag is sufficient").isTrue + assertThat(user.isContentAllowed(sharingLabels = setOf("other"))).`as`("missing age and no allowed label: other").isFalse + assertThat(user.isContentAllowed(sharingLabels = emptySet())).`as`("missing age and empty set label").isFalse + assertThat(user.isContentAllowed(5, setOf("allow"))).`as`("age and tag are good").isTrue + assertThat(user.isContentAllowed(5, setOf("other"))).`as`("age is good, other tag is ignored").isTrue + assertThat(user.isContentAllowed(15, setOf("allow"))).`as`("age is ignored, tag is allowed").isTrue + assertThat(user.isContentAllowed(15, setOf("other"))).`as`("age is too high, and no tag is allowed").isFalse + } + + @Test + fun `given user with both age AllowOnlyUnder restriction and sharing label Exclude restriction when checking for content restriction then it is accurate`() { + val user = + defaultUser.copy( + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(10, AllowExclude.ALLOW_ONLY), + labelsExclude = setOf("exclude"), + ), + ) + + assertThat(user.isContentAllowed(ageRating = 5)).isTrue + assertThat(user.isContentAllowed(ageRating = 15)).isFalse + assertThat(user.isContentAllowed(ageRating = null)).isFalse + assertThat(user.isContentAllowed(sharingLabels = setOf("exclude"))).isFalse + assertThat(user.isContentAllowed(sharingLabels = setOf("other"))).isFalse + assertThat(user.isContentAllowed(sharingLabels = emptySet())).isFalse + assertThat(user.isContentAllowed(5, setOf("exclude"))).isFalse + assertThat(user.isContentAllowed(5, setOf("other"))).isTrue + assertThat(user.isContentAllowed(15, setOf("exclude"))).isFalse + assertThat(user.isContentAllowed(15, setOf("other"))).isFalse + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/PageHashTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/PageHashTest.kt new file mode 100644 index 000000000..35034eda7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/PageHashTest.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PageHashTest { + @Test + fun `given negative size when creating a PageHash then its size is null`() { + val pageHash = PageHash("abc", -5) + + assertThat(pageHash.size).isNull() + } + + @Test + fun `given null size when creating a PageHash then its size is null`() { + val pageHash = PageHash("abc", null) + + assertThat(pageHash.size).isNull() + } + + @Test + fun `given size when creating a PageHash then its size is not null`() { + val pageHash = PageHash("abc", 5) + + assertThat(pageHash.size).isNotNull + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt new file mode 100644 index 000000000..3910e6153 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ProxyExtensionTest { + @Test + fun `when creating proxy of MediaExtension class then it is created`() { + val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName) + + assertThat(proxy).isNotNull + } + + @Test + fun `when creating proxy of MediaExtension interface then it is null`() { + val proxy = ProxyExtension.of(MediaExtension::class.qualifiedName) + + assertThat(proxy).isNull() + } + + @Test + fun `given proxy extension of class when checking against the class type then it returns true`() { + val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName)!! + + assertThat(proxy.proxyForType()).isTrue() + assertThat(proxy.proxyForType()).isFalse() + assertThat(proxy.proxyForType()).isFalse() + + assertThat(proxy.proxyForType(MediaExtensionEpub::class)).isTrue() + assertThat(proxy.proxyForType(MediaExtension::class)).isFalse() + assertThat(proxy.proxyForType(Media::class)).isFalse() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt new file mode 100644 index 000000000..35a93dec3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class SeriesMetadataTest { + @Test + fun `given untrimmed parameters when creating object then fields are trimmed`() { + val metadata = SeriesMetadata(title = " title ", titleSort = " titleSort ") + + assertThat(metadata.title).isEqualTo("title") + assertThat(metadata.titleSort).isEqualTo("titleSort") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesSearchTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesSearchTest.kt new file mode 100644 index 000000000..212088e1a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesSearchTest.kt @@ -0,0 +1,103 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.SearchCondition.AuthorMatch +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Duration +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@SpringBootTest +class SeriesSearchTest( + @Autowired private val mapper: ObjectMapper, +) { + private val writer = mapper.writerWithDefaultPrettyPrinter() + + @Test + fun `given seriesSearch entity when serializing then it looks alright`() { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + SearchCondition.AnyOfSeries( + emptyList(), + ), + SearchCondition.AnyOfSeries( + SearchCondition.LibraryId(SearchOperator.Is("library1")), + SearchCondition.LibraryId(SearchOperator.IsNot("library1")), + ), + SearchCondition.CollectionId(SearchOperator.Is("collection1")), + SearchCondition.CollectionId(SearchOperator.IsNot("collection1")), + SearchCondition.Deleted(SearchOperator.IsFalse), + SearchCondition.Deleted(SearchOperator.IsTrue), + SearchCondition.Complete(SearchOperator.IsTrue), + SearchCondition.Complete(SearchOperator.IsFalse), + SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))), + SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))), + SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(5))), + SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(5))), + SearchCondition.ReleaseDate(SearchOperator.IsNotNull), + SearchCondition.ReleaseDate(SearchOperator.IsNull), + SearchCondition.AgeRating(SearchOperator.LessThan(5)), + SearchCondition.AgeRating(SearchOperator.Is(5)), + SearchCondition.AgeRating(SearchOperator.IsNullT()), + SearchCondition.AgeRating(SearchOperator.IsNot(5)), + SearchCondition.AgeRating(SearchOperator.IsNotNullT()), + SearchCondition.Tag(SearchOperator.Is("fiction")), + SearchCondition.Tag(SearchOperator.IsNot("fantasy")), + SearchCondition.Tag(SearchOperator.IsNullT()), + SearchCondition.Tag(SearchOperator.IsNotNullT()), + SearchCondition.SharingLabel(SearchOperator.Is("label1")), + SearchCondition.SharingLabel(SearchOperator.IsNot("label1")), + SearchCondition.SharingLabel(SearchOperator.IsNullT()), + SearchCondition.SharingLabel(SearchOperator.IsNotNullT()), + SearchCondition.Publisher(SearchOperator.Is("publisher1")), + SearchCondition.Publisher(SearchOperator.IsNot("publisher1")), + SearchCondition.Language(SearchOperator.Is("en")), + SearchCondition.Language(SearchOperator.IsNot("en")), + SearchCondition.Genre(SearchOperator.Is("genre1")), + SearchCondition.Genre(SearchOperator.IsNot("genre1")), + SearchCondition.Genre(SearchOperator.IsNullT()), + SearchCondition.Genre(SearchOperator.IsNotNullT()), + SearchCondition.ReadStatus(SearchOperator.IsNot(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ENDED)), + SearchCondition.SeriesStatus(SearchOperator.IsNot(SeriesMetadata.Status.ONGOING)), + SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "writer"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jack", "writer"))), + SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(name = "jim"))), + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch())), + SearchCondition.OneShot(SearchOperator.IsFalse), + SearchCondition.OneShot(SearchOperator.IsTrue), + SearchCondition.Title(SearchOperator.Is("abc")), + SearchCondition.Title(SearchOperator.IsNot("abc")), + SearchCondition.Title(SearchOperator.Contains("abc")), + SearchCondition.Title(SearchOperator.DoesNotContain("abc")), + SearchCondition.Title(SearchOperator.BeginsWith("abc")), + SearchCondition.Title(SearchOperator.DoesNotBeginWith("abc")), + SearchCondition.Title(SearchOperator.EndsWith("abc")), + SearchCondition.Title(SearchOperator.DoesNotEndWith("abc")), + SearchCondition.TitleSort(SearchOperator.Is("abc")), + SearchCondition.TitleSort(SearchOperator.IsNot("abc")), + SearchCondition.TitleSort(SearchOperator.Contains("abc")), + SearchCondition.TitleSort(SearchOperator.DoesNotContain("abc")), + SearchCondition.TitleSort(SearchOperator.BeginsWith("abc")), + SearchCondition.TitleSort(SearchOperator.DoesNotBeginWith("abc")), + SearchCondition.TitleSort(SearchOperator.EndsWith("abc")), + SearchCondition.TitleSort(SearchOperator.DoesNotEndWith("abc")), + ), + ) + + val json = writer.writeValueAsString(search) + + println(json) + + val entity = mapper.readValue(json) + + assertThat(entity).isEqualTo(search) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt new file mode 100644 index 000000000..a3bd98399 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt @@ -0,0 +1,52 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.net.URL +import java.time.LocalDateTime + +fun makeBook( + name: String, + fileLastModified: LocalDateTime = LocalDateTime.now(), + libraryId: String = "", + seriesId: String = "", + url: URL? = null, + id: String = TsidCreator.getTsid256().toString(), +): Book { + Thread.sleep(5) + return Book( + name = name, + url = url ?: URL("file:/${name.replace(" ", "_")}"), + fileLastModified = fileLastModified, + libraryId = libraryId, + seriesId = seriesId, + id = id, + ) +} + +fun makeSeries( + name: String, + libraryId: String = "", + url: URL? = null, +): Series { + Thread.sleep(5) + return Series( + name = name, + url = url ?: URL("file:/${name.replace(" ", "_")}"), + fileLastModified = LocalDateTime.now(), + libraryId = libraryId, + ) +} + +fun makeLibrary( + name: String = "default", + path: String = "file:/${name.replace(" ", "_")}", + id: String = TsidCreator.getTsid256().toString(), + url: URL? = null, +): Library = + Library( + name = name, + root = url ?: URL(path), + id = id, + ) + +fun makeBookPage(name: String) = BookPage(name, "image/png") diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookAnalyzerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookAnalyzerTest.kt new file mode 100644 index 000000000..ddf23fa39 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookAnalyzerTest.kt @@ -0,0 +1,326 @@ +package org.gotson.komga.domain.service + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.mediacontainer.epub.EpubExtractor +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.core.io.ClassPathResource +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.io.path.extension +import kotlin.io.path.inputStream +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.toPath + +@SpringBootTest +class BookAnalyzerTest( + @Autowired private val komgaProperties: KomgaProperties, +) { + @SpykBean + private lateinit var bookAnalyzer: BookAnalyzer + + @SpykBean + private lateinit var epubExtractor: EpubExtractor + + @AfterEach + fun afterEach() { + clearAllMocks() + } + + @Nested + inner class ArchiveFormats { + @ParameterizedTest + @ValueSource( + strings = [ + "rar4.rar", "rar5.rar", "rar4-solid.rar", "rar5-solid.rar", + ], + ) + fun `given rar archives when analyzing then media status is READY`(fileName: String) { + val file = ClassPathResource("archives/$fileName") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).startsWith("application/x-rar-compressed") + assertThat(media.status).isEqualTo(Media.Status.READY) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "rar4-encrypted.rar", "rar5-encrypted.rar", + ], + ) + fun `given rar encrypted archive when analyzing then media status is UNSUPPORTED`(fileName: String) { + val file = ClassPathResource("archives/$fileName") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).startsWith("application/x-rar-compressed") + assertThat(media.status).isEqualTo(Media.Status.UNSUPPORTED) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "7zip.7z", "7zip-encrypted.7z", + ], + ) + fun `given 7zip archive when analyzing then media status is UNSUPPORTED`(fileName: String) { + val file = ClassPathResource("archives/$fileName") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/x-7z-compressed") + assertThat(media.status).isEqualTo(Media.Status.UNSUPPORTED) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "zip.zip", "zip-bzip2.zip", "zip-copy.zip", "zip-deflate64.zip", "zip-lzma.zip", "zip-ppmd.zip", + ], + ) + fun `given zip archive when analyzing then media status is READY`(fileName: String) { + val file = ClassPathResource("archives/$fileName") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.pages).hasSize(1) + } + + @Test + fun `given zip encrypted archive when analyzing then media status is ERROR`() { + val file = ClassPathResource("archives/zip-encrypted.zip") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/zip") + assertThat(media.status).isEqualTo(Media.Status.ERROR) + } + + @Test + fun `given epub archive when analyzing then media status is READY`() { + val file = ClassPathResource("archives/epub3.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + } + } + + @Nested + inner class Epub { + @Test + fun `given broken epub archive when analyzing then media status is ERROR`() { + val file = ClassPathResource("archives/zip-as-epub.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/zip") + assertThat(media.status).isEqualTo(Media.Status.ERROR) + assertThat(media.pages).hasSize(0) + } + + @Test + fun `given regular epub archive when analyzing then comment is empty`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).isNull() + } + + @Test + fun `given epub archive when toc cannot be extracted then media status is READY with comments`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + every { epubExtractor.getToc(any()) } throws Exception("mock exception") + + val media = bookAnalyzer.analyze(book, false) + val extension = media.extension as? MediaExtensionEpub + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).contains("ERR_1035") + assertThat(extension).isNotNull + assertThat(extension!!.toc).isEmpty() + } + + @Test + fun `given epub archive when landmarks cannot be extracted then media status is READY with comments`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + every { epubExtractor.getLandmarks(any()) } throws Exception("mock exception") + + val media = bookAnalyzer.analyze(book, false) + val extension = media.extension as? MediaExtensionEpub + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).contains("ERR_1036") + assertThat(extension).isNotNull + assertThat(extension!!.landmarks).isEmpty() + } + + @Test + fun `given epub archive when page list cannot be extracted then media status is READY with comments`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + every { epubExtractor.getPageList(any()) } throws Exception("mock exception") + + val media = bookAnalyzer.analyze(book, false) + val extension = media.extension as? MediaExtensionEpub + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).contains("ERR_1037") + assertThat(extension).isNotNull + assertThat(extension!!.pageList).isEmpty() + } + + @Test + fun `given epub archive when divina pages cannot be extracted then media status is READY with comments`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + every { epubExtractor.getDivinaPages(any(), any()) } throws Exception("mock exception") + + val media = bookAnalyzer.analyze(book, false) + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).contains("ERR_1038") + assertThat(media.pages).isEmpty() + } + + @Test + fun `given epub archive when positions cannot be extracted then media status is READY with comments`() { + val file = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val book = Book("book", file.url, LocalDateTime.now()) + + every { epubExtractor.computePositions(any(), any(), any(), any(), any()) } throws Exception("mock exception") + + val media = bookAnalyzer.analyze(book, false) + val extension = media.extension as? MediaExtensionEpub + + assertThat(media.mediaType).isEqualTo("application/epub+zip") + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.comment).contains("ERR_1039") + assertThat(extension).isNotNull + assertThat(extension!!.positions).isEmpty() + } + } + + @Nested + inner class PageHashing { + @Test + fun `given book with a single page when hashing then all pages are hashed`() { + val book = makeBook("book1") + val pages = listOf(BookPage("1.jpeg", "image/jpeg")) + val media = Media(Media.Status.READY, pages = pages) + + every { bookAnalyzer.getPageContent(any(), any()) } returns ByteArray(1) + every { bookAnalyzer.hashPage(any(), any()) } returns "hashed" + + val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media)) + + assertThat(hashedMedia.pages).hasSize(1) + assertThat(hashedMedia.pages.first().fileHash).isEqualTo("hashed") + } + + @Test + fun `given book with more than 6 pages when hashing then only first and last 3 are hashed`() { + val book = makeBook("book1") + val pages = (1..30).map { BookPage("$it.jpeg", "image/jpeg") } + val media = Media(Media.Status.READY, pages = pages) + + every { bookAnalyzer.getPageContent(any(), any()) } returns ByteArray(1) + every { bookAnalyzer.hashPage(any(), any()) } returns "hashed" + + val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media)) + + assertThat(hashedMedia.pages).hasSize(30) + assertThat(hashedMedia.pages.take(komgaProperties.pageHashing).map { it.fileHash }) + .hasSize(komgaProperties.pageHashing) + .containsOnly("hashed") + assertThat(hashedMedia.pages.takeLast(komgaProperties.pageHashing).map { it.fileHash }) + .hasSize(komgaProperties.pageHashing) + .containsOnly("hashed") + assertThat( + hashedMedia.pages + .drop(komgaProperties.pageHashing) + .dropLast(komgaProperties.pageHashing) + .map { it.fileHash }, + ).hasSize(30 - (komgaProperties.pageHashing * 2)) + .containsOnly("") + } + + @Test + fun `given book with already hashed pages when hashing then no hashing is done`() { + val book = makeBook("book1") + val pages = (1..30).map { BookPage("$it.jpeg", "image/jpeg", fileHash = "hashed") } + val media = Media(Media.Status.READY, pages = pages) + + val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media)) + + verify(exactly = 0) { bookAnalyzer.getPageContent(any(), any()) } + verify(exactly = 0) { bookAnalyzer.hashPage(any(), any()) } + + assertThat(hashedMedia.pages.map { it.fileHash }) + .hasSize(30) + .containsOnly("hashed") + } + + @ParameterizedTest + @MethodSource("provideDirectoriesForPageHashing") + fun `given 2 exact pages when hashing then hashes are the same`(directory: Path) { + val files = directory.listDirectoryEntries() + assertThat(files).hasSize(2) + + val mediaType = "image/${directory.fileName.extension}" + + val hashes = + files.map { + bookAnalyzer.hashPage(BookPage(it.name, mediaType = mediaType), it.inputStream().readBytes()) + } + + assertThat(hashes.first()).isEqualTo(hashes.last()) + } + + private fun provideDirectoriesForPageHashing() = ClassPathResource("hashpage").uri.toPath().listDirectoryEntries() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt new file mode 100644 index 000000000..c144ddac4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt @@ -0,0 +1,542 @@ +package org.gotson.komga.domain.service + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.verify +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.language.toIndexedMap +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.io.FileNotFoundException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Paths +import kotlin.io.path.createDirectories +import kotlin.io.path.createDirectory +import kotlin.io.path.createFile + +@SpringBootTest +class BookImporterTest( + @Autowired private val bookImporter: BookImporter, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val metadataRepository: BookMetadataRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, +) { + private val library = makeLibrary("lib", "file:/library") + private val user1 = KomgaUser("user1@example.org", "") + private val user2 = KomgaUser("user2@example.org", "") + + @MockkBean + private lateinit var mockTackReceiver: TaskEmitter + + @BeforeAll + fun init() { + libraryRepository.insert(library) + + userRepository.insert(user1) + userRepository.insert(user2) + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + readProgressRepository.deleteAll() + userRepository.deleteAll() + } + + @BeforeEach + fun initMocks() { + every { mockTackReceiver.refreshBookMetadata(any(), any(), any()) } just Runs + every { mockTackReceiver.refreshBookLocalArtwork(any(), any()) } just Runs + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given non-existent source file when importing then exception is thrown`() { + // given + val sourceFile = Paths.get("/non-existent") + + // when + val thrown = + Assertions.catchThrowable { + bookImporter.importBook(sourceFile, makeSeries("a series"), CopyMode.COPY) + } + + // then + assertThat(thrown).hasCauseInstanceOf(FileNotFoundException::class.java) + } + + @Test + fun `given existing target when importing then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + destDir.resolve("source.cbz").createFile() + + val series = makeSeries("dest", url = destDir.toUri().toURL()) + + // when + val thrown = + Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + } + + // then + assertThat(thrown).hasCauseInstanceOf(FileAlreadyExistsException::class.java) + } + } + + @Test + fun `given existing target when importing with destination name then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + destDir.resolve("dest.cbz").createFile() + + val series = makeSeries("dest").copy(url = destDir.toUri().toURL()) + + // when + val thrown = + Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "dest") + } + + // then + assertThat(thrown).hasCauseInstanceOf(FileAlreadyExistsException::class.java) + } + } + + @Test + fun `given source file part of a Komga library when importing then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + + val series = makeSeries("dest").copy(url = destDir.toUri().toURL()) + + val libraryJimfs = makeLibrary("jimfs", url = sourceDir.toUri().toURL()) + libraryRepository.insert(libraryJimfs) + + // when + val thrown = + Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + } + + // then + assertThat(thrown).isInstanceOf(PathContainedInPath::class.java) + + libraryRepository.delete(libraryJimfs.id) + } + } + + @Test + fun `given book when importing then book is imported and series is sorted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("2.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + + val existingBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, existingBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(existingBooks[0].id) + assertThat(books[2].id).isEqualTo(existingBooks[1].id) + + with(books[1]) { + assertThat(id) + .isNotEqualTo(existingBooks[0].id) + .isNotEqualTo(existingBooks[1].id) + assertThat(number).isEqualTo(2) + assertThat(name).isEqualTo("2") + + val newMedia = mediaRepository.findById(id) + assertThat(newMedia.status).isEqualTo(Media.Status.UNKNOWN) + } + } + } + + @Test + fun `given book with sidecars when importing then book and sidecars are imported`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("book 2.cbz").createFile() + sourceDir.resolve("book 2.jpg").createFile() + sourceDir.resolve("BOOK 2-1.jpg").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + + val existingBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, existingBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + + // then + verify(exactly = 2) { mockTackReceiver.refreshBookLocalArtwork(any()) } + + assertThat(destDir.resolve("book 2.cbz")).exists() + assertThat(destDir.resolve("book 2.jpg")).exists() + assertThat(destDir.resolve("BOOK 2-1.jpg")).exists() + } + } + + @Test + fun `given book with sidecars when importing with destination name then book and sidecars are imported`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("book 2.cbz").createFile() + sourceDir.resolve("book 2.jpg").createFile() + sourceDir.resolve("BOOK 2-1.jpg").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + + val existingBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, existingBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "book 5") + + // then + verify(exactly = 2) { mockTackReceiver.refreshBookLocalArtwork(any()) } + + assertThat(destDir.resolve("book 5.cbz")).exists() + assertThat(destDir.resolve("book 5.jpg")).exists() + assertThat(destDir.resolve("book 5-1.jpg")).exists() + } + } + + @Test + fun `given existing book when importing with upgrade then existing book and sidecars are deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("4.cbz").createFile() + val existingSidecar = destDir.resolve("4.jpg").createFile() + val existingSidecar2 = destDir.resolve("4-1.jpg").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isEqualTo(otherBooks[1].id) + assertThat(books[2].id).isNotEqualTo(bookToUpgrade.id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[2].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + assertThat(sourceFile).doesNotExist() + + assertThat(existingFile).doesNotExist() + assertThat(existingSidecar).doesNotExist() + assertThat(existingSidecar2).doesNotExist() + } + } + + @Test + fun `given existing book with metadata when importing with upgrade then metadata is kept`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("4.cbz").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + metadataRepository.findById(bookToUpgrade.id).let { + metadataRepository.update( + it.copy( + summary = "a summary", + number = "HS", + numberLock = true, + numberSort = 100F, + numberSortLock = true, + ), + ) + } + + bookLifecycle.addThumbnailForBook(ThumbnailBook(ByteArray(10), type = ThumbnailBook.Type.USER_UPLOADED, mediaType = "image/jpeg", fileSize = 10L, dimension = Dimension(1, 1), bookId = bookToUpgrade.id), MarkSelectedPreference.YES) + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isEqualTo(otherBooks[1].id) + assertThat(books[2].id).isNotEqualTo(bookToUpgrade.id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[2].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + with(metadataRepository.findById(books[2].id)) { + assertThat(summary).isEqualTo("a summary") + assertThat(number).isEqualTo("HS") + assertThat(numberLock).isTrue + assertThat(numberSort).isEqualTo(100F) + assertThat(numberSortLock).isTrue + } + + val thumbnail = bookLifecycle.getThumbnail(books[2].id) + assertThat(thumbnail).isNotNull + assertThat(thumbnail!!.type).isEqualTo(ThumbnailBook.Type.USER_UPLOADED) + assertThat(thumbnail.fileSize).isEqualTo(10L) + + assertThat(sourceFile).doesNotExist() + } + } + + @Test + fun `given existing book when importing with upgrade and same name then existing book is replaced`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("2.cbz").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = + listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "2", upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isNotEqualTo(bookToUpgrade.id) + assertThat(books[2].id).isEqualTo(otherBooks[1].id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[1].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + assertThat(sourceFile).exists() + } + } + + @Test + fun `given book with read progress when importing with upgrade then read progress is kept`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("1.cbz").createFile() + + val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL()) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade)) + seriesLifecycle.sortBooks(series) + } + + mediaRepository.findById(bookToUpgrade.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + bookLifecycle.markReadProgressCompleted(bookToUpgrade.id, user1) + bookLifecycle.markReadProgress(bookToUpgrade, user2, 4) + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(1) + + val progress = readProgressRepository.findAllByBookId(books[0].id) + assertThat(progress).hasSize(2) + with(progress.find { it.userId == user1.id }!!) { + assertThat(completed).isTrue + } + with(progress.find { it.userId == user2.id }!!) { + assertThat(completed).isFalse + assertThat(page).isEqualTo(4) + } + } + } + + @Test + fun `given book part of a read list when importing with upgrade then imported book replaces upgraded book in the read list`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("1.cbz").createFile() + + val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL()) + val series = + makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade)) + seriesLifecycle.sortBooks(series) + } + + val readList = + ReadList( + name = "readlist", + bookIds = listOf(bookToUpgrade.id).toIndexedMap(), + ) + readListLifecycle.addReadList(readList) + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(1) + + with(readListRepository.findByIdOrNull(readList.id)!!) { + assertThat(bookIds).hasSize(1) + assertThat(bookIds[0]).isEqualTo(books[0].id) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt new file mode 100644 index 000000000..4e9ad65eb --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt @@ -0,0 +1,468 @@ +package org.gotson.komga.domain.service + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import com.ninjasquad.springmockk.MockkBean +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatNoException +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeBookPage +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.nio.file.Files +import java.nio.file.Paths +import java.time.ZonedDateTime + +@SpringBootTest +class BookLifecycleTest( + @Autowired private val bookRepository: BookRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val thumbnailBookRepository: ThumbnailBookRepository, +) { + @SpykBean + private lateinit var bookLifecycle: BookLifecycle + + @MockkBean + private lateinit var mockAnalyzer: BookAnalyzer + + private val library = makeLibrary() + private val user1 = KomgaUser("user1@example.org", "") + private val user2 = KomgaUser("user2@example.org", "") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + + userRepository.insert(user1) + userRepository.insert(user2) + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + readProgressRepository.deleteAll() + userRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given outdated book with different number of pages than before when analyzing then existing incomplete read progress is reset to 1`() { + // given + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.OUTDATED, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + bookLifecycle.markReadProgressCompleted(book.id, user1) + bookLifecycle.markReadProgress(book, user2, 4) + + assertThat(readProgressRepository.findAll()).hasSize(2) + + // when + every { mockAnalyzer.analyze(any(), any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book.id) + bookLifecycle.analyzeAndPersist(book) + + // then + with(readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id)!!) { + assertThat(page).isEqualTo(2) + assertThat(completed).isTrue + } + with(readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user2.id)!!) { + assertThat(page).isEqualTo(1) + assertThat(completed).isFalse + } + } + + @Test + fun `given outdated book with same number of pages than before when analyzing then existing read progress is kept`() { + // given + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.OUTDATED, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + bookLifecycle.markReadProgressCompleted(book.id, user1) + bookLifecycle.markReadProgress(book, user2, 4) + + assertThat(readProgressRepository.findAll()).hasSize(2) + + // when + every { mockAnalyzer.analyze(any(), any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = (1..10).map { BookPage("$it", "image/jpeg") }, bookId = book.id) + bookLifecycle.analyzeAndPersist(book) + + // then + assertThat(readProgressRepository.findAll()).hasSize(2) + } + + @Test + fun `given a book with a sidecar when deleting book then all book files should be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val bookPath = seriesPath.resolve("book1.cbz") + Files.createFile(bookPath) + val sidecarPath = seriesPath.resolve("sidecar1.png") + Files.createFile(sidecarPath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val book = makeBook("1", libraryId = library.id, seriesId = series.id, url = bookPath.toUri().toURL()) + val sidecar = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(book)) + thumbnailBookRepository.insert(sidecar) + + // when + bookLifecycle.deleteBookFiles(book) + + // then + assertThat(Files.notExists(bookPath)) + assertThat(Files.notExists(sidecarPath)) + } + } + + @Test + fun `given a non-existent book file when deleting book then it returns`() { + // given + val bookPath = Paths.get("/non-existent") + val book = makeBook("1", libraryId = library.id, url = bookPath.toUri().toURL()) + + // when + bookLifecycle.deleteBookFiles(book) + + // then + verify(exactly = 0) { bookLifecycle.softDeleteMany(any()) } + } + + @Test + fun `given a book and a non-existent sidecar file when deleting book then book should be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val bookPath = seriesPath.resolve("book1.cbz") + Files.createFile(bookPath) + val sidecar1Path = seriesPath.resolve("sidecar1.png") + Files.createFile(sidecar1Path) + val sidecar2Path = seriesPath.resolve("sidecar2.png") + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val book = makeBook("1", libraryId = library.id, seriesId = series.id, url = bookPath.toUri().toURL()) + val sidecar1 = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecar1Path.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + val sidecar2 = ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, url = sidecar2Path.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(book)) + thumbnailBookRepository.insert(sidecar1) + thumbnailBookRepository.insert(sidecar2) + + // when + bookLifecycle.deleteBookFiles(book) + + // then + assertThat(Files.notExists(seriesPath)) + } + } + + @Test + fun `given a single book file when deleting book then parent directory should be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val bookPath = seriesPath.resolve("book1.cbz") + Files.createFile(bookPath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val book = makeBook("1", libraryId = library.id, seriesId = series.id, url = bookPath.toUri().toURL()) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(book)) + + // when + bookLifecycle.deleteBookFiles(book) + + // then + assertThat(Files.notExists(seriesPath)) + } + } + + @Test + fun `given a single book file with unrelated files in directory when deleting book then parent directory should not be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val bookPath = seriesPath.resolve("book1.cbz") + Files.createFile(bookPath) + val filePath = seriesPath.resolve("file.txt") + Files.createFile(filePath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val book = makeBook("1", libraryId = library.id, seriesId = series.id, url = bookPath.toUri().toURL()) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(book)) + + // when + bookLifecycle.deleteBookFiles(book) + + // then + assertThat(Files.exists(seriesPath)) + assertThat(Files.exists(filePath)) + assertThat(Files.notExists(bookPath)) + } + } + + @Nested + inner class Progression { + @BeforeEach + fun setup() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + } + + private val device = R2Device("abc", "device") + private val epubResources = + listOf( + MediaFile("ch1.xhtml", "application/xhtml+xml"), + MediaFile("ch2.xhtml", "application/xhtml+xml"), + MediaFile("ch3.xhtml", "application/xhtml+xml"), + ) + + private fun makeEpubPositions(): List { + var startPosition = 1 + return epubResources.flatMap { file -> + (1..10).map { + R2Locator(file.fileName, file.mediaType!!, locations = R2Locator.Location(position = startPosition++, progression = it.toFloat())) + } + } + } + + @Test + fun `given book when marking progress older than saved then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/zip", pageCount = 10)) + } + + val progress = R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 5))) + + bookLifecycle.markProgression(book, user1, progress) + + // when + val thrown = + catchThrowable { + bookLifecycle.markProgression(book, user1, progress.copy(modified = progress.modified.minusHours(1))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("older than existing") + } + + @ParameterizedTest + @ValueSource(strings = ["application/zip", "application/pdf"]) + fun `given divina or pdf book when marking progress over the page count then it fails`(mediaType: String) { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pageCount = 10, mediaType = mediaType)) + } + + // when + val thrown = + catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must be within 1 and book page count") + } + + @Test + fun `given epub book when marking progress with non-existing href then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = + catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch5.xhtml", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Resource does not exist in book") + } + + @Test + fun `given epub book when marking progress without location progression then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = + catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("location.progression is required") + } + + @Test + fun `given epub book without extension when marking progress then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = + catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(progression = 0.3F)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("extension not found") + } + + @Test + fun `given epub book when marking progress with exact position then it succeeds`() { + val book = bookRepository.findAll().first() + val epubPositions = makeEpubPositions() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions))) + } + + // when + val newProgression = R2Progression(ZonedDateTime.now(), device, epubPositions.first()) + assertThatNoException().isThrownBy { + bookLifecycle.markProgression(book, user1, newProgression) + } + val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id) + + // then + assertThat(savedProgression).isNotNull + assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator) + } + + @Test + fun `given epub book when marking progress with skewed position then it succeeds`() { + val book = bookRepository.findAll().first() + val epubPositions = makeEpubPositions() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions))) + } + + // when + val newProgression = + R2Progression( + ZonedDateTime.now(), + device, + with(epubPositions[0]) { + copy(locations = locations!!.copy(progression = locations!!.progression!! + 0.5F)) + }, + ) + assertThatNoException().isThrownBy { + bookLifecycle.markProgression(book, user1, newProgression) + } + val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id) + + // then + assertThat(savedProgression).isNotNull + assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt new file mode 100644 index 000000000..44570b5a4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt @@ -0,0 +1,409 @@ +package org.gotson.komga.domain.service + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import org.apache.commons.io.FilenameUtils +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.nio.file.Files +import java.nio.file.Path +import java.util.stream.Stream + +class FileSystemScannerTest { + private val scanner = FileSystemScanner(emptyList(), emptyList()) + + @Test + fun `given unavailable root directory when scanning then throw exception`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + + // when + val thrown = catchThrowable { scanner.scanRootFolder(root) } + + // then + assertThat(thrown).isInstanceOf(DirectoryNotFoundException::class.java) + } + } + + @Test + fun `given empty root directory when scanning then return empty list`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + // when + val scan = scanner.scanRootFolder(root).series + + // then + assertThat(scan).isEmpty() + } + } + + @Test + fun `given root directory with only files when scanning then return 1 series containing those files as books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val files = listOf("file1.cbz", "file2.cbz") + files.forEach { Files.createFile(root.resolve(it)) } + + // when + val scan = scanner.scanRootFolder(root).series + val series = scan.keys.first() + val books = scan.getValue(series) + + // then + assertThat(scan).hasSize(1) + assertThat(books).hasSize(2) + assertThat(books.map { it.name }).containsExactlyInAnyOrderElementsOf(files.map { FilenameUtils.removeExtension(it) }) + } + } + + @Test + fun `given root directory as filesystem root when scanning then return 1 series containing those files as books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/") + + val files = listOf("file1.cbz", "file2.cbz") + files.forEach { Files.createFile(root.resolve(it)) } + + // when + val scan = scanner.scanRootFolder(root).series + val series = scan.keys.first() + val books = scan.getValue(series) + + // then + assertThat(scan).hasSize(1) + assertThat(series.name).isEqualTo("/") + assertThat(books).hasSize(2) + assertThat(books.map { it.name }).containsExactlyInAnyOrderElementsOf(files.map { FilenameUtils.removeExtension(it) }) + } + } + + @Test + fun `given directory with unsupported files when scanning then return a series excluding those files as books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val files = listOf("file1.cbz", "file2.txt", "file3") + files.forEach { Files.createFile(root.resolve(it)) } + + // when + val scan = scanner.scanRootFolder(root).series + val series = scan.keys.first() + val books = scan.getValue(series) + + // then + assertThat(scan).hasSize(1) + assertThat(books).hasSize(1) + assertThat(books.map { it.name }).containsExactly("file1") + } + } + + @ParameterizedTest + @MethodSource("libraryScanFileTypesArguments") + fun `given directory when scanning excluding some files then return a series excluding those files as books`( + sourceFiles: List, + scanCbz: Boolean, + scanPdf: Boolean, + scanEpub: Boolean, + resultBookNames: List, + ) { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + sourceFiles.forEach { Files.createFile(root.resolve(it)) } + + // when + val scan = scanner.scanRootFolder(root, scanCbx = scanCbz, scanPdf = scanPdf, scanEpub = scanEpub).series + + // then + if (resultBookNames.isNotEmpty()) { + assertThat(scan).hasSize(1) + val books = scan.getValue(scan.keys.first()) + assertThat(books).hasSameSizeAs(resultBookNames) + assertThat(books.map { it.name }).containsExactlyInAnyOrderElementsOf(resultBookNames) + } else { + assertThat(scan).isEmpty() + } + } + } + + private fun libraryScanFileTypesArguments(): Stream { + val sourceFiles = listOf("cbz.cbz", "cbr.cbr", "zip.zip", "rar.rar", "pdf.pdf", "epub.epub") + return Stream.of( + Arguments.of(sourceFiles, true, true, true, listOf("cbz", "cbr", "zip", "rar", "pdf", "epub")), + Arguments.of(sourceFiles, false, true, true, listOf("pdf", "epub")), + Arguments.of(sourceFiles, true, false, true, listOf("cbz", "cbr", "zip", "rar", "epub")), + Arguments.of(sourceFiles, true, true, false, listOf("cbz", "cbr", "zip", "rar", "pdf")), + Arguments.of(sourceFiles, false, false, true, listOf("epub")), + Arguments.of(sourceFiles, true, false, false, listOf("cbz", "cbr", "zip", "rar")), + Arguments.of(sourceFiles, false, true, false, listOf("pdf")), + Arguments.of(sourceFiles, false, false, false, emptyList()), + ) + } + + @Test + fun `given directory with sub-directories containing files when scanning then return 1 series per folder containing direct files as books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val subDirs = + listOf( + "series1" to listOf("volume1.cbz", "volume2.cbz"), + "series2" to listOf("book1.cbz", "book2.cbz"), + ).toMap() + + subDirs.forEach { (dir, files) -> + makeSubDir(root, dir, files) + } + + // when + val scan = scanner.scanRootFolder(root).series + val series = scan.keys + + // then + assertThat(scan).hasSize(2) + + assertThat(series.map { it.name }).containsExactlyInAnyOrderElementsOf(subDirs.keys) + series.forEach { s -> + assertThat( + scan.getValue(s).map { it.name }, + ).containsExactlyInAnyOrderElementsOf( + subDirs[s.name]?.map { + FilenameUtils.removeExtension( + it, + ) + }, + ) + } + } + } + + @Test + fun `given symlink root directory when scanning then return series and books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val link = fs.getPath("/link") + Files.createSymbolicLink(link, root) + + val subDirs = + listOf( + "series1" to listOf("volume1.cbz", "volume2.cbz"), + "series2" to listOf("book1.cbz", "book2.cbz"), + ).toMap() + + subDirs.forEach { (dir, files) -> + makeSubDir(root, dir, files) + } + + // when + val scan = scanner.scanRootFolder(link).series + val series = scan.keys + + // then + assertThat(scan).hasSize(2) + + assertThat(series.map { it.name }).containsExactlyInAnyOrderElementsOf(subDirs.keys) + series.forEach { s -> + assertThat( + scan.getValue(s).map { it.name }, + ).containsExactlyInAnyOrderElementsOf( + subDirs[s.name]?.map { + FilenameUtils.removeExtension( + it, + ) + }, + ) + } + } + } + + @Test + fun `given root directory with symlinks when scanning then return series and books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val subDirs = + listOf( + "series1" to listOf("volume1.cbz", "volume2.cbz"), + "series2" to listOf("book1.cbz", "book2.cbz"), + ).toMap() + + subDirs.forEach { (dir, files) -> + makeSubDir(root, dir, files) + Files.createSymbolicLink(root.resolve("${dir}_link"), root.resolve(dir)) + } + + // when + val scan = scanner.scanRootFolder(root).series + val series = scan.keys + + // then + assertThat(scan).hasSize(4) + + assertThat(series.map { it.name }).containsExactlyInAnyOrderElementsOf(subDirs.keys + subDirs.keys.map { "${it}_link" }) + series.forEach { s -> + assertThat( + scan.getValue(s).map { it.name }, + ).containsExactlyInAnyOrderElementsOf( + subDirs[s.name.removeSuffix("_link")]?.map { + FilenameUtils.removeExtension( + it, + ) + }, + ) + } + } + } + + @Test + fun `given directory structure with excluded directories when scanning then excluded directories are not returned`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val dir1 = makeSubDir(root, "dir1", listOf("comic.cbz")) + makeSubDir(dir1, "subdir1", listOf("comic2.cbz")) + val recycle = makeSubDir(root, "#recycle", listOf("trash.cbz")) + makeSubDir(recycle, "subtrash", listOf("trash2.cbz")) + + // when + val scan = scanner.scanRootFolder(root, directoryExclusions = setOf("#recycle")).series + + // then + assertThat(scan).hasSize(2) + + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1", "subdir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") + } + } + + @Test + fun `given directory structure with hidden directories when scanning then hidden directories are not returned`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val dir1 = makeSubDir(root, "dir1", listOf("comic.cbz")) + makeSubDir(dir1, "subdir1", listOf("comic2.cbz")) + val hidden = makeSubDir(root, ".hidden", listOf("hidden.cbz")) + makeSubDir(hidden, "subhidden", listOf("hidden2.cbz")) + + // when + val scan = scanner.scanRootFolder(root).series + + // then + assertThat(scan).hasSize(2) + + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1", "subdir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") + } + } + + @Test + fun `given directory structure with hidden files when scanning then hidden files are not returned`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val dir1 = makeSubDir(root, "dir1", listOf("comic.cbz")) + makeSubDir(dir1, "subdir1", listOf("comic2.cbz", ".comic2.cbz")) + + // when + val scan = scanner.scanRootFolder(root).series + + // then + assertThat(scan).hasSize(2) + + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1", "subdir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") + } + } + + @Test + fun `given file with mixed-case extension when scanning then files are returned`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + makeSubDir(root, "dir1", listOf("comic.Cbz", "comic2.CBR")) + + // when + val scan = scanner.scanRootFolder(root).series + + // then + assertThat(scan).hasSize(1) + + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") + } + } + + @Test + fun `given oneshot directory when scanning then return a series per file`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val normal = makeSubDir(root, "normal", listOf("comic.cbz")) + makeSubDir(normal, "_oneshots", listOf("single4.cbz", "single5.cbz")) + makeSubDir(root, "_oneshots", listOf("single.cbz", "single2.cbz", "single3.cbz")) + + // when + val scan = scanner.scanRootFolder(root, oneshotsDir = "_oneshots").series + + // then + assertThat(scan).hasSize(6) + assertThat(scan.keys.map { it.name }) + .containsExactlyInAnyOrder("normal", "single", "single2", "single3", "single4", "single5") + .doesNotContain("_oneshots") + val (oneshots, regular) = scan.keys.partition { it.name.startsWith("single") } + assertThat(oneshots.map { it.oneshot }).containsOnly(true) + assertThat(oneshots.flatMap { scan[it] ?: emptyList() }.map { it.oneshot }).containsOnly(true) + assertThat(regular.map { it.oneshot }).containsOnly(false) + assertThat(regular.flatMap { scan[it] ?: emptyList() }.map { it.oneshot }).containsOnly(false) + + scan.forEach { (_, books) -> + assertThat(books).hasSize(1) + } + } + } + + private fun makeSubDir( + root: Path, + name: String, + files: List, + ): Path { + val dir = root.resolve(name) + Files.createDirectory(dir) + files.forEach { Files.createFile(root.resolve(root.fileSystem.getPath(name, it))) } + return dir + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt new file mode 100644 index 000000000..17a07d7b2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt @@ -0,0 +1,76 @@ +package org.gotson.komga.domain.service + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.security.apikey.ApiKeyGenerator +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class KomgaUserLifecycleTest( + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, +) { + @SpykBean + private lateinit var apiKeyGenerator: ApiKeyGenerator + + private val user1 = KomgaUser("user1@example.org", "") + private val user2 = KomgaUser("user2@example.org", "") + + @BeforeAll + fun setup() { + userRepository.insert(user1) + userRepository.insert(user2) + } + + @AfterEach + fun cleanup() { + userRepository.deleteApiKeyByUserId(user1.id) + userRepository.deleteApiKeyByUserId(user2.id) + } + + @AfterAll + fun teardown() { + userRepository.deleteAll() + } + + @Test + fun `given existing api key when api key cannot be uniquely generated then it returns null`() { + // given + val uuid = ApiKeyGenerator().generate() + every { apiKeyGenerator.generate() } returns uuid + userLifecycle.createApiKey(user1, "test key") + + // when + val apiKey = userLifecycle.createApiKey(user1, "test key 2") + val apiKey2 = userLifecycle.createApiKey(user2, "test key 3") + + // then + assertThat(apiKey).isNull() + assertThat(apiKey2).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["test", "TEST", " test "]) + fun `given existing api key comment when api key with same comment is generated then it throws exception`(comment: String) { + // given + userLifecycle.createApiKey(user1, "test") + + // when + val thrown = catchThrowable { userLifecycle.createApiKey(user1, comment) } + + // then + assertThat(thrown).isExactlyInstanceOf(DuplicateNameException::class.java) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt new file mode 100644 index 000000000..bee465d0b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -0,0 +1,1658 @@ +package org.gotson.komga.domain.service + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeBookPage +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository +import org.gotson.komga.language.toIndexedMap +import org.gotson.komga.toScanResult +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.Pageable +import java.net.URL +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.nameWithoutExtension + +@SpringBootTest +class LibraryContentLifecycleTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val libraryContentLifecycle: LibraryContentLifecycle, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val collectionLifecycle: SeriesCollectionLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val seriesDtoRepository: SeriesDtoRepository, + @Autowired private val thumbnailBookRepository: ThumbnailBookRepository, +) { + @MockkBean + private lateinit var mockScanner: FileSystemScanner + + @MockkBean + private lateinit var mockAnalyzer: BookAnalyzer + + @MockkBean + private lateinit var mockHasher: Hasher + + @MockkBean + private lateinit var mockTaskEmitter: TaskEmitter + + private val user = KomgaUser("user@example.org", "", id = "1") + + @BeforeAll + fun setup() { + userRepository.insert(user) + } + + @BeforeEach + fun beforeEach() { + every { mockTaskEmitter.refreshBookMetadata(any(), any()) } just Runs + every { mockTaskEmitter.refreshSeriesMetadata(any(), any()) } just Runs + } + + @AfterAll + fun tearDown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + } + + @AfterEach + fun `clear repositories`() { + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @Nested + inner class Scan { + @Test + fun `given existing series when adding files and scanning then only updated books are persisted`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) }.returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.name }).containsExactly("book1", "book2") + } + + @Test + fun `given existing series when removing files and scanning then updated books are persisted and removed books are marked as such`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.filter { it.deletedDate == null }.map { it.name }).containsExactly("book1") + assertThat(allBooks.filter { it.deletedDate != null }.map { it.name }).containsExactly("book2") + } + + @Test + fun `given existing series when updating files and scanning then books are updated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + verify(exactly = 0) { mockHasher.computeHash(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(1) + val book = allBooks.first() + assertThat(book.name).isEqualTo("book1") + assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) + val media = mediaRepository.findById(book.id) + assertThat(media.status).isEqualTo(Media.Status.OUTDATED) + } + + @Test + fun `given existing series when scanning and updated files have a different size then books are marked outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1").copy(fileSize = 1))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + bookRepository.findAll().first().let { book -> + bookRepository.update(book.copy(fileHash = "hashed")) + mediaRepository.update(mediaRepository.findById(book.id).copy(status = Media.Status.READY)) + } + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + verify(exactly = 0) { mockHasher.computeHash(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(1) + val book = allBooks.first() + assertThat(book.name).isEqualTo("book1") + assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) + assertThat(book.fileHash).isBlank + val media = mediaRepository.findById(book.id) + assertThat(media.status).isEqualTo(Media.Status.OUTDATED) + } + + @Test + fun `given existing series when scanning and updated files have the same hash then books are not marked outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + bookRepository.findAll().first().let { book -> + bookRepository.update(book.copy(fileHash = "hashed")) + mediaRepository.update(mediaRepository.findById(book.id).copy(status = Media.Status.READY)) + } + + every { mockHasher.computeHash(any()) } returns "hashed" + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + verify(exactly = 1) { mockHasher.computeHash(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(1) + val book = allBooks.first() + assertThat(book.name).isEqualTo("book1") + assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) + assertThat(book.fileHash).isEqualTo("hashed") + val media = mediaRepository.findById(book.id) + assertThat(media.status) + .isNotEqualTo(Media.Status.OUTDATED) + .isEqualTo(Media.Status.READY) + } + + @Test + fun `given existing series when deleting all books and scanning then Series and Books are marked as deleted`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + emptyMap>().toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + assertThat(allSeries.map { it.deletedDate }).doesNotContainNull() + assertThat(allSeries).hasSize(1) + assertThat(allBooks.map { it.deletedDate }).doesNotContainNull() + assertThat(allBooks).hasSize(1) + } + + @Test + fun `given existing series when deleting all books of one series and scanning then series and its books are marked as deleted`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book2")), + ).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } + val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } + + assertThat(series).hasSize(1) + assertThat(series.map { it.name }).containsExactlyInAnyOrder("series") + + assertThat(deletedSeries).hasSize(1) + assertThat(deletedSeries.map { it.name }).containsExactlyInAnyOrder("series2") + + assertThat(books).hasSize(1) + assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1") + + assertThat(deletedBooks).hasSize(1) + assertThat(deletedBooks.map { it.name }).containsExactlyInAnyOrder("book2") + } + + @Test + fun `given existing book with media when rescanning then media is kept intact`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book1 = makeBook("book1") + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + every { mockAnalyzer.analyze(any(), any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) + bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) } + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + verify(exactly = 1) { mockAnalyzer.analyze(any(), any()) } + + bookRepository.findAll().first().let { book -> + assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) + + mediaRepository.findById(book.id).let { media -> + assertThat(media.status).isEqualTo(Media.Status.READY) + assertThat(media.mediaType).isEqualTo("application/zip") + assertThat(media.pages).hasSize(2) + assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg") + } + } + } + + @Test + fun `given existing book with different last modified date and hash when rescanning then media is marked as outdated and hash is reset`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book1 = makeBook("book1") + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + every { mockAnalyzer.analyze(any(), any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) + every { mockHasher.computeHash(any()) }.returnsMany("abc", "def") + + bookRepository.findAll().map { + bookLifecycle.analyzeAndPersist(it) + bookLifecycle.hashAndPersist(it) + } + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + verify(exactly = 1) { mockAnalyzer.analyze(any(), any()) } + verify(exactly = 2) { mockHasher.computeHash(any()) } + + bookRepository.findAll().first().let { book -> + assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) + assertThat(book.fileHash).isEqualTo("def") + + mediaRepository.findById(book.id).let { media -> + assertThat(media.status).isEqualTo(Media.Status.OUTDATED) + assertThat(media.mediaType).isEqualTo("application/zip") + assertThat(media.pages).hasSize(2) + assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg") + } + } + } + + @Test + fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() { + // given + val library1 = makeLibrary(name = "library1") + libraryRepository.insert(library1) + val library2 = makeLibrary(name = "library2") + libraryRepository.insert(library2) + + every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1"))).toScanResult() + + every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany( + mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))).toScanResult(), + emptyMap>().toScanResult(), + ) + + libraryContentLifecycle.scanRootFolder(library1) + libraryContentLifecycle.scanRootFolder(library2) + + assertThat(seriesRepository.count()).describedAs("Series repository should not be empty").isEqualTo(2) + assertThat(bookRepository.count()).describedAs("Book repository should not be empty").isEqualTo(2) + + // when + libraryContentLifecycle.scanRootFolder(library2) + + // then + verify(exactly = 1) { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } + verify(exactly = 2) { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) } + + val (seriesLib1, seriesLib2) = seriesRepository.findAll().partition { it.libraryId == library1.id } + val (booksLib1, booksLib2) = bookRepository.findAll().partition { it.libraryId == library1.id } + + assertThat(seriesLib1.map { it.deletedDate }).containsOnlyNulls() + assertThat(seriesLib1.map { it.name }).containsExactlyInAnyOrder("series1") + + assertThat(seriesLib2.map { it.deletedDate }).doesNotContainNull() + assertThat(seriesLib2.map { it.name }).containsExactlyInAnyOrder("series2") + + assertThat(booksLib1.map { it.deletedDate }).containsOnlyNulls() + assertThat(booksLib1.map { it.name }).containsExactlyInAnyOrder("book1") + + assertThat(booksLib2.map { it.deletedDate }).doesNotContainNull() + assertThat(booksLib2.map { it.name }).containsExactlyInAnyOrder("book2") + } + + @Test + fun `given library with auto empty trash when scanning then removed series and books are deleted permanently`() { + // given + val library = makeLibrary().copy(emptyTrashAfterScan = true) + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book3")), + makeSeries(name = "series2") to listOf(makeBook("book2")), + ).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + // when + libraryContentLifecycle.scanRootFolder(library) + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } + val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } + + assertThat(series).hasSize(1) + assertThat(series.map { it.name }).containsExactlyInAnyOrder("series") + + assertThat(deletedSeries).isEmpty() + + assertThat(books).hasSize(1) + assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1") + + assertThat(deletedBooks).isEmpty() + } + + @Test + fun `given library when scanning and the root folder is not accessible then exception is thrown`() { + // given + val library = makeLibrary().copy(emptyTrashAfterScan = true) + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } returns + mapOf( + makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book3")), + makeSeries(name = "series2") to listOf(makeBook("book2")), + ).toScanResult() andThenThrows DirectoryNotFoundException("") + + libraryContentLifecycle.scanRootFolder(library) + + // when + val thrown = catchThrowable { libraryContentLifecycle.scanRootFolder(library) } + + // then + verify(exactly = 2) { mockScanner.scanRootFolder(any()) } + + val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } + val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } + + assertThat(series).hasSize(2) + assertThat(series.map { it.name }).containsExactlyInAnyOrder("series", "series2") + + assertThat(deletedSeries).isEmpty() + + assertThat(books).hasSize(3) + assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1", "book2", "book3") + + assertThat(deletedBooks).isEmpty() + + assertThat(thrown).isExactlyInstanceOf(DirectoryNotFoundException::class.java) + } + } + + @Nested + inner class Restore { + @Test + fun `given existing series when removing files and scanning, restoring files and scanning then restored books are available and media status is not set to outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2") + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), book2)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + bookMetadataRepository.update(bookMetadataRepository.findById(it.id).copy(tags = setOf("my-tag"))) + bookLifecycle.addThumbnailForBook(ThumbnailBook(ByteArray(10), type = ThumbnailBook.Type.USER_UPLOADED, mediaType = "image/jpeg", fileSize = 10L, dimension = Dimension(1, 1), bookId = it.id), MarkSelectedPreference.YES) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + libraryContentLifecycle.scanRootFolder(library) // deletion + + // when + libraryContentLifecycle.scanRootFolder(library) // restore + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + verify(exactly = 3) { mockScanner.scanRootFolder(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + with(allBooks.last()) { + assertThat(mediaRepository.findById(id).status).`as` { "Book media should be kept intact" }.isEqualTo(Media.Status.READY) + assertThat(bookMetadataRepository.findById(id).tags).containsExactlyInAnyOrder("my-tag") + val thumbnail = bookLifecycle.getThumbnail(id) + assertThat(thumbnail).isNotNull + assertThat(thumbnail!!.type).isEqualTo(ThumbnailBook.Type.USER_UPLOADED) + assertThat(thumbnail.fileSize).isEqualTo(10L) + } + } + + @Test + fun `given existing series when deleting all books and scanning then restoring and scanning then Series and Books are available and media status is not set to outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + emptyMap>().toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + seriesRepository.findAll().forEach { series -> + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(language = "en")) } + seriesLifecycle.addThumbnailForSeries(ThumbnailSeries(ByteArray(10), type = ThumbnailSeries.Type.USER_UPLOADED, mediaType = "image/jpeg", fileSize = 10L, dimension = Dimension(1, 1), seriesId = series.id), MarkSelectedPreference.YES) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + libraryContentLifecycle.scanRootFolder(library) // deletion + + // when + libraryContentLifecycle.scanRootFolder(library) // restore + + // then + verify(exactly = 3) { mockScanner.scanRootFolder(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + assertThat(allSeries.map { it.deletedDate }).containsOnlyNulls() + assertThat(allSeries).hasSize(1) + + allSeries.forEach { series -> + assertThat(seriesMetadataRepository.findById(series.id).language).isEqualTo("en") + val thumbnail = seriesLifecycle.getSelectedThumbnail(series.id) + assertThat(thumbnail).isNotNull + assertThat(thumbnail!!.type).isEqualTo(ThumbnailSeries.Type.USER_UPLOADED) + assertThat(thumbnail.fileSize).isEqualTo(10L) + } + + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + assertThat(allBooks).hasSize(2) + + allBooks.forEach { book -> + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + } + + @Nested + inner class FileRename { + @Test + fun `given existing series when renaming 1 file and scanning then renamed book media and generated thumbnails are kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id, fileSize = 0, mediaType = "", dimension = Dimension(0, 0)), MarkSelectedPreference.NO) + bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id, fileSize = 0, mediaType = "", dimension = Dimension(0, 0)), MarkSelectedPreference.NO) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).`as` { "Book name should have changed to match the filename" }.isEqualTo("book3") + assertThat(mediaRepository.findById(id).status).`as` { "Book media should be kept intact" }.isEqualTo(Media.Status.READY) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, setOf(ThumbnailBook.Type.SIDECAR))).hasSize(0) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, setOf(ThumbnailBook.Type.GENERATED))).hasSize(1) + } + } + + @Test + fun `given existing series when renaming 1 file and scanning but series modified time did not change then renamed book media and generated thumbnails are kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + val series = makeSeries(name = "series") + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(series to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series").copy(fileLastModified = series.fileLastModified) to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id, fileSize = 0, mediaType = "", dimension = Dimension(0, 0)), MarkSelectedPreference.NO) + bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id, fileSize = 0, mediaType = "", dimension = Dimension(0, 0)), MarkSelectedPreference.NO) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).`as` { "Book name should have changed to match the filename" }.isEqualTo("book3") + assertThat(mediaRepository.findById(id).status).`as` { "Book media should be kept intact" }.isEqualTo(Media.Status.READY) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, setOf(ThumbnailBook.Type.SIDECAR))).hasSize(0) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, setOf(ThumbnailBook.Type.GENERATED))).hasSize(1) + } + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookLifecycle.markReadProgressCompleted(it.id, user) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + assertThat(readProgressRepository.findByBookIdAndUserIdOrNull(id, user.id)).isNotNull + } + + assertThat(readProgressRepository.findAll()).hasSize(1) + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book is still in read lists`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + readListLifecycle.addReadList(ReadList("read list", bookIds = listOf(it.id).toIndexedMap())) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + + readListRepository.findAllContainingBookId(id, null).let { readLists -> + assertThat(readLists).hasSize(1) + assertThat(readLists.first().name).isEqualTo("read list") + } + } + } + + @Test + fun `given existing series when renaming 1 file with locked title and scanning then renamed book's title is not changed and book metadata is not refreshed for title`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookMetadataRepository.update( + bookMetadataRepository.findById(it.id).copy( + title = "Updated Title", + titleLock = true, + ), + ) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + verify(exactly = 0) { mockTaskEmitter.refreshBookMetadata(bookRenamed, setOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + bookMetadataRepository.findById(id).let { + assertThat(it.title).isEqualTo("Updated Title") + assertThat(it.titleLock).isTrue + } + } + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book's title matches the filename and book metadata is refreshed for title only`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + verify(exactly = 1) { mockTaskEmitter.refreshBookMetadata(withArg { assertThat(it.id).isEqualTo(bookRenamed.id) }, setOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + assertThat(bookMetadataRepository.findById(id).title).`as` { "Book metadata title should have changed to match the filename" }.isEqualTo("book3") + } + } + + @Test + fun `given series when renaming all files in its folder and scanning then series books media is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1))).toScanResult(), + mapOf(makeSeries(name = "series1") to listOf(makeBook("book2").copy(fileSize = 1))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesMetadataRepository.update(seriesMetadataRepository.findById(it.id).copy(summary = "Summary")) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "sameHash")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series1 -> + assertThat(series1.name).isEqualTo("series1") + assertThat(seriesMetadataRepository.findById(series1.id).summary).isEqualTo("Summary") + + val books = bookRepository.findAllBySeriesId(series1.id) + assertThat(books).hasSize(1) + books.first().let { book -> + assertThat(book.name).isEqualTo("book2") + assertThat(bookMetadataRepository.findById(book.id).title).isEqualTo("book2") + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(1) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming all files in its folder but folder modified time is not changed and scanning then series books media is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val series = makeSeries(name = "series1") + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(series to listOf(makeBook("book1").copy(fileSize = 1))).toScanResult(), + mapOf(makeSeries(name = "series1").copy(fileLastModified = series.fileLastModified) to listOf(makeBook("book2").copy(fileSize = 1))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesMetadataRepository.update(seriesMetadataRepository.findById(it.id).copy(summary = "Summary")) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "sameHash")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series1 -> + assertThat(series1.name).isEqualTo("series1") + assertThat(seriesMetadataRepository.findById(series1.id).summary).isEqualTo("Summary") + + val books = bookRepository.findAllBySeriesId(series1.id) + assertThat(books).hasSize(1) + books.first().let { book -> + assertThat(book.name).isEqualTo("book2") + assertThat(bookMetadataRepository.findById(book.id).title).isEqualTo("book2") + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(1) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + } + + @Nested + inner class FileMoveToAnotherFolder { + @Test + @DisplayName("given 2 series when moving 1 file from 1 series to another and scanning then moved book's media is kept") + fun `file moved media kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(mediaRepository.findById(it.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookLifecycle.markReadProgressCompleted(it.id, user) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(readProgressRepository.findByBookIdAndUserIdOrNull(it.id, user.id)).isNotNull + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + assertThat(readProgressRepository.findAll()).hasSize(1) + } + + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book is still in read lists`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + readListLifecycle.addReadList(ReadList("read list", bookIds = listOf(it.id).toIndexedMap())) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + readListRepository.findAllContainingBookId(it.id, null).let { readLists -> + assertThat(readLists).hasSize(1) + assertThat(readLists.first().name).isEqualTo("read list") + } + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given 2 series when moving 1 file with locked title from 1 series to another and scanning then moved book's title is not changed and book metadata is not refreshed for title`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookMetadataRepository.update( + bookMetadataRepository.findById(it.id).copy( + title = "Updated Title", + titleLock = true, + ), + ) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 0) { mockTaskEmitter.refreshBookMetadata(book2Moved, setOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { book2 -> + bookMetadataRepository.findById(book2.id).let { + assertThat(it.title).isEqualTo("Updated Title") + assertThat(it.titleLock).isTrue + } + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + @DisplayName("given 2 series when moving 1 file from 1 series to another and scanning then moved book's title matches the filename and book metadata is refreshed for title only") + fun `file moved title refreshed`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockTaskEmitter.refreshBookMetadata(withArg { assertThat(it.id).isEqualTo(book2Moved.id) }, setOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(bookMetadataRepository.findById(it.id).title).isEqualTo("book2") + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + } + + @Nested + inner class RenameFolder { + @Test + fun `given series when renaming folder and scanning then renamed series books media is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 2) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + val books = bookRepository.findAllBySeriesId(series2.id) + assertThat(books).hasSize(2) + books.forEach { book -> + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder and scanning then renamed series read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesLifecycle.markReadProgressCompleted(it.id, user) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + + seriesDtoRepository.findByIdOrNull(series2.id, user.id)?.let { seriesDto -> + assertThat(seriesDto.booksReadCount).isEqualTo(2) + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + assertThat(readProgressRepository.findAll()).hasSize(2) + } + + @Test + fun `given series when renaming folder and scanning then renamed series is still in collections`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + collectionLifecycle.addCollection(SeriesCollection("collection", seriesIds = listOf(it.id))) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + + collectionRepository.findAllContainingSeriesId(series2.id, null).let { collections -> + assertThat(collections).hasSize(1) + assertThat(collections.first().name).isEqualTo("collection") + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder with locked title and scanning then renamed series title is not changed`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesMetadataRepository.update(seriesMetadataRepository.findById(it.id).copy(title = "Updated", titleLock = true, titleSort = "SortTitle", titleSortLock = true)) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + seriesMetadataRepository.findById(series2.id).let { + assertThat(it.title).isEqualTo("Updated") + assertThat(it.titleLock).isTrue + assertThat(it.titleSort).isEqualTo("SortTitle") + assertThat(it.titleSortLock).isTrue + } + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder and scanning then renamed series title matches the folder name`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + with(seriesMetadataRepository.findById(series2.id)) { + assertThat(title).isEqualTo("series2") + assertThat(titleSort).isEqualTo("series2") + } + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + } + + @Nested + inner class EmptyTrash { + @Test + fun `given library with deleted series and books when emptying the trash then deleted elements are permanently removed`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book3")), + makeSeries(name = "series2") to listOf(makeBook("book2")), + ).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + ) + repeat(2) { libraryContentLifecycle.scanRootFolder(library) } + + // when + libraryContentLifecycle.emptyTrash(library) + + // then + val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } + val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } + + assertThat(series).hasSize(1) + assertThat(series.map { it.name }).containsExactlyInAnyOrder("series") + assertThat(deletedSeries).isEmpty() + + assertThat(books).hasSize(1) + assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1") + assertThat(deletedBooks).isEmpty() + } + + @Test + fun `given series with books when emptying the trash then the series is properly sorted`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"), makeBook("book3"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book2"), makeBook("book3"))).toScanResult(), + ) + repeat(2) { libraryContentLifecycle.scanRootFolder(library) } + + // when + libraryContentLifecycle.emptyTrash(library) + + // then + val series = seriesRepository.findAll().first() + val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number } + + assertThat(books).hasSize(2) + with(books.first()) { + assertThat(name).isEqualTo("book2") + assertThat(number).isEqualTo(1) + } + with(books.last()) { + assertThat(name).isEqualTo("book3") + assertThat(number).isEqualTo(2) + } + } + + @Test + fun `given collection and read list with deleted elements when emptying the trash then those sets are deleted`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"), makeBook("book3"))).toScanResult(), + emptyMap>().toScanResult(), + ) + repeat(2) { libraryContentLifecycle.scanRootFolder(library) } + + collectionRepository.insert(SeriesCollection("collection", seriesIds = seriesRepository.findAllIdsByLibraryId(library.id).toList())) + readListRepository.insert(ReadList("readlist", bookIds = bookRepository.findAllIdsByLibraryId(library.id).toList().toIndexedMap())) + + // when + libraryContentLifecycle.emptyTrash(library) + + // then + val collections = collectionRepository.findAll(pageable = Pageable.unpaged()) + val readLists = readListRepository.findAll(pageable = Pageable.unpaged()) + + assertThat(collections.content).isEmpty() + assertThat(readLists.content).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt new file mode 100644 index 000000000..df4c96d62 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt @@ -0,0 +1,289 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.persistence.LibraryRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.io.FileNotFoundException +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path + +@SpringBootTest +class LibraryLifecycleTest( + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, +) { + @AfterEach + fun `clear repositories`() { + libraryRepository.deleteAll() + } + + @Nested + inner class Add { + @Test + fun `when adding library with non-existent root folder then exception is thrown`() { + // when + val thrown = catchThrowable { libraryLifecycle.addLibrary(Library("test", URL("file:/non-existent"))) } + + // then + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } + + @Test + fun `when adding library with non-directory root folder then exception is thrown`() { + // when + val thrown = + catchThrowable { + libraryLifecycle.addLibrary( + Library( + "test", + Files + .createTempFile(null, null) + .also { it.toFile().deleteOnExit() } + .toUri() + .toURL(), + ), + ) + } + + // then + assertThat(thrown).isInstanceOf(DirectoryNotFoundException::class.java) + } + + @Test + fun `given existing library when adding library with same name then exception is thrown`( + @TempDir path1: Path, + @TempDir path2: Path, + ) { + // given + libraryLifecycle.addLibrary(Library("test", path1.toUri().toURL())) + + // when + val thrown = + catchThrowable { + libraryLifecycle.addLibrary(Library("test", path2.toUri().toURL())) + } + + // then + assertThat(thrown).isInstanceOf(DuplicateNameException::class.java) + } + + @Test + fun `given existing library when adding library with root folder as child of existing library then exception is thrown`( + @TempDir parent: Path, + ) { + // given + libraryLifecycle.addLibrary(Library("parent", parent.toUri().toURL())) + + // when + val child = Files.createTempDirectory(parent, "") + val thrown = + catchThrowable { + libraryLifecycle.addLibrary(Library("child", child.toUri().toURL())) + } + + // then + assertThat(thrown) + .isInstanceOf(PathContainedInPath::class.java) + .hasMessageContaining("child") + } + + @Test + fun `given existing library when adding library with root folder as parent of existing library then exception is thrown`( + @TempDir parent: Path, + ) { + // given + val child = Files.createTempDirectory(parent, null) + libraryLifecycle.addLibrary(Library("child", child.toUri().toURL())) + + // when + val thrown = + catchThrowable { + libraryLifecycle.addLibrary(Library("parent", parent.toUri().toURL())) + } + + // then + assertThat(thrown) + .isInstanceOf(PathContainedInPath::class.java) + .hasMessageContaining("parent") + } + } + + @Nested + inner class Update { + private lateinit var rootFolder: Path + private lateinit var library: Library + + @BeforeAll + fun setup( + @TempDir root: Path, + ) { + rootFolder = root + library = Library("Existing", rootFolder.toUri().toURL()) + } + + @Test + fun `given existing library when updating with non-existent root folder then exception is thrown`() { + // given + val existing = libraryLifecycle.addLibrary(library) + + // when + val toUpdate = existing.copy(name = "test", root = URL("file:/non-existent")) + val thrown = catchThrowable { libraryLifecycle.updateLibrary(toUpdate) } + + // then + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } + + @Test + fun `given existing library when updating with non-directory root folder then exception is thrown`() { + // given + val existing = libraryLifecycle.addLibrary(library) + + // when + val toUpdate = + existing.copy( + name = "test", + root = + Files + .createTempFile(null, null) + .also { it.toFile().deleteOnExit() } + .toUri() + .toURL(), + ) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown).isInstanceOf(DirectoryNotFoundException::class.java) + } + + @Test + fun `given single existing library when updating library with same name then it is updated`() { + // given + val existing = libraryLifecycle.addLibrary(library) + + // when + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(existing) + } + + // then + assertThat(thrown).doesNotThrowAnyException() + } + + @Test + fun `given existing library when updating library with same name then exception is thrown`( + @TempDir path1: Path, + @TempDir path2: Path, + ) { + // given + libraryLifecycle.addLibrary(Library("test", path1.toUri().toURL())) + val existing = libraryLifecycle.addLibrary(library) + + // when + val toUpdate = existing.copy(name = "test", root = path2.toUri().toURL()) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown).isInstanceOf(DuplicateNameException::class.java) + } + + @Test + fun `given single existing library when updating library with root folder as child of existing library then no exception is thrown`() { + // given + val existing = libraryLifecycle.addLibrary(library) + + // when + val child = Files.createTempDirectory(rootFolder, "") + val toUpdate = existing.copy(root = child.toUri().toURL()) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown).doesNotThrowAnyException() + } + + @Test + fun `given existing library when updating library with root folder as child of existing library then exception is thrown`( + @TempDir parent: Path, + ) { + // given + libraryLifecycle.addLibrary(Library("parent", parent.toUri().toURL())) + val existing = libraryLifecycle.addLibrary(library) + + // when + val child = Files.createTempDirectory(parent, "") + val toUpdate = existing.copy(root = child.toUri().toURL()) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown) + .isInstanceOf(PathContainedInPath::class.java) + .hasMessageContaining("child") + } + + @Test + fun `given single existing library when updating library with root folder as parent of existing library then no exception is thrown`( + @TempDir parent: Path, + ) { + // given + val child = Files.createTempDirectory(parent, null) + val existing = libraryLifecycle.addLibrary(Library("child", child.toUri().toURL())) + + // when + val toUpdate = existing.copy(root = parent.toUri().toURL()) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown).doesNotThrowAnyException() + } + + @Test + fun `given existing library when updating library with root folder as parent of existing library then exception is thrown`( + @TempDir parent: Path, + ) { + // given + val child = Files.createTempDirectory(parent, null) + libraryLifecycle.addLibrary(Library("child", child.toUri().toURL())) + val existing = libraryLifecycle.addLibrary(library) + + // when + val toUpdate = existing.copy(root = parent.toUri().toURL()) + val thrown = + catchThrowable { + libraryLifecycle.updateLibrary(toUpdate) + } + + // then + assertThat(thrown) + .isInstanceOf(PathContainedInPath::class.java) + .hasMessageContaining("parent") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataAggregatorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataAggregatorTest.kt new file mode 100644 index 000000000..a47c9fb2a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataAggregatorTest.kt @@ -0,0 +1,69 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class MetadataAggregatorTest { + private val aggregator = MetadataAggregator() + + @Test + fun `given metadatas when aggregating then aggregation is relevant`() { + val metadatas = + listOf( + BookMetadata(title = "ignored", summary = "summary 1", number = "1", numberSort = 1F, authors = listOf(Author("author1", "role1"), Author("author2", "role2")), releaseDate = LocalDate.of(2020, 1, 1), tags = setOf("tag1")), + BookMetadata(title = "ignored", summary = "summary 2", number = "2", numberSort = 2F, authors = listOf(Author("author3", "role3"), Author("author2", "role3")), releaseDate = LocalDate.of(2021, 1, 1), tags = setOf("tag2")), + ) + + val aggregation = aggregator.aggregate(metadatas) + + assertThat(aggregation.authors).hasSize(4) + assertThat(aggregation.tags).hasSize(2) + assertThat(aggregation.releaseDate?.year).isEqualTo(2020) + assertThat(aggregation.summary).isEqualTo("summary 1") + assertThat(aggregation.summaryNumber).isEqualTo("1") + } + + @Test + fun `given metadatas with summary only on second book when aggregating then aggregation has second book's summary`() { + val metadatas = + listOf( + BookMetadata(title = "ignored", number = "1", numberSort = 1F), + BookMetadata(title = "ignored", summary = "summary 2", number = "2", numberSort = 2F), + ) + + val aggregation = aggregator.aggregate(metadatas) + + assertThat(aggregation.summary).isEqualTo("summary 2") + assertThat(aggregation.summaryNumber).isEqualTo("2") + } + + @Test + fun `given metadatas with second book with earlier release date when aggregating then aggregation has release date from second book`() { + val metadatas = + listOf( + BookMetadata(title = "ignored", number = "1", numberSort = 1F, releaseDate = LocalDate.of(2020, 1, 1)), + BookMetadata(title = "ignored", number = "2", numberSort = 2F, releaseDate = LocalDate.of(2019, 1, 1)), + ) + + val aggregation = aggregator.aggregate(metadatas) + + assertThat(aggregation.releaseDate?.year).isEqualTo(2019) + } + + @Test + fun `given metadatas with duplicate authors or tags when aggregating then aggregation has no duplicates`() { + val metadatas = + listOf( + BookMetadata(title = "ignored", number = "1", numberSort = 1F, authors = listOf(Author("author1", "role1"), Author("author2", "role2")), tags = setOf("tag1", "tag2")), + BookMetadata(title = "ignored", number = "2", numberSort = 2F, authors = listOf(Author("author1", "role1"), Author("author2", "role2")), tags = setOf("tag1")), + ) + + val aggregation = aggregator.aggregate(metadatas) + + assertThat(aggregation.authors).hasSize(2) + assertThat(aggregation.tags).hasSize(2) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataApplierTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataApplierTest.kt new file mode 100644 index 000000000..30dbcf7b0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/MetadataApplierTest.kt @@ -0,0 +1,200 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.model.WebLink +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.net.URI +import java.time.LocalDate + +class MetadataApplierTest { + private val metadataApplier = MetadataApplier() + + @Nested + inner class Book { + @Test + fun `given locked metadata when applying patch then metadata is not changed`() { + val metadata = + BookMetadata( + title = "title", + number = "1", + numberSort = 1F, + titleLock = true, + summaryLock = true, + numberLock = true, + numberSortLock = true, + releaseDateLock = true, + authorsLock = true, + tagsLock = true, + isbnLock = true, + linksLock = true, + ) + + val patch = + BookMetadataPatch( + title = "new title", + summary = "new summary", + number = "2", + numberSort = 2F, + releaseDate = LocalDate.of(2020, 12, 2), + authors = listOf(Author("Marcel", "writer")), + isbn = "9782811632397", + links = listOf(WebLink("Comixology", URI("https://www.comixology.com/Sandman/digital-comic/727888"))), + tags = setOf("tag1", "tag2"), + ) + + val patched = metadataApplier.apply(patch, metadata) + + assertThat(patched.title).isEqualTo(metadata.title) + assertThat(patched.number).isEqualTo(metadata.number) + assertThat(patched.numberSort).isEqualTo(metadata.numberSort) + assertThat(patched.summary).isEqualTo("") + assertThat(patched.authors).isEmpty() + assertThat(patched.releaseDate).isNull() + assertThat(patched.tags).isEmpty() + assertThat(patched.isbn).isEqualTo("") + assertThat(patched.links).isEmpty() + assertThat(patched.tags).isEmpty() + } + + @Test + fun `given unlocked metadata when applying patch then metadata is changed`() { + val metadata = + BookMetadata( + title = "title", + number = "1", + numberSort = 1F, + ) + + val patch = + BookMetadataPatch( + title = "new title", + summary = "new summary", + number = "2", + numberSort = 2F, + releaseDate = LocalDate.of(2020, 12, 2), + authors = listOf(Author("Marcel", "writer")), + isbn = "9782811632397", + links = listOf(WebLink("Comixology", URI("https://www.comixology.com/Sandman/digital-comic/727888"))), + tags = setOf("tag1", "tag2"), + ) + + val patched = metadataApplier.apply(patch, metadata) + + assertThat(patched.title).isEqualTo(patch.title) + assertThat(patched.number).isEqualTo(patch.number) + assertThat(patched.numberSort).isEqualTo(patch.numberSort) + assertThat(patched.summary).isEqualTo(patch.summary) + assertThat(patched.authors) + .hasSize(1) + .containsExactlyInAnyOrder( + Author("Marcel", "writer"), + ) + assertThat(patched.releaseDate).isEqualTo(patch.releaseDate) + assertThat(patched.isbn).isEqualTo(patch.isbn) + assertThat(patched.links) + .hasSize(1) + .containsExactlyInAnyOrder( + WebLink("Comixology", URI("https://www.comixology.com/Sandman/digital-comic/727888")), + ) + assertThat(patched.tags as Iterable) + .hasSize(2) + .containsExactlyInAnyOrder("tag1", "tag2") + } + } + + @Nested + inner class Series { + @Test + fun `given locked metadata when applying patch then metadata is not changed`() { + val metadata = + SeriesMetadata( + title = "title", + statusLock = true, + titleLock = true, + titleSortLock = true, + summaryLock = true, + readingDirectionLock = true, + publisherLock = true, + ageRatingLock = true, + languageLock = true, + genresLock = true, + tagsLock = true, + totalBookCountLock = true, + ) + + val patch = + SeriesMetadataPatch( + title = "new title", + titleSort = "new title sort", + status = SeriesMetadata.Status.ENDED, + summary = "new summary", + readingDirection = SeriesMetadata.ReadingDirection.VERTICAL, + publisher = "new publisher", + ageRating = 12, + language = "en", + genres = setOf("shonen"), + totalBookCount = 12, + collections = emptySet(), + ) + + val patched = metadataApplier.apply(patch, metadata) + + assertThat(patched.title).isEqualTo(metadata.title) + assertThat(patched.titleSort).isEqualTo(metadata.titleSort) + assertThat(patched.status).isEqualTo(metadata.status) + assertThat(patched.summary).isEqualTo(metadata.summary) + assertThat(patched.readingDirection).isEqualTo(metadata.readingDirection) + assertThat(patched.publisher).isEqualTo(metadata.publisher) + assertThat(patched.ageRating).isEqualTo(metadata.ageRating) + assertThat(patched.language).isEqualTo(metadata.language) + assertThat(patched.genres).isEmpty() + assertThat(patched.totalBookCount).isNull() + assertThat(patched.tags).isEmpty() + } + + @Test + fun `given unlocked metadata when applying patch then metadata is changed`() { + val metadata = + SeriesMetadata( + title = "title", + ) + + val patch = + SeriesMetadataPatch( + title = "new title", + titleSort = "new title sort", + status = SeriesMetadata.Status.ENDED, + summary = "new summary", + readingDirection = SeriesMetadata.ReadingDirection.VERTICAL, + publisher = "new publisher", + ageRating = 12, + language = "en", + genres = setOf("shonen"), + totalBookCount = 12, + collections = emptySet(), + ) + + val patched = metadataApplier.apply(patch, metadata) + + assertThat(patched.title).isEqualTo(patch.title) + assertThat(patched.titleSort).isEqualTo(patch.titleSort) + assertThat(patched.status).isEqualTo(patch.status) + assertThat(patched.summary).isEqualTo(patch.summary) + assertThat(patched.readingDirection).isEqualTo(patch.readingDirection) + assertThat(patched.publisher).isEqualTo(patch.publisher) + assertThat(patched.ageRating).isEqualTo(patch.ageRating) + assertThat(patched.language).isEqualTo(patch.language) + assertThat(patched.totalBookCount).isEqualTo(patch.totalBookCount) + assertThat(patched.genres as Iterable) + .hasSize(1) + .containsExactlyInAnyOrder("shonen") + assertThat(patched.tags).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt new file mode 100644 index 000000000..caa072cf2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt @@ -0,0 +1,285 @@ +package org.gotson.komga.domain.service + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadListRequest +import org.gotson.komga.domain.model.ReadListRequestBook +import org.gotson.komga.domain.model.ReadListRequestBookMatches +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ReadListMatcherTest( + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val readListMatcher: ReadListMatcher, +) { + private val library = makeLibrary() + + @MockkBean + private lateinit var mockTaskEmitter: TaskEmitter + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + } + + @BeforeEach + fun beforeEach() { + every { mockTaskEmitter.refreshBookMetadata(any(), any()) } just Runs + } + + @AfterAll + fun `teardown library`() { + libraryRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + readListRepository.deleteAll() + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Nested + inner class Match { + private fun Collection.mapIds() = + map { + it.matches + .mapKeys { (series, _) -> series.id } + .mapValues { (_, books) -> books.map { book -> book.id } } + } + + @Test + fun `given request with existing series and books when matching then all requests are matched with a single result`() { + // given + val booksSeries1 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book5", libraryId = library.id), + ) + val series1 = + makeSeries(name = "batman", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries1) + seriesLifecycle.sortBooks(s) + seriesMetadataRepository.findById(s.id).let { + seriesMetadataRepository.update(it.copy(title = "Batman: White Knight")) + } + } + + val booksSeries2 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book2", libraryId = library.id), + ) + val series2 = + makeSeries(name = "joker", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries2) + seriesLifecycle.sortBooks(s) + + bookMetadataRepository.findById(booksSeries2[0].id).let { + bookMetadataRepository.update(it.copy(number = "0025")) + } + } + + val request = + ReadListRequest( + name = "readlist request", + books = + listOf( + ReadListRequestBook(series = setOf("Batman: White Knight"), number = "1"), + ReadListRequestBook(series = setOf("joker"), number = "02"), + ReadListRequestBook(series = setOf("Batman: White Knight"), number = "2"), + ReadListRequestBook(series = setOf("joker"), number = "25"), + ), + ) + + // when + val result = readListMatcher.matchReadListRequest(request) + + // then + with(result) { + with(readListMatch) { + assertThat(name).isEqualTo(request.name) + assertThat(errorCode).isBlank + } + assertThat(requests).hasSize(4) + assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books) + assertThat(requests.mapIds()).isEqualTo( + listOf( + mapOf(series1.id to listOf(booksSeries1[0].id)), + mapOf(series2.id to listOf(booksSeries2[1].id)), + mapOf(series1.id to listOf(booksSeries1[1].id)), + mapOf(series2.id to listOf(booksSeries2[0].id)), + ), + ) + } + } + + @Test + fun `given request with existing read list when matching then result has no readlist name and appropriate error code but correct matches`() { + // given + val booksSeries1 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book5", libraryId = library.id), + ) + val series1 = + makeSeries(name = "batman", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries1) + seriesLifecycle.sortBooks(s) + seriesMetadataRepository.findById(s.id).let { + seriesMetadataRepository.update(it.copy(title = "Batman: White Knight")) + } + } + + val booksSeries2 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book2", libraryId = library.id), + ) + val series2 = + makeSeries(name = "joker", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries2) + seriesLifecycle.sortBooks(s) + + bookMetadataRepository.findById(booksSeries2[0].id).let { + bookMetadataRepository.update(it.copy(number = "0025")) + } + } + + readListLifecycle.addReadList( + ReadList(name = "my ReadList"), + ) + + val request = + ReadListRequest( + name = "my readlist", + books = + listOf( + ReadListRequestBook(series = setOf("batman: white knight"), number = "1"), + ReadListRequestBook(series = setOf("joker"), number = "2"), + ReadListRequestBook(series = setOf("BATMAN: WHITE KNIGHT"), number = "2"), + ReadListRequestBook(series = setOf("joker"), number = "25"), + ), + ) + + // when + val result = readListMatcher.matchReadListRequest(request) + + // then + with(result) { + with(readListMatch) { + assertThat(name).isEqualTo(request.name) + assertThat(errorCode).isEqualTo("ERR_1009") + } + assertThat(requests).hasSize(4) + assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books) + assertThat(requests.mapIds()).isEqualTo( + listOf( + mapOf(series1.id to listOf(booksSeries1[0].id)), + mapOf(series2.id to listOf(booksSeries2[1].id)), + mapOf(series1.id to listOf(booksSeries1[1].id)), + mapOf(series2.id to listOf(booksSeries2[0].id)), + ), + ) + } + } + + @Test + fun `given request and some matching series or books when matching then returns all matches`() { + // given + val booksSeries1 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book5", libraryId = library.id), + ) + val series1 = + makeSeries(name = "batman", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries1) + seriesLifecycle.sortBooks(s) + + bookMetadataRepository.findById(booksSeries1[0].id).let { + bookMetadataRepository.update(it.copy(number = "2")) + } + } + + val booksSeries2 = + listOf( + makeBook("book1", libraryId = library.id), + makeBook("book2", libraryId = library.id), + ) + val series2 = + makeSeries(name = "joker", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + seriesLifecycle.addBooks(s, booksSeries2) + seriesLifecycle.sortBooks(s) + } + makeSeries(name = "joker", libraryId = library.id).also { s -> + seriesLifecycle.createSeries(s) + } + + val request = + ReadListRequest( + name = "readlist", + books = + listOf( + ReadListRequestBook(series = setOf("tokyo ghost"), number = "1"), + ReadListRequestBook(series = setOf("batman"), number = "3"), + ReadListRequestBook(series = setOf("joker"), number = "2"), + ReadListRequestBook(series = setOf("batman"), number = "2"), + ), + ) + + // when + val result = readListMatcher.matchReadListRequest(request) + + // then + with(result) { + with(readListMatch) { + assertThat(name).isEqualTo(request.name) + assertThat(errorCode).isBlank + } + assertThat(requests).hasSize(4) + assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books) + assertThat(requests.mapIds()).isEqualTo( + listOf( + emptyMap(), + emptyMap(), + mapOf(series2.id to listOf(booksSeries2[1].id)), + mapOf(series1.id to listOf(booksSeries1[0].id, booksSeries1[1].id)), + ), + ) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt new file mode 100644 index 000000000..f87d993a5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt @@ -0,0 +1,431 @@ +package org.gotson.komga.domain.service + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.ThumbnailSeries +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository +import org.jooq.exception.DataAccessException +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.nio.file.Files +import java.nio.file.Paths + +@SpringBootTest +class SeriesLifecycleTest( + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val thumbnailSeriesRepository: ThumbnailSeriesRepository, + @Autowired private val thumbnailBookRepository: ThumbnailBookRepository, +) { + @SpykBean + private lateinit var bookLifecycle: BookLifecycle + + @SpykBean + private lateinit var seriesMetadataRepository: SeriesMetadataRepository + + @SpykBean + private lateinit var bookMetadataAggregationRepository: BookMetadataAggregationRepository + + @SpykBean + private lateinit var mediaRepository: MediaRepository + + @SpykBean + private lateinit var bookMetadataRepository: BookMetadataRepository + + private val library = makeLibrary() + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + } + + @AfterAll + fun `teardown library`() { + libraryRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given series with unordered books when saving then books are ordered with natural sort`() { + // given + val books = + listOf( + makeBook("book 1", libraryId = library.id), + makeBook("boôk 05", libraryId = library.id), + makeBook(" book 3", libraryId = library.id), + makeBook("book 4 ", libraryId = library.id), + makeBook("book 6", libraryId = library.id), + makeBook("book 002", libraryId = library.id), + ) + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { + seriesLifecycle.createSeries(it) + } + seriesLifecycle.addBooks(createdSeries, books) + + // when + seriesLifecycle.sortBooks(createdSeries) + + // then + assertThat(seriesRepository.count()).isEqualTo(1) + assertThat(bookRepository.count()).isEqualTo(6) + + val savedBooks = bookRepository.findAllBySeriesId(createdSeries.id).sortedBy { it.number } + assertThat(savedBooks.map { it.name }).containsExactly("book 1", "book 002", " book 3", "book 4 ", "boôk 05", "book 6") + assertThat(savedBooks.map { it.number }).containsExactly(1, 2, 3, 4, 5, 6) + } + + @Test + fun `given series when removing a book then remaining books are indexed in sequence`() { + // given + val books = + listOf( + makeBook("book 1", libraryId = library.id), + makeBook("book 2", libraryId = library.id), + makeBook("book 3", libraryId = library.id), + makeBook("book 4", libraryId = library.id), + ) + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { + seriesLifecycle.createSeries(it) + } + seriesLifecycle.addBooks(createdSeries, books) + seriesLifecycle.sortBooks(createdSeries) + + // when + val book = bookRepository.findAllBySeriesId(createdSeries.id).first { it.name == "book 2" } + bookLifecycle.deleteOne(book) + seriesLifecycle.sortBooks(createdSeries) + + // then + assertThat(seriesRepository.count()).isEqualTo(1) + assertThat(bookRepository.count()).isEqualTo(3) + + val savedBooks = bookRepository.findAllBySeriesId(createdSeries.id).sortedBy { it.number } + assertThat(savedBooks.map { it.name }).containsExactly("book 1", "book 3", "book 4") + assertThat(savedBooks.map { it.number }).containsExactly(1, 2, 3) + } + + @Test + fun `given series when adding a book then all books are indexed in sequence`() { + // given + val books = + listOf( + makeBook("book 1", libraryId = library.id), + makeBook("book 2", libraryId = library.id), + makeBook("book 4", libraryId = library.id), + makeBook("book 5", libraryId = library.id), + ) + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { + seriesLifecycle.createSeries(it) + } + seriesLifecycle.addBooks(createdSeries, books) + seriesLifecycle.sortBooks(createdSeries) + + // when + val book = makeBook("book 3", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(book)) + seriesLifecycle.sortBooks(createdSeries) + + // then + assertThat(seriesRepository.count()).isEqualTo(1) + assertThat(bookRepository.count()).isEqualTo(5) + + val savedBooks = bookRepository.findAllBySeriesId(createdSeries.id).sortedBy { it.number } + assertThat(savedBooks.map { it.name }).containsExactly("book 1", "book 2", "book 3", "book 4", "book 5") + assertThat(savedBooks.map { it.number }).containsExactly(1, 2, 3, 4, 5) + } + + @Nested + inner class Transactions { + @Test + fun `given series when saving and an exception occur while saving metadata then series is not saved`() { + // given + val series = makeSeries(name = "series", libraryId = library.id) + every { seriesMetadataRepository.insert(any()) } throws DataAccessException("") + + // when + val thrown = catchThrowable { seriesLifecycle.createSeries(series) } + + // then + assertThat(thrown).isInstanceOf(RuntimeException::class.java) + assertThat(bookMetadataAggregationRepository.count()).isEqualTo(0) + assertThat(seriesMetadataRepository.count()).isEqualTo(0) + assertThat(seriesRepository.count()).isEqualTo(0) + } + + @Test + fun `given series when saving and an exception occur while saving metadata aggregation then series is not saved`() { + // given + val series = makeSeries(name = "series", libraryId = library.id) + every { bookMetadataAggregationRepository.insert(any()) } throws DataAccessException("") + + // when + val thrown = catchThrowable { seriesLifecycle.createSeries(series) } + + // then + assertThat(thrown).isInstanceOf(RuntimeException::class.java) + assertThat(bookMetadataAggregationRepository.count()).isEqualTo(0) + assertThat(seriesMetadataRepository.count()).isEqualTo(0) + assertThat(seriesRepository.count()).isEqualTo(0) + } + + @Test + fun `given series when adding books and an exception occur while saving media then books are not saved`() { + val books = + listOf( + makeBook("book 1", libraryId = library.id), + ) + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { + seriesLifecycle.createSeries(it) + } + + every { mediaRepository.insert(any>()) } throws DataAccessException("") + + // when + val thrown = catchThrowable { seriesLifecycle.addBooks(createdSeries, books) } + + // then + assertThat(thrown).isInstanceOf(Exception::class.java) + assertThat(mediaRepository.count()).isEqualTo(0) + assertThat(bookMetadataRepository.count()).isEqualTo(0) + assertThat(bookRepository.count()).isEqualTo(0) + } + + @Test + fun `given series when adding books and an exception occur while saving metadata then books are not saved`() { + val books = + listOf( + makeBook("book 1", libraryId = library.id), + ) + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { + seriesLifecycle.createSeries(it) + } + + every { bookMetadataRepository.insert(any>()) } throws DataAccessException("") + + // when + val thrown = catchThrowable { seriesLifecycle.addBooks(createdSeries, books) } + + // then + assertThat(thrown).isInstanceOf(Exception::class.java) + assertThat(mediaRepository.count()).isEqualTo(0) + assertThat(bookMetadataRepository.count()).isEqualTo(0) + assertThat(bookRepository.count()).isEqualTo(0) + } + } + + @Test + fun `given a sidecar thumbnail when deleting then IllegarlArgumentException is thrown`() { + val thumbnail = ThumbnailSeries(type = ThumbnailSeries.Type.SIDECAR, fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + val thrown = catchThrowable { seriesLifecycle.deleteThumbnailForSeries(thumbnail) } + + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `given a series when deleting series then series directory is deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val book1Path = seriesPath.resolve("book1.cbz") + Files.createFile(book1Path) + val book2Path = seriesPath.resolve("book2.cbz") + Files.createFile(book2Path) + val bookSidecarPath = seriesPath.resolve("sidecar1.png") + Files.createFile(bookSidecarPath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val books = + listOf( + makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()), + makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()), + ) + val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, books) + thumbnailBookRepository.insert(bookSidecar) + + // when + seriesLifecycle.deleteSeriesFiles(series) + + // then + assertThat(Files.notExists(seriesPath)) + } + } + + @Test + fun `given a series with a series sidecar when deleting series then series directory is deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val book1Path = seriesPath.resolve("book1.cbz") + Files.createFile(book1Path) + val book2Path = seriesPath.resolve("book2.cbz") + Files.createFile(book2Path) + val bookSidecarPath = seriesPath.resolve("sidecar1.png") + Files.createFile(bookSidecarPath) + val seriesSidecarPath = seriesPath.resolve("cover.png") + Files.createFile(seriesSidecarPath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val books = + listOf( + makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()), + makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()), + ) + val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, books) + thumbnailBookRepository.insert(bookSidecar) + thumbnailSeriesRepository.insert(seriesSidecar) + + // when + seriesLifecycle.deleteSeriesFiles(series) + + // then + assertThat(Files.notExists(seriesPath)) + } + } + + @Test + fun `given a series directory with unrelated files when deleting series then series directory should not be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val book1Path = seriesPath.resolve("book1.cbz") + Files.createFile(book1Path) + val book2Path = seriesPath.resolve("book2.cbz") + Files.createFile(book2Path) + val filePath = seriesPath.resolve("file.txt") + Files.createFile(filePath) + val bookSidecarPath = seriesPath.resolve("sidecar1.png") + Files.createFile(bookSidecarPath) + val seriesSidecarPath = seriesPath.resolve("cover.png") + Files.createFile(seriesSidecarPath) + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val books = + listOf( + makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()), + makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()), + ) + val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, books) + thumbnailBookRepository.insert(bookSidecar) + thumbnailSeriesRepository.insert(seriesSidecar) + + // when + seriesLifecycle.deleteSeriesFiles(series) + + // then + assertThat(Files.exists(seriesPath)) + assertThat(Files.exists(filePath)) + assertThat(Files.notExists(book1Path)) + assertThat(Files.notExists(book2Path)) + assertThat(Files.notExists(bookSidecarPath)) + assertThat(Files.notExists(seriesSidecarPath)) + } + } + + @Test + fun `given a non-existent series directory when deleting series then it returns`() { + // given + val seriesPath = Paths.get("/non-existent") + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + + // when + seriesLifecycle.deleteSeriesFiles(series) + + // then + verify(exactly = 0) { bookLifecycle.softDeleteMany(any()) } + } + + @Test + fun `given a series and a non-existent sidecar file when deleting series then series should be deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + val seriesPath = root.resolve("series") + Files.createDirectory(seriesPath) + val book1Path = seriesPath.resolve("book1.cbz") + Files.createFile(book1Path) + val book2Path = seriesPath.resolve("book2.cbz") + Files.createFile(book2Path) + val bookSidecarPath = seriesPath.resolve("sidecar1.png") + Files.createFile(bookSidecarPath) + val seriesSidecarPath = seriesPath.resolve("cover.png") + + val series = makeSeries(name = "series", libraryId = library.id, url = seriesPath.toUri().toURL()) + val books = + listOf( + makeBook("1", libraryId = library.id, url = book1Path.toUri().toURL()), + makeBook("2", libraryId = library.id, url = book2Path.toUri().toURL()), + ) + val bookSidecar = ThumbnailBook(bookId = books[0].id, type = ThumbnailBook.Type.SIDECAR, url = bookSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + val seriesSidecar = ThumbnailSeries(seriesId = series.id, type = ThumbnailSeries.Type.SIDECAR, url = seriesSidecarPath.toUri().toURL(), fileSize = 0, mediaType = "", dimension = Dimension(0, 0)) + + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, books) + thumbnailBookRepository.insert(bookSidecar) + thumbnailSeriesRepository.insert(seriesSidecar) + + // when + seriesLifecycle.deleteSeriesFiles(series) + + // then + assertThat(Files.notExists(seriesPath)) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt new file mode 100644 index 000000000..3797f41b3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt @@ -0,0 +1,362 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.SyncPoint.ReadList.Companion.ON_DECK_ID +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.language.toZonedDateTime +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +@SpringBootTest +class SyncPointLifecycleTest( + @Autowired private val syncPointLifecycle: SyncPointLifecycle, + @Autowired private val syncPointRepository: SyncPointRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, +) { + private val library1 = makeLibrary() + private val library2 = makeLibrary() + private val library3 = makeLibrary() + private val user1 = + KomgaUser( + "user1@example.org", + "", + sharedLibrariesIds = setOf(library1.id, library2.id), + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(18, AllowExclude.EXCLUDE), + labelsExclude = setOf("exclude"), + ), + ) + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library1) + libraryRepository.insert(library2) + libraryRepository.insert(library3) + userRepository.insert(user1) + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + syncPointRepository.deleteAll() + userRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given user when creating syncpoint then all in-scope books are included`() { + // given + val bookValid = makeBook("valid", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now()) + val bookExcludedByAge = makeBook("age restriction", libraryId = library1.id) + val bookExcludedByLabel = makeBook("label restriction", libraryId = library1.id) + val bookDeleted = makeBook("deleted", libraryId = library1.id).copy(deletedDate = LocalDateTime.now()) + val bookNotReady = makeBook("media not ready", libraryId = library1.id) + val bookNotEpub = makeBook("not epub", libraryId = library1.id) + val bookOtherLibrary = makeBook("lib not in list", libraryId = library2.id) + val bookUnauthorizedLibrary = makeBook("unauthorized lib", libraryId = library3.id) + + makeSeries(name = "series1", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookValid, bookDeleted, bookNotReady, bookNotEpub)) + } + } + makeSeries(name = "series age restricted", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookExcludedByAge)) + } + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(ageRating = 20)) } + } + makeSeries(name = "series label restricted", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookExcludedByLabel)) + } + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("exclude"))) } + } + makeSeries(name = "series2", libraryId = library2.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookOtherLibrary)) + } + } + makeSeries(name = "series3", libraryId = library3.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookUnauthorizedLibrary)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + mediaRepository.findById(bookNotReady.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.ERROR)) } + mediaRepository.findById(bookNotEpub.id).let { media -> mediaRepository.update(media.copy(mediaType = MediaType.ZIP.type)) } + + // when + val syncPoint = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val syncPointBooks = syncPointRepository.findBooksById(syncPoint.id, false, Pageable.unpaged()) + + // then + assertThat(syncPoint.userId).isEqualTo(user1.id) + assertThat(syncPointBooks).hasSize(1) + with(syncPointBooks.first()) { + assertThat(this.bookId).isEqualTo(bookValid.id) + assertThat(this.fileHash).isEqualTo(bookValid.fileHash) + assertThat(this.fileSize).isEqualTo(bookValid.fileSize) + assertThat(this.fileLastModified).isCloseTo(bookValid.fileLastModified.toZonedDateTime(), within(1, ChronoUnit.SECONDS)) + } + } + + @Test + fun `given syncpoint when adding new books then syncpoint diff contains new books`() { + // given + val book1 = makeBook("valid", libraryId = library1.id) + + val series = + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1)) + } + } + + mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + val book2 = makeBook("valid", libraryId = library1.id) + val book3 = makeBook("valid", libraryId = library1.id) + seriesLifecycle.addBooks(series, listOf(book2, book3)) + mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + mediaRepository.findById(book3.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksAdded = syncPointRepository.findBooksAdded(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + + // then + assertThat(booksAdded).hasSize(2) + assertThat(booksAdded.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page2.map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page1.map { it.bookId }) + } + + @Test + fun `given syncpoint when deleting books then syncpoint diff contains removed books`() { + // given + val book1 = makeBook("valid1", libraryId = library1.id) + val book2 = makeBook("valid2", libraryId = library1.id) + val book3 = makeBook("valid3", libraryId = library1.id) + + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2, book3)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + bookLifecycle.softDeleteMany(listOf(bookRepository.findByIdOrNull(book2.id)!!)) + bookLifecycle.deleteOne(bookRepository.findByIdOrNull(book3.id)!!) + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksRemoved = syncPointRepository.findBooksRemoved(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + + // then + assertThat(booksRemoved).hasSize(2) + assertThat(booksRemoved.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page2.map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page1.map { it.bookId }) + } + + @Test + fun `given syncpoint when changing books then syncpoint diff contains changed books`() { + // given + val book1 = makeBook("valid1", libraryId = library1.id) + val book2 = makeBook("valid2", libraryId = library1.id).copy(fileSize = 1, fileHash = "hash1") + val book3 = makeBook("valid3", libraryId = library1.id) + val book4 = makeBook("no hash to hash", libraryId = library1.id) + + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2, book3)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + bookRepository.findByIdOrNull(book1.id)?.let { bookRepository.update(it.copy(fileLastModified = LocalDateTime.of(2020, 1, 1, 1, 1))) } + bookRepository.findByIdOrNull(book2.id)?.let { bookRepository.update(it.copy(fileHash = "hash2")) } + bookMetadataRepository.findById(book3.id).let { bookMetadataRepository.update(it.copy(title = "changed")) } + bookRepository.findByIdOrNull(book4.id)?.let { bookRepository.update(it.copy(fileHash = "hash")) } // not included in changed books if it had no hash before + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksChanged = syncPointRepository.findBooksChanged(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + val page3 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + + // then + assertThat(booksChanged).hasSize(3) + assertThat(booksChanged.map { it.bookId }).containsExactlyInAnyOrder(book1.id, book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page2 + page3).map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page1 + page3).map { it.bookId }) + assertThat(page3).hasSize(1) + assertThat(page3.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page1 + page2).map { it.bookId }) + } + + @Test + fun `given syncpoint when books are read then syncpoint diff contains on deck read list`() { + // given + val book1 = makeBook("book 1", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 1) + val book2 = makeBook("book 2", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 2) + val book3 = makeBook("book 3", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 3) + + makeSeries(name = "series1", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2, book3)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + + // first sync point + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val syncPoint1ReadLists = syncPointRepository.findReadListsById(syncPoint1.id, false, Pageable.unpaged()) + + assertThat(syncPoint1ReadLists).isEmpty() + + // book marked as read + bookLifecycle.markReadProgressCompleted(book1.id, user1) + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // on deck is present and has 1 book + val syncPoint2ReadLists = syncPointRepository.findReadListsById(syncPoint2.id, false, Pageable.unpaged()) + val rlAdded1to2 = syncPointRepository.findReadListsAdded(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val rlChanged1to2 = syncPointRepository.findReadListsChanged(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val rlRemoved1to2 = syncPointRepository.findReadListsRemoved(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val syncPoint2Page1 = syncPointLifecycle.takeReadListsAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + val syncPoint2Page2 = syncPointLifecycle.takeReadListsAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + + assertThat(syncPoint2ReadLists).hasSize(1) + assertThat(rlAdded1to2).containsExactlyInAnyOrderElementsOf(syncPoint2ReadLists) + assertThat(rlChanged1to2).isEmpty() + assertThat(rlRemoved1to2).isEmpty() + with(syncPoint2ReadLists.first()) { + assertThat(this.readListId).isEqualTo(ON_DECK_ID) + assertThat(this.createdDate).isCloseTo(ZonedDateTime.now(), within(1, ChronoUnit.SECONDS)) + assertThat(this.lastModifiedDate).isCloseTo(ZonedDateTime.now(), within(1, ChronoUnit.SECONDS)) + } + assertThat(syncPoint2Page1).containsExactlyInAnyOrderElementsOf(rlAdded1to2) + assertThat(syncPoint2Page2).isEmpty() + val syncPoint2OnDeckBooks = syncPointRepository.findBookIdsByReadListIds(syncPoint2.id, listOf(ON_DECK_ID)) + assertThat(syncPoint2OnDeckBooks.map { it.bookId }) + .hasSize(1) + .containsExactlyInAnyOrder(book2.id) + + // 2nd book marked as read, on deck is still present but has changed + bookLifecycle.markReadProgressCompleted(book2.id, user1) + val syncPoint3 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + val syncPoint3ReadLists = syncPointRepository.findReadListsById(syncPoint3.id, false, Pageable.unpaged()) + val rlAdded2to3 = syncPointRepository.findReadListsAdded(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged()) + val rlChanged2to3 = syncPointRepository.findReadListsChanged(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged()) + val rlRemoved2to3 = syncPointRepository.findReadListsRemoved(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged()) + val syncPoint3Page1 = syncPointLifecycle.takeReadListsChanged(syncPoint2.id, syncPoint3.id, Pageable.ofSize(1)) + val syncPoint3Page2 = syncPointLifecycle.takeReadListsChanged(syncPoint2.id, syncPoint3.id, Pageable.ofSize(1)) + + assertThat(syncPoint3ReadLists.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID) + assertThat(rlChanged2to3.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID) + assertThat(rlAdded2to3).isEmpty() + assertThat(rlRemoved2to3).isEmpty() + assertThat(syncPoint3Page1).containsExactlyInAnyOrderElementsOf(rlChanged2to3) + assertThat(syncPoint3Page2).isEmpty() + + val syncPoint3OnDeckBooks = syncPointRepository.findBookIdsByReadListIds(syncPoint3.id, listOf(ON_DECK_ID)) + assertThat(syncPoint3OnDeckBooks.map { it.bookId }) + .hasSize(1) + .containsExactlyInAnyOrder(book3.id) + + // 3rd book marked as read, whole series is read now - on deck is not present anymore + bookLifecycle.markReadProgressCompleted(book3.id, user1) + val syncPoint4 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + val syncPoint4ReadLists = syncPointRepository.findReadListsById(syncPoint4.id, false, Pageable.unpaged()) + val rlAdded3to4 = syncPointRepository.findReadListsAdded(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged()) + val rlChanged3to4 = syncPointRepository.findReadListsChanged(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged()) + val rlRemoved3to4 = syncPointRepository.findReadListsRemoved(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged()) + val syncPoint4Page1 = syncPointLifecycle.takeReadListsRemoved(syncPoint3.id, syncPoint4.id, Pageable.ofSize(1)) + val syncPoint4Page2 = syncPointLifecycle.takeReadListsRemoved(syncPoint3.id, syncPoint4.id, Pageable.ofSize(1)) + + assertThat(syncPoint4ReadLists).isEmpty() + assertThat(rlAdded3to4).isEmpty() + assertThat(rlChanged3to4).isEmpty() + assertThat(rlRemoved3to4).hasSize(1) + assertThat(rlRemoved3to4.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID) + assertThat(syncPoint4Page1).containsExactlyInAnyOrderElementsOf(rlRemoved3to4) + assertThat(syncPoint4Page2).isEmpty() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt new file mode 100644 index 000000000..371da1d98 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt @@ -0,0 +1,115 @@ +package org.gotson.komga.domain.service + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.model.TransientBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.metadata.comicrack.ComicInfoProvider +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class TransientBookLifecycleTest( + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val transientBookLifecycle: TransientBookLifecycle, +) { + private val library = makeLibrary() + + @SpykBean + private lateinit var mockProvider: ComicInfoProvider + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + } + + @AfterEach + fun cleanup() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `when getting metadata for transient book then the most specific series name is matched first`() { + seriesLifecycle.createSeries(makeSeries("Batman and Robin", libraryId = library.id)) + val seriesExact = makeSeries("Batman", libraryId = library.id) + seriesLifecycle.createSeries(seriesExact) + seriesLifecycle.createSeries(makeSeries("Batman and Robin (2022)", libraryId = library.id)) + + val book = + TransientBook( + makeBook("whatever"), + Media(), + ) + + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, 15F, null, null, null, null, null, emptyList()) + every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch("BATMAN", null, null, null, null, null, null, null, null, null, emptySet()) + + val (seriesId, number) = transientBookLifecycle.getMetadata(book) + + assertThat(seriesId).isEqualTo(seriesExact.id) + assertThat(number).isEqualTo(15F) + } + + @Test + fun `when getting metadata for transient book without series then no series is matched`() { + seriesLifecycle.createSeries(makeSeries("Batman and Robin", libraryId = library.id)) + val seriesExact = makeSeries("Batman", libraryId = library.id) + seriesLifecycle.createSeries(seriesExact) + seriesLifecycle.createSeries(makeSeries("Batman and Robin (2022)", libraryId = library.id)) + + val book = + TransientBook( + makeBook("whatever"), + Media(), + ) + + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList()) + every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(null, null, null, null, null, null, null, null, null, null, emptySet()) + + val (seriesId, number) = transientBookLifecycle.getMetadata(book) + + assertThat(seriesId).isNull() + assertThat(number).isNull() + } + + @Test + fun `when getting metadata for transient book with blank series then no series is matched`() { + seriesLifecycle.createSeries(makeSeries("Batman and Robin", libraryId = library.id)) + val seriesExact = makeSeries("Batman", libraryId = library.id) + seriesLifecycle.createSeries(seriesExact) + seriesLifecycle.createSeries(makeSeries("Batman and Robin (2022)", libraryId = library.id)) + + val book = + TransientBook( + makeBook("whatever"), + Media(), + ) + + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList()) + every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(" ", null, null, null, null, null, null, null, null, null, emptySet()) + + val (seriesId, number) = transientBookLifecycle.getMetadata(book) + + assertThat(seriesId).isNull() + assertThat(number).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfigurationTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfigurationTest.kt new file mode 100644 index 000000000..ee3132f6d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/datasource/DataSourcesConfigurationTest.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.infrastructure.datasource + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import javax.sql.DataSource + +class DataSourcesConfigurationTest { + @SpringBootTest + @Nested + inner class WalMode( + @Autowired private val dataSourceRW: DataSource, + @Autowired @Qualifier("sqliteDataSourceRO") private val dataSourceRO: DataSource, + @Autowired @Qualifier("tasksDataSourceRW") private val tasksDataSourceRW: DataSource, + @Autowired @Qualifier("tasksDataSourceRO") private val tasksDataSourceRO: DataSource, + ) { + @Test + fun `given wal mode when autoriwiring beans then bean instances are different between RW and RO`() { + assertThat(dataSourceRW).isNotSameAs(dataSourceRO) + assertThat(tasksDataSourceRW).isNotSameAs(tasksDataSourceRO) + } + } + + @SpringBootTest + @ActiveProfiles("test", "memorydb") + @Nested + inner class MemoryMode( + @Autowired private val dataSourceRW: DataSource, + @Autowired @Qualifier("sqliteDataSourceRO") private val dataSourceRO: DataSource, + @Autowired @Qualifier("tasksDataSourceRW") private val tasksDataSourceRW: DataSource, + @Autowired @Qualifier("tasksDataSourceRO") private val tasksDataSourceRO: DataSource, + ) { + @Test + fun `given wal mode when autoriwiring beans then bean instances are the same between RW and RO`() { + assertThat(dataSourceRW).isSameAs(dataSourceRO) + assertThat(tasksDataSourceRW).isSameAs(tasksDataSourceRO) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/TestUtils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/TestUtils.kt new file mode 100644 index 000000000..16e79cdf5 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/TestUtils.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.within +import org.assertj.core.data.TemporalUnitOffset +import java.time.temporal.ChronoUnit + +val offset: TemporalUnitOffset = within(3, ChronoUnit.SECONDS) diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDaoTest.kt new file mode 100644 index 000000000..275e1dca1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDaoTest.kt @@ -0,0 +1,203 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.Pageable +import java.net.URL +import java.time.LocalDateTime + +@SpringBootTest +class BookDaoTest( + @Autowired private val bookDao: BookDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val series = makeSeries("Series") + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + seriesRepository.insert(series.copy(libraryId = library.id)) + } + + @AfterEach + fun deleteBooks() { + bookDao.deleteAll() + assertThat(bookDao.count()).isEqualTo(0) + } + + @AfterAll + fun tearDown() { + seriesRepository.deleteAll() + libraryRepository.deleteAll() + } + + @Test + fun `given a book when inserting then it is persisted`() { + val now = LocalDateTime.now() + val book = + Book( + name = "Book", + url = URL("file://book"), + fileLastModified = now, + fileSize = 3, + fileHash = "abc", + seriesId = series.id, + libraryId = library.id, + deletedDate = LocalDateTime.now(), + ) + + bookDao.insert(book) + val created = bookDao.findByIdOrNull(book.id)!! + + assertThat(created.id).isNotEqualTo(0) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + assertThat(created.name).isEqualTo(book.name) + assertThat(created.url).isEqualTo(book.url) + assertThat(created.fileLastModified).isEqualToIgnoringNanos(book.fileLastModified) + assertThat(created.fileSize).isEqualTo(book.fileSize) + assertThat(created.fileHash).isEqualTo(book.fileHash) + assertThat(created.deletedDate).isEqualToIgnoringNanos(book.deletedDate) + } + + @Test + fun `given existing book when updating then it is persisted`() { + val book = + Book( + name = "Book", + url = URL("file://book"), + fileLastModified = LocalDateTime.now(), + fileSize = 3, + seriesId = series.id, + libraryId = library.id, + ) + bookDao.insert(book) + + val modificationDate = LocalDateTime.now() + + val updated = + with(bookDao.findByIdOrNull(book.id)!!) { + copy( + name = "Updated", + url = URL("file://updated"), + fileLastModified = modificationDate, + fileSize = 5, + fileHash = "def", + deletedDate = LocalDateTime.now(), + ) + } + + bookDao.update(updated) + val modified = bookDao.findByIdOrNull(updated.id)!! + + assertThat(modified.id).isEqualTo(updated.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.name).isEqualTo("Updated") + assertThat(modified.url).isEqualTo(URL("file://updated")) + assertThat(modified.fileLastModified).isEqualToIgnoringNanos(modificationDate) + assertThat(modified.fileSize).isEqualTo(5) + assertThat(modified.fileHash).isEqualTo("def") + assertThat(modified.deletedDate).isEqualToIgnoringNanos(updated.deletedDate) + } + + @Test + fun `given existing book when finding by id then book is returned`() { + val book = + Book( + name = "Book", + url = URL("file://book"), + fileLastModified = LocalDateTime.now(), + fileSize = 3, + seriesId = series.id, + libraryId = library.id, + ) + bookDao.insert(book) + + val found = bookDao.findByIdOrNull(book.id) + + assertThat(found).isNotNull + assertThat(found?.name).isEqualTo("Book") + } + + @Test + fun `given non-existing book when finding by id then null is returned`() { + val found = bookDao.findByIdOrNull("128742") + + assertThat(found).isNull() + } + + @Test + fun `given some books when finding all then all are returned`() { + bookDao.insert(makeBook("1", libraryId = library.id, seriesId = series.id)) + bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id)) + + val found = bookDao.findAll() + + assertThat(found).hasSize(2) + } + + @Test + fun `given some books when searching then results are returned`() { + bookDao.insert(makeBook("1", libraryId = library.id, seriesId = series.id)) + bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id)) + + val search = + SearchCondition.AllOfBook( + SearchCondition.LibraryId(SearchOperator.Is(library.id)), + SearchCondition.SeriesId(SearchOperator.Is(series.id)), + ) + val found = bookDao.findAll(search, SearchContext.empty(), Pageable.unpaged()).content + + assertThat(found).hasSize(2) + } + + @Test + fun `given some books when finding by libraryId then results are returned`() { + bookDao.insert(makeBook("1", libraryId = library.id, seriesId = series.id)) + bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id)) + + val found = bookDao.findAllIdsByLibraryId(library.id) + + assertThat(found).hasSize(2) + } + + @Test + fun `given some books when finding by seriesId then results are returned`() { + bookDao.insert(makeBook("1", libraryId = library.id, seriesId = series.id)) + bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id)) + + val found = bookDao.findAllIdsBySeriesId(series.id) + + assertThat(found).hasSize(2) + } + + @Test + fun `given some books when deleting all then count is zero`() { + bookDao.insert(makeBook("1", libraryId = library.id, seriesId = series.id)) + bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id)) + + bookDao.deleteAll() + + assertThat(bookDao.count()).isEqualTo(0) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDaoTest.kt new file mode 100644 index 000000000..d2be12f65 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDaoTest.kt @@ -0,0 +1,949 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.search.SearchIndexLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.net.URL +import java.time.LocalDate +import java.time.LocalDateTime + +@SpringBootTest +class BookDtoDaoTest( + @Autowired private val bookDtoDao: BookDtoDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val searchIndexLifecycle: SearchIndexLifecycle, +) { + private val library = makeLibrary() + private var series = makeSeries("Series") + private val user = KomgaUser("user@example.org", "") + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + every { mockEventPublisher.publishEvent(any()) } just Runs + libraryRepository.insert(library) + series = seriesLifecycle.createSeries(series.copy(libraryId = library.id)) + userRepository.insert(user) + } + + @BeforeEach + fun resetMocks() { + every { mockEventPublisher.publishEvent(any()) } just Runs + } + + @AfterEach + fun deleteBooks() { + bookLifecycle.deleteMany(bookRepository.findAll()) + searchIndexLifecycle.rebuildIndex() + } + + @AfterAll + fun tearDown() { + every { mockEventPublisher.publishEvent(any()) } just Runs + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + private fun setupBooks() { + seriesLifecycle.addBooks( + series, + (1..3).map { + makeBook("$it", seriesId = series.id, libraryId = library.id) + }, + ) + + val books = bookRepository.findAll().sortedBy { it.name } + books.elementAt(0).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, false)) } + books.elementAt(1).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) } + } + + @Nested + inner class Criteria { + @Test + fun `given books when searching by multiple tags then results are matched and not duplicated`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + val book2 = makeBook("Éric le bleu", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + book2, + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("tag1", "tag2"))) + } + bookMetadataRepository.findById(book2.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("tag1", "tag2"))) + } + + // when + val page = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.Tag(SearchOperator.Is("tag1")), + SearchCondition.Tag(SearchOperator.Is("tag2")), + ), + ), + SearchContext(user), + Pageable.unpaged(), + ) + + // then + assertThat(page.totalElements).isEqualTo(2) + assertThat(page.content).hasSize(2) + assertThat(page.content.map { it.metadata.title }).containsExactly("Éric le rouge", "Éric le bleu") + } + + @Test + fun `given books when searching by multiple authors then results are matched and not duplicated`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + val book2 = makeBook("Éric le bleu", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + book2, + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("Mark", "writer"), Author("Jim", "inker")))) + } + bookMetadataRepository.findById(book2.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("Mark", "writer"), Author("Jim", "inker")))) + } + + // when + val page = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("Mark", "writer"))), + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("Jim", "inker"))), + ), + ), + SearchContext(user), + Pageable.unpaged(), + ) + + // then + assertThat(page.totalElements).isEqualTo(2) + assertThat(page.content).hasSize(2) + assertThat(page.content.map { it.metadata.title }).containsExactly("Éric le rouge", "Éric le bleu") + } + } + + @Nested + inner class ReadProgress { + @Test + fun `given books in various read status when searching for read books then only read books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(1) + assertThat(found.first().readProgress?.completed).isTrue + assertThat(found.first().name).isEqualTo("2") + } + + @Test + fun `given books in various read status when searching for unread books then only unread books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(1) + assertThat(found.first().readProgress).isNull() + assertThat(found.first().name).isEqualTo("3") + } + + @Test + fun `given books in various read status when searching for in progress books then only in progress books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(1) + assertThat(found.first().readProgress?.completed).isFalse + assertThat(found.first().name).isEqualTo("1") + } + + @Test + fun `given books in various read status when searching for read and unread books then only matching books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3") + } + + @Test + fun `given books in various read status when searching for read and in progress books then only matching books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "1") + } + + @Test + fun `given books in various read status when searching for unread and in progress books then only matching books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "1") + } + + @Test + fun `given books in various read status when searching for read and unread and in progress books then only matching books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "1", "2") + } + + @Test + fun `given books in various read status when searching without read progress then all books are returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAll( + BookSearch(), + SearchContext(user), + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "1", "2") + } + } + + @Nested + inner class OnDeck { + @Test + fun `given series with in progress books status when searching for on deck then nothing is returned`() { + // given + setupBooks() + + // when + val found = + bookDtoDao.findAllOnDeck( + user.id, + null, + PageRequest.of(0, 20), + ) + + // then + assertThat(found).isEmpty() + } + + @Test + fun `given series with only unread books when searching for on deck then no books are returned`() { + // given + seriesLifecycle.addBooks( + series, + (1..3).map { + makeBook("$it", seriesId = series.id, libraryId = library.id) + }, + ) + + // when + val found = + bookDtoDao.findAllOnDeck( + user.id, + null, + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(0) + } + + @Test + fun `given series with some unread books when searching for on deck then first unread book of series is returned`() { + // given + seriesLifecycle.addBooks( + series, + (1..3).map { + makeBook("$it", seriesId = series.id, libraryId = library.id).copy(number = it) + }, + ) + + val books = bookRepository.findAll().sortedBy { it.name } + books.first().let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) } + + // when + val found = + bookDtoDao.findAllOnDeck( + user.id, + null, + PageRequest.of(0, 20), + ) + + // then + assertThat(found).hasSize(1) + assertThat(found.first().name).isEqualTo("2") + } + } + + @Nested + inner class FullTextSearch { + @Test + fun `given books when searching by term then results are ordered by rank`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("The incredible adventures of Batman, the man who is also a bat!", seriesId = series.id, libraryId = library.id), + makeBook("Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman and Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "batman"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactly("Batman", "Batman and Robin", "The incredible adventures of Batman, the man who is also a bat!") + } + + @Test + fun `given books when searching by term and sort order then results are ordered by sort order`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Book 3", seriesId = series.id, libraryId = library.id), + makeBook("Book 1", seriesId = series.id, libraryId = library.id), + makeBook("Book 2", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "book"), + SearchContext(user), + UnpagedSorted(Sort.by("name")), + ).content + val pages = + (0..2).map { + bookDtoDao.findAll( + BookSearch(fullTextSearch = "book"), + SearchContext(user), + PageRequest.of(it, 1, Sort.by("name")), + ) + } + val page0 = + bookDtoDao.findAll( + BookSearch(fullTextSearch = "book"), + SearchContext(user), + PageRequest.of(0, 2, Sort.by("name")), + ) + val page1 = + bookDtoDao.findAll( + BookSearch(fullTextSearch = "book"), + SearchContext(user), + PageRequest.of(1, 2, Sort.by("name")), + ) + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactly("Book 1", "Book 2", "Book 3") + + assertThat(pages).hasSize(3) + assertThat(pages.map { it.totalPages }).containsOnly(3) + assertThat(pages.map { it.totalElements }).containsOnly(3) + assertThat(pages.map { it.size }).containsOnly(1) + assertThat(pages.flatMap { it.content }.map { it.name }).containsExactly("Book 1", "Book 2", "Book 3") + + assertThat(page0).hasSize(2) + assertThat(page0.content.map { it.name }).containsExactly("Book 1", "Book 2") + assertThat(page1).hasSize(1) + assertThat(page1.content.map { it.name }).containsExactly("Book 3") + } + + @Test + fun `given books when searching by term with accent then results are matched accent insensitive`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + makeBook("Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman and Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(title = "Éric le bleu")) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "eric"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le bleu") + } + + @Test + fun `given books when searching by ISBN then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + makeBook("Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman and Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(isbn = "9782413016878")) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "9782413016878"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge") + } + + @Test + fun `given books when searching by tags then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("tag1"))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "tag:tag1"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge") + } + + @Test + fun `given books when searching by authors then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks( + series, + listOf( + book1, + ), + ) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("bob", "writer")))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val foundGeneric = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "author:bob"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + val foundByRole = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "writer:bob"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + val notFound = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "penciller:bob"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(foundGeneric).hasSize(1) + assertThat(foundGeneric.map { it.metadata.title }).containsExactly("Éric le rouge") + assertThat(foundByRole).hasSize(1) + assertThat(foundByRole.map { it.metadata.title }).containsExactly("Éric le rouge") + assertThat(notFound).isEmpty() + } + + @Test + fun `given books when searching by release year then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book1)) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(1999, 5, 12))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "release_date:1999"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge") + } + + @Test + fun `given books when searching by release year range then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + val book2 = makeBook("Éric le bleu", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book1, book2)) + + bookMetadataRepository.findById(book1.id).let { + bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(1999, 5, 12))) + } + bookMetadataRepository.findById(book2.id).let { + bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2005, 5, 12))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "release_date:[1990 TO 2010]"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge", "Éric le bleu") + } + + @Test + fun `given books when searching by media status then results are matched`() { + // given + val book1 = makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book1)) + + mediaRepository.findById(book1.id).let { + mediaRepository.update(it.copy(status = Media.Status.ERROR)) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "status:error"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge") + } + + @Test + fun `given books when searching by deleted then results are matched`() { + // given + val book1 = + makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id) + .copy(deletedDate = LocalDateTime.now()) + seriesLifecycle.addBooks( + series, + listOf( + book1, + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "deleted:true"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Éric le rouge") + } + + @Test + fun `given books with dots in title when searching by title then results are matched`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("S.W.O.R.D.", seriesId = series.id, libraryId = library.id), + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "s.w.o.r.d"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("S.W.O.R.D.") + } + + @Test + fun `given books when searching with multiple words then results are matched`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Éric le rouge", seriesId = series.id, libraryId = library.id), + makeBook("Robin and Batman", seriesId = series.id, libraryId = library.id), + makeBook("Batman and Robin", seriesId = series.id, libraryId = library.id), + makeBook("Batman", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "batman robin"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.metadata.title }).containsExactlyInAnyOrder("Batman and Robin", "Robin and Batman") + } + + @Test + fun `given books when searching by term containing hyphens then results are ordered by rank`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Batman", seriesId = series.id, libraryId = library.id), + makeBook("Another X-Men adventure", seriesId = series.id, libraryId = library.id), + makeBook("X-Men", seriesId = series.id, libraryId = library.id), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "x-men"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactly("X-Men", "Another X-Men adventure") + } + + @Test + fun `when searching by unknown field then empty result are returned and no exception is thrown`() { + assertThatCode { + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "publisher:batman"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(0) + }.doesNotThrowAnyException() + } + + @Test + fun `given books in CJK when searching by CJK term then results are ordered by rank`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("[不道德公會][河添太一 ][東立]Vol.04-搬运", seriesId = series.id, libraryId = library.id, url = URL("file:/file.cbz")), + ), + ) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + bookDtoDao + .findAll( + BookSearch(fullTextSearch = "不道德"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + } + } + + @Nested + inner class Duplicates { + @Test + fun `given books with same hash and size when searching then results are returned`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Book 1", seriesId = series.id, libraryId = library.id).copy(fileHash = "hashed", fileSize = 10), + makeBook("Book 2", seriesId = series.id, libraryId = library.id).copy(fileHash = "hashed", fileSize = 10), + ), + ) + + // when + val found = bookDtoDao.findAllDuplicates(user.id, Pageable.unpaged()).content + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("Book 1", "Book 2") + } + + @Test + fun `given books with same hash but different size when searching then no results are returned`() { + // given + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Book 1", seriesId = series.id, libraryId = library.id).copy(fileHash = "hashed", fileSize = 10), + makeBook("Book 2", seriesId = series.id, libraryId = library.id).copy(fileHash = "hashed", fileSize = 12), + ), + ) + + // when + val found = bookDtoDao.findAllDuplicates(user.id, Pageable.unpaged()).content + + // then + assertThat(found).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDaoTest.kt new file mode 100644 index 000000000..8f95f549c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDaoTest.kt @@ -0,0 +1,182 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadataAggregation +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDate +import java.time.LocalDateTime + +@SpringBootTest +class BookMetadataAggregationDaoTest( + @Autowired private val bookMetadataAggregationDao: BookMetadataAggregationDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + } + + @AfterEach + fun deleteSeries() { + seriesRepository.findAll().forEach { + bookMetadataAggregationDao.delete(it.id) + } + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given a bookMetadataAggregation when inserting then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val now = LocalDateTime.now() + val metadata = + BookMetadataAggregation( + authors = listOf(Author("author", "role")), + tags = setOf("tag1", "tag2"), + releaseDate = LocalDate.now(), + summary = "Summary", + summaryNumber = "1", + seriesId = series.id, + ) + + bookMetadataAggregationDao.insert(metadata) + val created = bookMetadataAggregationDao.findById(metadata.seriesId) + + assertThat(created.seriesId).isEqualTo(series.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + + assertThat(created.releaseDate).isEqualTo(metadata.releaseDate) + assertThat(created.summary).isEqualTo(metadata.summary) + assertThat(created.summaryNumber).isEqualTo(metadata.summaryNumber) + with(created.authors.first()) { + assertThat(name).isEqualTo(metadata.authors.first().name) + assertThat(role).isEqualTo(metadata.authors.first().role) + } + assertThat(created.tags).containsExactlyInAnyOrderElementsOf(metadata.tags) + } + + @Test + fun `given a minimum bookMetadataAggregation when inserting then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val now = LocalDateTime.now() + val metadata = + BookMetadataAggregation( + seriesId = series.id, + ) + + bookMetadataAggregationDao.insert(metadata) + val created = bookMetadataAggregationDao.findById(metadata.seriesId) + + assertThat(created.seriesId).isEqualTo(series.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + + assertThat(created.releaseDate).isNull() + assertThat(created.summary).isBlank + assertThat(created.summaryNumber).isBlank + assertThat(created.authors).isEmpty() + } + + @Test + fun `given existing bookMetadataAggregation when finding by id then metadata is returned`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val metadata = + BookMetadataAggregation( + authors = listOf(Author("author", "role")), + tags = setOf("tag1", "tag2"), + releaseDate = LocalDate.now(), + summary = "Summary", + seriesId = series.id, + ) + + bookMetadataAggregationDao.insert(metadata) + + val found = bookMetadataAggregationDao.findById(series.id) + + assertThat(found).isNotNull + assertThat(found.summary).isEqualTo("Summary") + } + + @Test + fun `given non-existing bookMetadataAggregation when finding by id then exception is thrown`() { + val found = catchThrowable { bookMetadataAggregationDao.findById("128742") } + + assertThat(found).isInstanceOf(Exception::class.java) + } + + @Test + fun `given non-existing bookMetadataAggregation when findByIdOrNull then null is returned`() { + val found = bookMetadataAggregationDao.findByIdOrNull("128742") + + assertThat(found).isNull() + } + + @Test + fun `given a bookMetadataAggregation when updating then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val metadata = + BookMetadataAggregation( + authors = listOf(Author("author", "role")), + tags = setOf("tag1", "tag2"), + releaseDate = LocalDate.now(), + summary = "Summary", + summaryNumber = "1", + seriesId = series.id, + ) + bookMetadataAggregationDao.insert(metadata) + val created = bookMetadataAggregationDao.findById(metadata.seriesId) + + val modificationDate = LocalDateTime.now() + + val updated = + with(created) { + copy( + releaseDate = LocalDate.now().plusYears(1), + summary = "SummaryUpdated", + summaryNumber = "2", + authors = listOf(Author("authorUpdated", "roleUpdated"), Author("author2", "role2")), + tags = setOf("tag1", "tag2updated"), + ) + } + + bookMetadataAggregationDao.update(updated) + val modified = bookMetadataAggregationDao.findById(updated.seriesId) + + assertThat(modified.seriesId).isEqualTo(series.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(modified.createdDate) + + assertThat(modified.releaseDate).isEqualTo(updated.releaseDate) + assertThat(modified.summary).isEqualTo(updated.summary) + assertThat(modified.authors).hasSize(2) + assertThat(modified.authors.map { it.name }).containsExactlyInAnyOrderElementsOf(updated.authors.map { it.name }) + assertThat(modified.authors.map { it.role }).containsExactlyInAnyOrderElementsOf(updated.authors.map { it.role }) + assertThat(modified.tags).containsExactlyInAnyOrderElementsOf(updated.tags) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDaoTest.kt new file mode 100644 index 000000000..f6441a6fc --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDaoTest.kt @@ -0,0 +1,253 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.net.URI +import java.time.LocalDate +import java.time.LocalDateTime + +@SpringBootTest +class BookMetadataDaoTest( + @Autowired private val bookMetadataDao: BookMetadataDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val series = makeSeries("Series") + private val book = makeBook("Book") + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + + seriesRepository.insert(series.copy(libraryId = library.id)) + + bookRepository.insert(book.copy(libraryId = library.id, seriesId = series.id)) + } + + @AfterEach + fun deleteMedia() { + bookRepository.findAll().forEach { + bookMetadataDao.delete(it.id) + } + } + + @AfterAll + fun tearDown() { + bookRepository.deleteAll() + seriesRepository.deleteAll() + libraryRepository.deleteAll() + } + + @Test + fun `given a metadata when inserting then it is persisted`() { + val now = LocalDateTime.now() + val metadata = + BookMetadata( + title = "Book", + summary = "Summary", + number = "1", + numberSort = 1F, + releaseDate = LocalDate.now(), + authors = listOf(Author("author", "role")), + tags = setOf("tag", "another"), + isbn = "987654321", + links = listOf(WebLink("Comicvine", URI("https://comicvine.gamespot.com/doctor-strange-30-a-gathering-of-fear/4000-18731/"))), + bookId = book.id, + titleLock = true, + summaryLock = true, + numberLock = true, + numberSortLock = true, + releaseDateLock = true, + authorsLock = true, + tagsLock = true, + isbnLock = true, + ) + + bookMetadataDao.insert(metadata) + val created = bookMetadataDao.findById(metadata.bookId) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + + assertThat(created.title).isEqualTo(metadata.title) + assertThat(created.summary).isEqualTo(metadata.summary) + assertThat(created.number).isEqualTo(metadata.number) + assertThat(created.numberSort).isEqualTo(metadata.numberSort) + assertThat(created.releaseDate).isEqualTo(metadata.releaseDate) + assertThat(created.authors).hasSize(1) + with(created.authors.first()) { + assertThat(name).isEqualTo(metadata.authors.first().name) + assertThat(role).isEqualTo(metadata.authors.first().role) + } + assertThat(created.tags).containsAll(metadata.tags) + assertThat(created.isbn).isEqualTo(metadata.isbn) + with(created.links.first()) { + assertThat(label).isEqualTo(metadata.links.first().label) + assertThat(url).isEqualTo(metadata.links.first().url) + } + + assertThat(created.titleLock).isEqualTo(metadata.titleLock) + assertThat(created.summaryLock).isEqualTo(metadata.summaryLock) + assertThat(created.numberLock).isEqualTo(metadata.numberLock) + assertThat(created.numberSortLock).isEqualTo(metadata.numberSortLock) + assertThat(created.releaseDateLock).isEqualTo(metadata.releaseDateLock) + assertThat(created.authorsLock).isEqualTo(metadata.authorsLock) + assertThat(created.tagsLock).isEqualTo(metadata.tagsLock) + assertThat(created.isbnLock).isEqualTo(metadata.isbnLock) + assertThat(created.linksLock).isEqualTo(metadata.linksLock) + } + + @Test + fun `given a minimum metadata when inserting then it is persisted`() { + val metadata = + BookMetadata( + title = "Book", + number = "1", + numberSort = 1F, + bookId = book.id, + ) + + bookMetadataDao.insert(metadata) + val created = bookMetadataDao.findById(metadata.bookId) + + assertThat(created.bookId).isEqualTo(book.id) + + assertThat(created.title).isEqualTo(metadata.title) + assertThat(created.summary).isBlank + assertThat(created.number).isEqualTo(metadata.number) + assertThat(created.numberSort).isEqualTo(metadata.numberSort) + assertThat(created.releaseDate).isNull() + assertThat(created.authors).isEmpty() + assertThat(created.tags).isEmpty() + assertThat(created.isbn).isBlank + assertThat(created.links).isEmpty() + + assertThat(created.titleLock).isFalse + assertThat(created.summaryLock).isFalse + assertThat(created.numberLock).isFalse + assertThat(created.numberSortLock).isFalse + assertThat(created.releaseDateLock).isFalse + assertThat(created.authorsLock).isFalse + assertThat(created.tagsLock).isFalse + assertThat(created.isbnLock).isFalse + assertThat(created.linksLock).isFalse + } + + @Test + fun `given existing metadata when updating then it is persisted`() { + val metadata = + BookMetadata( + title = "Book", + summary = "Summary", + number = "1", + numberSort = 1F, + releaseDate = LocalDate.now(), + authors = listOf(Author("author", "role")), + tags = setOf("tag"), + links = listOf(WebLink("Comicvine", URI("https://comicvine.gamespot.com/doctor-strange-30-a-gathering-of-fear/4000-18731/"))), + bookId = book.id, + ) + bookMetadataDao.insert(metadata) + + val modificationDate = LocalDateTime.now() + val updated = + with(bookMetadataDao.findById(metadata.bookId)) { + copy( + title = "BookUpdated", + summary = "SummaryUpdated", + number = "2", + numberSort = 2F, + releaseDate = LocalDate.now(), + authors = listOf(Author("author2", "role2")), + tags = setOf("another"), + isbn = "987654321", + links = listOf(WebLink("Bedetheque", URI("https://www.bedetheque.com/BD-AD-Grand-Riviere-Tome-1-Terre-d-election-12596.html"))), + titleLock = true, + summaryLock = true, + numberLock = true, + numberSortLock = true, + releaseDateLock = true, + authorsLock = true, + tagsLock = true, + isbnLock = true, + linksLock = true, + ) + } + + bookMetadataDao.update(updated) + val modified = bookMetadataDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(updated.lastModifiedDate) + + assertThat(modified.title).isEqualTo(updated.title) + assertThat(modified.summary).isEqualTo(updated.summary) + assertThat(modified.number).isEqualTo(updated.number) + assertThat(modified.numberSort).isEqualTo(updated.numberSort) + assertThat(modified.isbn).isEqualTo(updated.isbn) + + assertThat(modified.titleLock).isEqualTo(updated.titleLock) + assertThat(modified.summaryLock).isEqualTo(updated.summaryLock) + assertThat(modified.numberLock).isEqualTo(updated.numberLock) + assertThat(modified.numberSortLock).isEqualTo(updated.numberSortLock) + assertThat(modified.releaseDateLock).isEqualTo(updated.releaseDateLock) + assertThat(modified.authorsLock).isEqualTo(updated.authorsLock) + assertThat(modified.tagsLock).isEqualTo(updated.tagsLock) + assertThat(modified.isbnLock).isEqualTo(updated.isbnLock) + assertThat(modified.linksLock).isEqualTo(updated.linksLock) + + assertThat(modified.tags).containsAll(updated.tags) + assertThat(modified.authors.first().name).isEqualTo(updated.authors.first().name) + assertThat(modified.authors.first().role).isEqualTo(updated.authors.first().role) + assertThat(modified.links.first().label).isEqualTo(updated.links.first().label) + assertThat(modified.links.first().url).isEqualTo(updated.links.first().url) + } + + @Test + fun `given existing metadata when finding by id then metadata is returned`() { + val metadata = + BookMetadata( + title = "Book", + summary = "Summary", + number = "1", + numberSort = 1F, + releaseDate = LocalDate.now(), + authors = listOf(Author("author", "role")), + bookId = book.id, + ) + bookMetadataDao.insert(metadata) + + val found = catchThrowable { bookMetadataDao.findById(metadata.bookId) } + + assertThat(found).doesNotThrowAnyException() + } + + @Test + fun `given non-existing metadata when finding by id then exception is thrown`() { + val found = catchThrowable { bookMetadataDao.findById("128742") } + + assertThat(found).isInstanceOf(Exception::class.java) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookSearchTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookSearchTest.kt new file mode 100644 index 000000000..3b969f041 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/BookSearchTest.kt @@ -0,0 +1,1110 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchCondition.AuthorMatch +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime + +@SpringBootTest +class BookSearchTest( + @Autowired private val bookDao: BookDao, + @Autowired private val bookDtoDao: BookDtoDao, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val readListRepository: ReadListRepository, +) { + private val library1 = makeLibrary() + private val library2 = makeLibrary() + private val series1 = makeSeries("Series 1").copy(libraryId = library1.id) + private val series2 = makeSeries("Series 2").copy(libraryId = library2.id) + private val user1 = KomgaUser("user1@example.org", "p") + private val user2 = KomgaUser("user2@example.org", "p") + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + every { mockEventPublisher.publishEvent(any()) } just Runs + libraryRepository.insert(library1) + libraryRepository.insert(library2) + seriesLifecycle.createSeries(series1) + seriesLifecycle.createSeries(series2) + userRepository.insert(user1) + userRepository.insert(user2) + } + + @BeforeEach + fun resetMocks() { + every { mockEventPublisher.publishEvent(any()) } just Runs + } + + @AfterEach + fun deleteBooks() { + readListRepository.deleteAll() + bookLifecycle.deleteMany(bookRepository.findAll()) + assertThat(bookDao.count()).isEqualTo(0) + } + + @AfterAll + fun tearDown() { + every { mockEventPublisher.publishEvent(any()) } just Runs + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @Test + fun `given some books when searching by library then results are accurate`() { + val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id) + val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id) + seriesLifecycle.addBooks(series1, listOf(book1)) + seriesLifecycle.addBooks(series2, listOf(book2)) + + run { + val search = BookSearch(SearchCondition.LibraryId(SearchOperator.Is(library1.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("readList.number"))).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("readList.number"))).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.LibraryId(SearchOperator.IsNot(library1.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some books when searching by series then results are accurate`() { + val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id) + val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id) + seriesLifecycle.addBooks(series1, listOf(book1)) + seriesLifecycle.addBooks(series2, listOf(book2)) + + run { + val search = BookSearch(SearchCondition.SeriesId(SearchOperator.Is(series1.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.SeriesId(SearchOperator.IsNot(series1.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some books when searching by read list then results are accurate`() { + val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id) + val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id) + seriesLifecycle.addBooks(series1, listOf(book1)) + seriesLifecycle.addBooks(series2, listOf(book2)) + val readList = ReadList("rl1", bookIds = mapOf(1 to book1.id).toSortedMap()) + readListRepository.insert(readList) + + run { + val search = BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReadListId(SearchOperator.IsNot(readList.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some books in multiple read lists when searching by read list then results are accurate`() { + val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id) + val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id) + seriesLifecycle.addBooks(series1, listOf(book1)) + seriesLifecycle.addBooks(series2, listOf(book2)) + val readList1 = ReadList("rl1", bookIds = mapOf(1 to book1.id, 2 to book2.id).toSortedMap()) + val readList2 = ReadList("rl2", bookIds = mapOf(1 to book2.id, 2 to book1.id).toSortedMap()) + readListRepository.insert(readList1) + readListRepository.insert(readList2) + + // search by readList 1 + run { + val search = BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList1.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + + // order not guaranteed for bookDao + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactly("1", "2") + } + + // search by readList 2 + run { + val search = BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList2.id))) + val found = bookDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + + // order not guaranteed for bookDao + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "1") + assertThat(foundDto.map { it.name }).containsExactly("2", "1") + } + + // search by readList 1 or 2 - order is not guaranteed in that case + run { + val search = + BookSearch( + SearchCondition.AnyOfBook( + SearchCondition.ReadListId(SearchOperator.Is(readList1.id)), + SearchCondition.ReadListId(SearchOperator.Is(readList2.id)), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number")))).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "1") + } + } + + @Test + fun `given some books when searching by deleted then results are accurate`() { + val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id).copy(deletedDate = LocalDateTime.now()) + val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id) + seriesLifecycle.addBooks(series1, listOf(book1)) + seriesLifecycle.addBooks(series2, listOf(book2)) + + run { + val search = BookSearch(SearchCondition.Deleted(SearchOperator.IsTrue)) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.Deleted(SearchOperator.IsFalse)) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some books when searching by title then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(title = "Book 1")) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(title = "Book 2")) + } + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.Is("boOK 1"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.IsNot("book 1"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.Contains("book"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.DoesNotContain("1"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.BeginsWith("book"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = BookSearch(SearchCondition.Title(SearchOperator.EndsWith("1"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + } + + @Test + fun `given some books when searching by release date then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(releaseDate = LocalDate.now().minusDays(5))) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now().minusDays(10)))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now()))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(10)))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(10)))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(1)))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNull)) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotNull)) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + } + + @Test + fun `given some books when searching by number sort then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(numberSort = 1F)) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(numberSort = 10.5F)) + } + } + + run { + val search = BookSearch(SearchCondition.NumberSort(SearchOperator.Is(10.5F))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.NumberSort(SearchOperator.IsNot(10.5F))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.NumberSort(SearchOperator.GreaterThan(0F))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = BookSearch(SearchCondition.NumberSort(SearchOperator.GreaterThan(5F))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.NumberSort(SearchOperator.LessThan(11F))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + BookSearch( + SearchCondition.AnyOfBook( + listOf( + SearchCondition.NumberSort(SearchOperator.Is(10.5F)), + SearchCondition.NumberSort(SearchOperator.Is(1F)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + BookSearch( + SearchCondition.AllOfBook( + listOf( + SearchCondition.NumberSort(SearchOperator.Is(10.5F)), + SearchCondition.NumberSort(SearchOperator.Is(1F)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + } + + @Test + fun `given some books when searching by tag then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("fiction", "horror"))) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("fiction"))) + } + } + makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("fantasy"))) + } + } + makeBook("4", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + run { + val search = BookSearch(SearchCondition.Tag(SearchOperator.Is("FICTîON"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + BookSearch( + SearchCondition.AllOfBook( + listOf( + SearchCondition.Tag(SearchOperator.Is("FICTION")), + SearchCondition.Tag(SearchOperator.Is("horror")), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = + BookSearch( + SearchCondition.AnyOfBook( + listOf( + SearchCondition.Tag(SearchOperator.Is("horror")), + SearchCondition.Tag(SearchOperator.Is("notexist")), + SearchCondition.Tag(SearchOperator.Is("fantasy")), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + run { + val search = + BookSearch( + SearchCondition.AllOfBook( + listOf( + SearchCondition.Tag(SearchOperator.Is("fiction")), + SearchCondition.Tag(SearchOperator.IsNot("horror")), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.Tag(SearchOperator.IsNot("fiction"))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + run { + val search = BookSearch(SearchCondition.Tag(SearchOperator.IsNullT())) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + run { + val search = BookSearch(SearchCondition.Tag(SearchOperator.IsNotNullT())) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some books when searching by media status then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(status = Media.Status.READY)) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(status = Media.Status.ERROR)) + } + } + + run { + val search = BookSearch(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.MediaStatus(SearchOperator.IsNot(Media.Status.READY))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = + BookSearch( + SearchCondition.AllOfBook( + listOf( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.ERROR)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = + BookSearch( + SearchCondition.AnyOfBook( + listOf( + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)), + SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.ERROR)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + } + + @Test + fun `given some books when searching by media profile then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(mediaType = MediaType.ZIP.type)) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(mediaType = MediaType.RAR_4.type)) + } + } + makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(mediaType = MediaType.EPUB.type)) + } + } + + run { + val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.IsNot(MediaProfile.EPUB))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3") + } + + run { + val search = + BookSearch( + SearchCondition.AllOfBook( + listOf( + SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA)), + SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = + BookSearch( + SearchCondition.AnyOfBook( + listOf( + SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA)), + SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)), + ), + ), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some books when searching by read progress then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + readProgressRepository.save(ReadProgress(book.id, user1.id, 5, false)) + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + readProgressRepository.save(ReadProgress(book.id, user1.id, 10, true)) + } + makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3") + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = bookDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))) + val found = bookDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + + run { + val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = bookDao.findAll(search.condition, SearchContext(null), Pageable.unpaged()).content + val thrown = catchThrowable { bookDtoDao.findAll(search, SearchContext(null), Pageable.unpaged()) } + + assertThat(found).isEmpty() + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `given some books when searching by author then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "writer"), Author("jim", "cover")))) + } + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "artist"), Author("amanda", "artist")))) + } + } + makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("jack", "writer")))) + } + } + makeBook("4", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + // books with an author named 'john' + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.Is(AuthorMatch("jÒhn"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + // books with an author named 'john' with the 'writer' role + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "WRÎTER"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + // books with any author with the 'writer' role + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + // books without any author named 'john' with a 'writer' role + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("john", "writer"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3", "4") + } + + // books without any author named 'john' + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jOhn"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + // books without any author with the 'writer' role + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(role = "writer"))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "4") + } + + // empty AuthorMatch does not apply any condition + run { + val search = + BookSearch( + SearchCondition.Author(SearchOperator.Is(AuthorMatch())), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + } + } + + @Test + fun `given some books when searching by one-shot then results are accurate`() { + makeBook("1", libraryId = library1.id, seriesId = series1.id).copy(oneshot = true).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + } + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + run { + val search = + BookSearch( + SearchCondition.OneShot(SearchOperator.IsTrue), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = + BookSearch( + SearchCondition.OneShot(SearchOperator.IsFalse), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some books when searching by poster then results are accurate`() { + // book with GENERATED selected + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.GENERATED, mediaType = "image/jpeg", fileSize = 0L, dimension = Dimension(0, 0)), MarkSelectedPreference.YES) + } + // book with GENERATED not selected, SIDECAR selected + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.GENERATED, mediaType = "image/jpeg", fileSize = 0L, dimension = Dimension(0, 0)), MarkSelectedPreference.YES) + bookLifecycle.addThumbnailForBook(ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.SIDECAR, mediaType = "image/jpeg", fileSize = 0L, dimension = Dimension(0, 0)), MarkSelectedPreference.YES) + } + // book with GENERATED not selected, USER_UPLOADED selected + makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.GENERATED, mediaType = "image/jpeg", fileSize = 0L, dimension = Dimension(0, 0)), MarkSelectedPreference.YES) + bookLifecycle.addThumbnailForBook(ThumbnailBook(bookId = book.id, type = ThumbnailBook.Type.USER_UPLOADED, mediaType = "image/jpeg", fileSize = 0L, dimension = Dimension(0, 0)), MarkSelectedPreference.YES) + } + // book without poster + makeBook("4", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + } + + // books with a poster of type GENERATED + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch(SearchCondition.PosterMatch.Type.GENERATED))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + + // books with a poster of type GENERATED, selected + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch(SearchCondition.PosterMatch.Type.GENERATED, true))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + // books with any poster not selected + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch(selected = false))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3") + } + + // books without a poster of type SIDECAR + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.IsNot(SearchCondition.PosterMatch(SearchCondition.PosterMatch.Type.SIDECAR))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3", "4") + } + + // books without a poster of type GENERATED + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.IsNot(SearchCondition.PosterMatch(SearchCondition.PosterMatch.Type.GENERATED))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + // books without selected poster + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.IsNot(SearchCondition.PosterMatch(selected = true))), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + // empty PosterMatch does not apply any condition + run { + val search = + BookSearch( + SearchCondition.Poster(SearchOperator.Is(SearchCondition.PosterMatch())), + ) + val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDaoTest.kt new file mode 100644 index 000000000..742efbbf6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ClientSettingsDtoDaoTest.kt @@ -0,0 +1,122 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.interfaces.api.rest.dto.ClientSettingDto +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ClientSettingsDtoDaoTest( + @Autowired private val clientSettingsDtoDao: ClientSettingsDtoDao, + @Autowired private val userRepository: KomgaUserRepository, +) { + private val user1 = KomgaUser("user1@example.org", "") + private val user2 = KomgaUser("user2@example.org", "") + + @BeforeAll + fun setup() { + userRepository.insert(user1) + userRepository.insert(user2) + } + + @AfterEach + fun cleanup() { + clientSettingsDtoDao.deleteAll() + } + + @AfterAll + fun tearDown() { + userRepository.deleteAll() + } + + @Nested + inner class Global { + @Test + fun `when saving global setting then it is persisted`() { + clientSettingsDtoDao.saveGlobal("setting1", "value1", true) + clientSettingsDtoDao.saveGlobal("setting2", "value2", false) + + val fetch = clientSettingsDtoDao.findAllGlobal() + + assertThat(fetch).containsAllEntriesOf( + mapOf( + "setting1" to ClientSettingDto("value1", true), + "setting2" to ClientSettingDto("value2", false), + ), + ) + } + + @Test + fun `given existing global setting when saving global setting then it is updated`() { + clientSettingsDtoDao.saveGlobal("setting1", "value1", true) + clientSettingsDtoDao.saveGlobal("setting1", "updated", true) + val fetch = clientSettingsDtoDao.findAllGlobal() + + assertThat(fetch).containsAllEntriesOf( + mapOf( + "setting1" to ClientSettingDto("updated", true), + ), + ) + } + + @Test + fun `given existing global setting when deleting global setting then it is deleted`() { + clientSettingsDtoDao.saveGlobal("setting1", "value1", true) + + clientSettingsDtoDao.deleteGlobalByKeys(listOf("setting1")) + + val fetch = clientSettingsDtoDao.findAllGlobal() + + assertThat(fetch).isEmpty() + } + } + + @Nested + inner class User { + @Test + fun `when saving user setting then it is persisted`() { + clientSettingsDtoDao.saveForUser(user1.id, "setting1", "value1") + clientSettingsDtoDao.saveForUser(user2.id, "setting2", "value2") + + val fetch = clientSettingsDtoDao.findAllUser(user1.id) + + assertThat(fetch).containsAllEntriesOf( + mapOf( + "setting1" to ClientSettingDto("value1", null), + ), + ) + } + + @Test + fun `given existing user setting when saving user setting then it is updated`() { + clientSettingsDtoDao.saveForUser(user1.id, "setting1", "value1") + clientSettingsDtoDao.saveForUser(user1.id, "setting1", "updated") + + val fetch = clientSettingsDtoDao.findAllUser(user1.id) + + assertThat(fetch).containsAllEntriesOf( + mapOf( + "setting1" to ClientSettingDto("updated", null), + ), + ) + } + + @Test + fun `given existing user setting when deleting user setting then it is deleted`() { + clientSettingsDtoDao.saveForUser(user1.id, "setting1", "value1") + + clientSettingsDtoDao.deleteByUserIdAndKeys(user1.id, listOf("setting1")) + + val fetch = clientSettingsDtoDao.findAllUser(user1.id) + + assertThat(fetch).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt new file mode 100644 index 000000000..e45114cd6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt @@ -0,0 +1,236 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDateTime + +@SpringBootTest +class KomgaUserDaoTest( + @Autowired private val komgaUserDao: KomgaUserDao, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + } + + @AfterEach + fun deleteUsers() { + komgaUserDao.deleteAll() + assertThat(komgaUserDao.count()).isEqualTo(0) + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given a user when saving it then it is persisted`() { + val now = LocalDateTime.now() + val user = + KomgaUser( + email = "user@example.org", + password = "password", + sharedLibrariesIds = setOf(library.id), + sharedAllLibraries = false, + ) + + komgaUserDao.insert(user) + val created = komgaUserDao.findByIdOrNull(user.id)!! + + with(created) { + assertThat(id).isNotEqualTo(0) + assertThat(createdDate).isCloseTo(now, offset) + assertThat(lastModifiedDate).isCloseTo(now, offset) + assertThat(email).isEqualTo("user@example.org") + assertThat(password).isEqualTo("password") + assertThat(roles).containsExactlyInAnyOrder(UserRoles.FILE_DOWNLOAD, UserRoles.PAGE_STREAMING) + assertThat(sharedLibrariesIds).containsExactly(library.id) + assertThat(sharedAllLibraries).isFalse + assertThat(restrictions.ageRestriction).isNull() + assertThat(restrictions.labelsAllow).isEmpty() + assertThat(restrictions.labelsExclude).isEmpty() + } + } + + @Test + fun `given existing user when modifying and saving it then it is persisted`() { + val user = + KomgaUser( + email = "user@example.org", + password = "password", + sharedLibrariesIds = setOf(library.id), + sharedAllLibraries = false, + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(10, AllowExclude.ALLOW_ONLY), + labelsAllow = setOf("allow"), + labelsExclude = setOf("exclude"), + ), + ) + + komgaUserDao.insert(user) + val created = komgaUserDao.findByIdOrNull(user.id)!! + with(created) { + assertThat(restrictions.ageRestriction).isNotNull + assertThat(restrictions.ageRestriction!!.age).isEqualTo(10) + assertThat(restrictions.ageRestriction!!.restriction).isEqualTo(AllowExclude.ALLOW_ONLY) + assertThat(restrictions.labelsAllow).containsExactly("allow") + assertThat(restrictions.labelsExclude).containsExactly("exclude") + } + + val modified = + created.copy( + email = "user2@example.org", + password = "password2", + roles = setOf(UserRoles.ADMIN, UserRoles.FILE_DOWNLOAD, UserRoles.PAGE_STREAMING, UserRoles.KOBO_SYNC), + sharedLibrariesIds = emptySet(), + sharedAllLibraries = true, + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(16, AllowExclude.EXCLUDE), + labelsAllow = setOf("allow2"), + labelsExclude = setOf("exclude2"), + ), + ) + val modifiedDate = LocalDateTime.now() + komgaUserDao.update(modified) + val modifiedSaved = komgaUserDao.findByIdOrNull(modified.id)!! + + with(modifiedSaved) { + assertThat(id).isEqualTo(created.id) + assertThat(createdDate).isEqualTo(created.createdDate) + assertThat(lastModifiedDate) + .isCloseTo(modifiedDate, offset) + .isNotEqualTo(modified.createdDate) + assertThat(email).isEqualTo("user2@example.org") + assertThat(password).isEqualTo("password2") + assertThat(roles).containsExactlyInAnyOrder(UserRoles.ADMIN, UserRoles.FILE_DOWNLOAD, UserRoles.PAGE_STREAMING, UserRoles.KOBO_SYNC) + assertThat(sharedLibrariesIds).isEmpty() + assertThat(sharedAllLibraries).isTrue + assertThat(restrictions.ageRestriction).isNotNull + assertThat(restrictions.ageRestriction!!.age).isEqualTo(16) + assertThat(restrictions.ageRestriction!!.restriction).isEqualTo(AllowExclude.EXCLUDE) + assertThat(restrictions.labelsAllow).containsExactly("allow2") + assertThat(restrictions.labelsExclude).containsExactly("exclude2") + } + + komgaUserDao.update(modifiedSaved.copy(restrictions = ContentRestrictions())) + with(komgaUserDao.findByIdOrNull(modified.id)!!) { + assertThat(restrictions.ageRestriction).isNull() + assertThat(restrictions.labelsAllow).isEmpty() + assertThat(restrictions.labelsExclude).isEmpty() + } + } + + @Test + fun `given multiple users when saving then they are persisted`() { + komgaUserDao.insert(KomgaUser("user1@example.org", "p")) + komgaUserDao.insert(KomgaUser("user2@example.org", "p")) + + val users = komgaUserDao.findAll() + + assertThat(users).hasSize(2) + assertThat(users.map { it.email }).containsExactlyInAnyOrder( + "user1@example.org", + "user2@example.org", + ) + } + + @Test + fun `given some users when counting then proper count is returned`() { + komgaUserDao.insert(KomgaUser("user1@example.org", "p")) + komgaUserDao.insert(KomgaUser("user2@example.org", "p")) + + val count = komgaUserDao.count() + + assertThat(count).isEqualTo(2) + } + + @Test + fun `given existing user when finding by id then user is returned`() { + val existing = KomgaUser("user1@example.org", "p") + komgaUserDao.insert(existing) + + val user = komgaUserDao.findByIdOrNull(existing.id) + + assertThat(user).isNotNull + } + + @Test + fun `given non-existent user when finding by id then null is returned`() { + val user = komgaUserDao.findByIdOrNull("38473") + + assertThat(user).isNull() + } + + @Test + fun `given existing user when deleting then user is deleted`() { + val existing = KomgaUser("user1@example.org", "p") + komgaUserDao.insert(existing) + + komgaUserDao.delete(existing.id) + + assertThat(komgaUserDao.count()).isEqualTo(0) + } + + @Test + fun `given users when checking if exists by email then return true or false`() { + komgaUserDao.insert( + KomgaUser("user1@example.org", "p"), + ) + + val exists = komgaUserDao.existsByEmailIgnoreCase("USER1@EXAMPLE.ORG") + val notExists = komgaUserDao.existsByEmailIgnoreCase("USER2@EXAMPLE.ORG") + + assertThat(exists).isTrue() + assertThat(notExists).isFalse() + } + + @Test + fun `given users when finding by email then return user`() { + komgaUserDao.insert( + KomgaUser("user1@example.org", "p"), + ) + + val found = komgaUserDao.findByEmailIgnoreCaseOrNull("USER1@EXAMPLE.ORG") + val notFound = komgaUserDao.findByEmailIgnoreCaseOrNull("USER2@EXAMPLE.ORG") + + assertThat(found).isNotNull + assertThat(found?.email).isEqualTo("user1@example.org") + assertThat(notFound).isNull() + } + + @Test + fun `given user when saving announcement as read then it works`() { + val user = KomgaUser("user1@example.org", "p") + komgaUserDao.insert(user) + + assertThat(komgaUserDao.findAnnouncementIdsReadByUserId(user.id)).isEmpty() + + komgaUserDao.saveAnnouncementIdsRead(user, setOf("1")) + assertThat(komgaUserDao.findAnnouncementIdsReadByUserId(user.id)).containsExactlyInAnyOrder("1") + + komgaUserDao.saveAnnouncementIdsRead(user, setOf("2")) + assertThat(komgaUserDao.findAnnouncementIdsReadByUserId(user.id)).containsExactlyInAnyOrder("1", "2") + + komgaUserDao.saveAnnouncementIdsRead(user, setOf("2")) + assertThat(komgaUserDao.findAnnouncementIdsReadByUserId(user.id)).containsExactlyInAnyOrder("1", "2") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDaoTest.kt new file mode 100644 index 000000000..144865436 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDaoTest.kt @@ -0,0 +1,225 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Library +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.net.URL +import java.time.LocalDateTime + +@SpringBootTest +class LibraryDaoTest( + @Autowired private val libraryDao: LibraryDao, +) { + @AfterEach + fun deleteLibraries() { + libraryDao.deleteAll() + assertThat(libraryDao.count()).isEqualTo(0) + } + + @Test + fun `given a library when inserting then it is persisted`() { + val now = LocalDateTime.now() + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + + libraryDao.insert(library) + val created = libraryDao.findById(library.id) + + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + assertThat(created.name).isEqualTo(library.name) + assertThat(created.root).isEqualTo(library.root) + } + + @Test + fun `given existing library when updating then it is persisted`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + libraryDao.insert(library) + + val modificationDate = LocalDateTime.now() + + val updated = + with(libraryDao.findById(library.id)) { + copy( + name = "LibraryUpdated", + root = URL("file://library2"), + importEpubSeries = false, + importEpubBook = false, + importComicInfoCollection = false, + importComicInfoSeries = false, + importComicInfoBook = false, + importComicInfoReadList = false, + importComicInfoSeriesAppendVolume = false, + importMylarSeries = false, + importBarcodeIsbn = false, + importLocalArtwork = false, + repairExtensions = true, + convertToCbz = true, + emptyTrashAfterScan = true, + seriesCover = Library.SeriesCover.LAST, + hashFiles = false, + hashPages = true, + analyzeDimensions = false, + scanForceModifiedTime = true, + scanCbx = false, + scanEpub = false, + scanPdf = false, + scanInterval = Library.ScanInterval.DAILY, + scanOnStartup = true, + scanDirectoryExclusions = setOf("a", "b"), + ) + } + + libraryDao.update(updated) + val modified = libraryDao.findById(updated.id) + + assertThat(modified.id).isEqualTo(updated.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(updated.lastModifiedDate) + + assertThat(modified.name).isEqualTo(updated.name) + assertThat(modified.root).isEqualTo(updated.root) + assertThat(modified.importEpubSeries).isEqualTo(updated.importEpubSeries) + assertThat(modified.importEpubBook).isEqualTo(updated.importEpubBook) + assertThat(modified.importComicInfoCollection).isEqualTo(updated.importComicInfoCollection) + assertThat(modified.importComicInfoSeries).isEqualTo(updated.importComicInfoSeries) + assertThat(modified.importComicInfoBook).isEqualTo(updated.importComicInfoBook) + assertThat(modified.importComicInfoReadList).isEqualTo(updated.importComicInfoReadList) + assertThat(modified.importComicInfoSeriesAppendVolume).isEqualTo(updated.importComicInfoSeriesAppendVolume) + assertThat(modified.importBarcodeIsbn).isEqualTo(updated.importBarcodeIsbn) + assertThat(modified.importLocalArtwork).isEqualTo(updated.importLocalArtwork) + assertThat(modified.importMylarSeries).isEqualTo(updated.importMylarSeries) + assertThat(modified.repairExtensions).isEqualTo(updated.repairExtensions) + assertThat(modified.convertToCbz).isEqualTo(updated.convertToCbz) + assertThat(modified.emptyTrashAfterScan).isEqualTo(updated.emptyTrashAfterScan) + assertThat(modified.seriesCover).isEqualTo(updated.seriesCover) + assertThat(modified.hashFiles).isEqualTo(updated.hashFiles) + assertThat(modified.hashPages).isEqualTo(updated.hashPages) + assertThat(modified.analyzeDimensions).isEqualTo(updated.analyzeDimensions) + assertThat(modified.scanForceModifiedTime).isEqualTo(updated.scanForceModifiedTime) + assertThat(modified.scanCbx).isEqualTo(updated.scanCbx) + assertThat(modified.scanEpub).isEqualTo(updated.scanEpub) + assertThat(modified.scanPdf).isEqualTo(updated.scanPdf) + assertThat(modified.scanInterval).isEqualTo(updated.scanInterval) + assertThat(modified.scanOnStartup).isEqualTo(updated.scanOnStartup) + assertThat(modified.scanDirectoryExclusions).containsExactlyInAnyOrderElementsOf(updated.scanDirectoryExclusions) + } + + @Test + fun `given a library when deleting then it is deleted`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + + libraryDao.insert(library) + assertThat(libraryDao.count()).isEqualTo(1) + + libraryDao.delete(library.id) + + assertThat(libraryDao.count()).isEqualTo(0) + } + + @Test + fun `given libraries when deleting all then all are deleted`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + val library2 = + Library( + name = "Library2", + root = URL("file://library2"), + ) + + libraryDao.insert(library) + libraryDao.insert(library2) + assertThat(libraryDao.count()).isEqualTo(2) + + libraryDao.deleteAll() + + assertThat(libraryDao.count()).isEqualTo(0) + } + + @Test + fun `given libraries when finding all then all are returned`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + val library2 = + Library( + name = "Library2", + root = URL("file://library2"), + ) + + libraryDao.insert(library) + libraryDao.insert(library2) + + val all = libraryDao.findAll() + + assertThat(all).hasSize(2) + assertThat(all.map { it.name }).containsExactlyInAnyOrder("Library", "Library2") + } + + @Test + fun `given libraries when finding all by id then all are returned`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + val library2 = + Library( + name = "Library2", + root = URL("file://library2"), + ) + + libraryDao.insert(library) + libraryDao.insert(library2) + + val all = libraryDao.findAllByIds(listOf(library.id, library2.id)) + + assertThat(all).hasSize(2) + assertThat(all.map { it.name }).containsExactlyInAnyOrder("Library", "Library2") + } + + @Test + fun `given existing library when finding by id then library is returned`() { + val library = + Library( + name = "Library", + root = URL("file://library"), + ) + + libraryDao.insert(library) + + val found = libraryDao.findByIdOrNull(library.id) + + assertThat(found).isNotNull + assertThat(found?.name).isEqualTo("Library") + } + + @Test + fun `given non-existing library when finding by id then null is returned`() { + val found = libraryDao.findByIdOrNull("1287386") + + assertThat(found).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt new file mode 100644 index 000000000..4edb42250 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt @@ -0,0 +1,460 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.EpubTocEntry +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.ProxyExtension +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDateTime + +@SpringBootTest +class MediaDaoTest( + @Autowired private val mediaDao: MediaDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val komgaProperties: KomgaProperties, +) { + private val library = makeLibrary() + private val series = makeSeries("Series", libraryId = library.id) + private val book = makeBook("Book", libraryId = library.id, seriesId = series.id) + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + + seriesRepository.insert(series) + + bookRepository.insert(book) + } + + @AfterEach + fun deleteMedia() { + bookRepository.findAll().forEach { + mediaDao.delete(it.id) + } + } + + @AfterAll + fun tearDown() { + bookRepository.deleteAll() + seriesRepository.deleteAll() + libraryRepository.deleteAll() + } + + @Test + fun `given a media when inserting then it is persisted`() { + val now = LocalDateTime.now() + val media = + Media( + status = Media.Status.READY, + mediaType = "application/zip", + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + dimension = Dimension(10, 10), + fileHash = "hashed", + fileSize = 10, + ), + ), + files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 3)), + comment = "comment", + bookId = book.id, + ) + + mediaDao.insert(media) + val created = mediaDao.findById(media.bookId) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + assertThat(created.status).isEqualTo(media.status) + assertThat(created.mediaType).isEqualTo(media.mediaType) + assertThat(created.comment).isEqualTo(media.comment) + assertThat(created.pages).hasSize(1) + with(created.pages.first()) { + assertThat(fileName).isEqualTo(media.pages.first().fileName) + assertThat(mediaType).isEqualTo(media.pages.first().mediaType) + assertThat(dimension).isEqualTo(media.pages.first().dimension) + assertThat(fileHash).isEqualTo(media.pages.first().fileHash) + assertThat(fileSize).isEqualTo(media.pages.first().fileSize) + } + assertThat(created.files).hasSize(1) + with(created.files.first()) { + assertThat(fileName).isEqualTo(media.files.first().fileName) + assertThat(mediaType).isEqualTo(media.files.first().mediaType) + assertThat(subType).isEqualTo(media.files.first().subType) + assertThat(fileSize).isEqualTo(media.files.first().fileSize) + } + } + + @Test + fun `given a minimum media when inserting then it is persisted`() { + val media = Media(bookId = book.id) + + mediaDao.insert(media) + val created = mediaDao.findById(media.bookId) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.status).isEqualTo(Media.Status.UNKNOWN) + assertThat(created.mediaType).isNull() + assertThat(created.comment).isNull() + assertThat(created.pages).isEmpty() + assertThat(created.files).isEmpty() + assertThat(created.extension).isNull() + } + + @Test + fun `given existing media when updating then it is persisted`() { + val media = + Media( + status = Media.Status.READY, + mediaType = "application/zip", + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + ), + ), + files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 5)), + comment = "comment", + bookId = book.id, + ) + mediaDao.insert(media) + + val modificationDate = LocalDateTime.now() + + val updated = + with(mediaDao.findById(media.bookId)) { + copy( + status = Media.Status.ERROR, + mediaType = "application/rar", + pages = + listOf( + BookPage( + fileName = "2.png", + mediaType = "image/png", + dimension = Dimension(10, 10), + fileHash = "hashed", + fileSize = 10, + ), + ), + files = listOf(MediaFile("id.txt")), + comment = "comment2", + ) + } + + mediaDao.update(updated) + val modified = mediaDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.status).isEqualTo(updated.status) + assertThat(modified.mediaType).isEqualTo(updated.mediaType) + assertThat(modified.comment).isEqualTo(updated.comment) + assertThat(modified.pages.first().fileName).isEqualTo(updated.pages.first().fileName) + assertThat(modified.pages.first().mediaType).isEqualTo(updated.pages.first().mediaType) + assertThat(modified.pages.first().dimension).isEqualTo(updated.pages.first().dimension) + assertThat(modified.pages.first().fileHash).isEqualTo(updated.pages.first().fileHash) + assertThat(modified.pages.first().fileSize).isEqualTo(updated.pages.first().fileSize) + assertThat(modified.files.first().fileName).isEqualTo(updated.files.first().fileName) + assertThat(modified.files.first().mediaType).isEqualTo(updated.files.first().mediaType) + assertThat(modified.files.first().subType).isEqualTo(updated.files.first().subType) + assertThat(modified.files.first().fileSize).isEqualTo(updated.files.first().fileSize) + } + + @Test + fun `given existing media when finding by id then media is returned`() { + val media = + Media( + status = Media.Status.READY, + mediaType = "application/zip", + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + ), + ), + files = listOf(MediaFile("ComicInfo.xml")), + comment = "comment", + bookId = book.id, + ) + mediaDao.insert(media) + + val found = catchThrowable { mediaDao.findById(media.bookId) } + + assertThat(found).doesNotThrowAnyException() + } + + @Test + fun `given non-existing media when finding by id then exception is thrown`() { + val found = catchThrowable { mediaDao.findById("128742") } + + assertThat(found).isInstanceOf(Exception::class.java) + } + + @Nested + inner class MediaExtension { + @Test + fun `given a media with extension when inserting then it is persisted`() { + val media = + Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = + MediaExtensionEpub( + toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))), + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + + mediaDao.insert(media) + val created = mediaDao.findById(media.bookId) + + assertThat(created.extension).isNotNull + assertThat(created.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((created.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + val extension = mediaDao.findExtensionByIdOrNull(media.bookId) + assertThat(extension).isNotNull + assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java) + assertThat(extension).isEqualTo(media.extension) + } + + @Test + fun `given existing media with extension when updating then it is persisted`() { + val media = + Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = + MediaExtensionEpub( + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + mediaDao.insert(media) + + val updated = + with(mediaDao.findById(media.bookId)) { + copy( + extension = + MediaExtensionEpub( + toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))), + ), + ) + } + + mediaDao.update(updated) + val modified = mediaDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.extension).isNotNull + assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + assertThat(mediaDao.findExtensionByIdOrNull(media.bookId)).isEqualTo(updated.extension) + } + + @Test + fun `given existing media with proxy extension when updating then it is kept as-is`() { + val media = + Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = + MediaExtensionEpub( + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + mediaDao.insert(media) + + val updated = mediaDao.findById(media.bookId).copy(comment = "updated") + + mediaDao.update(updated) + val modified = mediaDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.comment).isEqualTo(updated.comment) + assertThat(modified.extension).isNotNull + assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + val extension = mediaDao.findExtensionByIdOrNull(media.bookId) + assertThat(extension).isNotNull + assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java) + assertThat(extension).isEqualTo(media.extension) + } + } + + @Nested + inner class MissingPageHash { + @Test + fun `given media with single page not hashed when finding for missing page hash then it is returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + ), + ), + mediaType = MediaType.ZIP.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).containsExactly(book.id) + } + + @Test + fun `given non-convertible media not hashed when finding for missing page hash then it is returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + ), + ), + mediaType = MediaType.RAR_4.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).isEmpty() + } + + @Test + fun `given media with no pages hashed when finding for missing page hash then it is not returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + (1..12).map { + BookPage( + fileName = "$it.jpg", + mediaType = "image/jpeg", + ) + }, + mediaType = MediaType.ZIP.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).containsOnly(book.id) + } + + @Test + fun `given media with single page hashed when finding for missing page hash then it is not returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + listOf( + BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg", + fileHash = "hashed", + ), + ), + mediaType = MediaType.ZIP.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).isEmpty() + } + + @Test + fun `given media with required pages hashed when finding for missing page hash then it is not returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + (1..12).map { + BookPage( + fileName = "$it.jpg", + mediaType = "image/jpeg", + fileHash = if (it <= 3 || it >= 9) "hashed" else "", + ) + }, + mediaType = MediaType.ZIP.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).isEmpty() + } + + @Test + fun `given media with more pages hashed than required when finding for missing page hash then it is not returned`() { + val media = + Media( + status = Media.Status.READY, + pages = + (1..12).map { + BookPage( + fileName = "$it.jpg", + mediaType = "image/jpeg", + fileHash = "hashed", + ) + }, + mediaType = MediaType.ZIP.type, + bookId = book.id, + ) + mediaDao.insert(media) + + val found = mediaDao.findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(book.libraryId, listOf(MediaType.ZIP.type), komgaProperties.pageHashing) + + assertThat(found).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDaoTest.kt new file mode 100644 index 000000000..9df45f232 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/PageHashDaoTest.kt @@ -0,0 +1,137 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.PageHashKnown +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime + +@SpringBootTest +class PageHashDaoTest( + @Autowired private val pageHashDao: PageHashDao, + @Autowired private val mediaDao: MediaDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val series = makeSeries("Series", libraryId = library.id) + private val books = + listOf( + makeBook("Book", libraryId = library.id, seriesId = series.id), + makeBook("Book2", libraryId = library.id, seriesId = series.id), + ) + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + seriesRepository.insert(series) + bookRepository.insert(books) + } + + @AfterEach + fun deleteMedia() { + bookRepository.findAll().forEach { + mediaDao.delete(it.id) + } + } + + @AfterAll + fun tearDown() { + bookRepository.deleteAll() + seriesRepository.deleteAll() + libraryRepository.deleteAll() + } + + @Nested + inner class Known { + @Test + fun `given a known page hash when inserting then it is persisted`() { + val now = LocalDateTime.now() + val pageHash = + PageHashKnown( + hash = "hashed", + size = 10, + action = PageHashKnown.Action.IGNORE, + ) + + pageHashDao.insert(pageHash, null) + val known = pageHashDao.findKnown(pageHash.hash)!! + + assertThat(known.hash).isEqualTo(pageHash.hash) + assertThat(known.size).isEqualTo(pageHash.size) + assertThat(known.action).isEqualTo(pageHash.action) + assertThat(known.createdDate).isCloseTo(now, offset) + assertThat(known.lastModifiedDate).isCloseTo(now, offset) + } + } + + @Nested + inner class Unknown { + @Test + fun `given known hashes when finding unknown then known are not included`() { + // given + pageHashDao.insert( + PageHashKnown( + hash = "hash-1", + size = 1, + action = PageHashKnown.Action.IGNORE, + ), + null, + ) + + pageHashDao.insert( + PageHashKnown( + hash = "hash-2", + size = null, + action = PageHashKnown.Action.IGNORE, + ), + null, + ) + + val media = + Media( + status = Media.Status.READY, + mediaType = "application/zip", + pages = + (1..10).map { + BookPage( + fileName = "$it.jpg", + mediaType = "image/jpeg", + fileHash = "hash-$it", + fileSize = it.toLong(), + ) + }, + comment = "comment", + bookId = books.first().id, + ) + mediaDao.insert(media) + mediaDao.insert(media.copy(bookId = books.last().id)) + + // when + val unknown = pageHashDao.findAllUnknown(Pageable.unpaged()) + + // then + assertThat(unknown).hasSize(8) + assertThat(unknown.map { it.hash }) + .doesNotContain("hash-1") + .doesNotContain("hash-2") + .containsExactlyInAnyOrderElementsOf((3..10).map { "hash-$it" }) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDaoTest.kt new file mode 100644 index 000000000..ceff3c086 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadListDaoTest.kt @@ -0,0 +1,240 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.gotson.komga.language.toIndexedMap +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime + +@SpringBootTest +class ReadListDaoTest( + @Autowired private val readListDao: ReadListDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val library2 = makeLibrary("library2") + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + libraryRepository.insert(library2) + } + + @AfterEach + fun deleteSeries() { + readListDao.deleteAll() + bookRepository.deleteAll() + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given read list with books when inserting then it is persisted`() { + // given + val series = makeSeries("Series", library.id) + seriesRepository.insert(series) + val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) } + books.forEach { bookRepository.insert(it) } + + val readList = + ReadList( + name = "MyReadList", + summary = "summary", + bookIds = books.map { it.id }.toIndexedMap(), + ) + + // when + val now = LocalDateTime.now() + + readListDao.insert(readList) + val created = readListDao.findByIdOrNull(readList.id)!! + + // then + assertThat(created.name).isEqualTo(readList.name) + assertThat(created.summary).isEqualTo(readList.summary) + assertThat(created.createdDate) + .isEqualTo(created.lastModifiedDate) + .isCloseTo(now, offset) + assertThat(created.bookIds.values).containsExactlyElementsOf(books.map { it.id }) + } + + @Test + fun `given read list with updated books when updating then it is persisted`() { + // given + val series = makeSeries("Series", library.id) + seriesRepository.insert(series) + val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) } + books.forEach { bookRepository.insert(it) } + + val readList = + ReadList( + name = "MyReadList", + bookIds = books.map { it.id }.toIndexedMap(), + ) + + readListDao.insert(readList) + + // when + val updatedReadList = + readList.copy( + name = "UpdatedReadList", + summary = "summary", + bookIds = + readList.bookIds.values + .take(5) + .toIndexedMap(), + ) + + val now = LocalDateTime.now() + readListDao.update(updatedReadList) + val updated = readListDao.findByIdOrNull(updatedReadList.id)!! + + // then + assertThat(updated.name).isEqualTo(updatedReadList.name) + assertThat(updated.summary).isEqualTo(updatedReadList.summary) + assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(updated.lastModifiedDate).isCloseTo(now, offset) + assertThat(updated.bookIds.values as Iterable) + .hasSize(5) + .containsExactlyElementsOf(books.map { it.id }.take(5)) + } + + @Test + fun `given read lists with books when removing one book from all then it is removed from all`() { + // given + val series = makeSeries("Series", library.id) + seriesRepository.insert(series) + val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) } + books.forEach { bookRepository.insert(it) } + + val readList1 = + ReadList( + name = "MyReadList", + bookIds = books.map { it.id }.toIndexedMap(), + ) + readListDao.insert(readList1) + + val readList2 = + ReadList( + name = "MyReadList", + bookIds = books.map { it.id }.take(5).toIndexedMap(), + ) + readListDao.insert(readList2) + + // when + readListDao.removeBookFromAll(books.first().id) + + // then + val rl1 = readListDao.findByIdOrNull(readList1.id)!! + assertThat(rl1.bookIds.values as Iterable) + .hasSize(9) + .doesNotContain(books.first().id) + + val col2 = readListDao.findByIdOrNull(readList2.id)!! + assertThat(col2.bookIds.values as Iterable) + .hasSize(4) + .doesNotContain(books.first().id) + } + + @Test + fun `given read lists spanning different libraries when finding by library then only matching collections are returned`() { + // given + val seriesLibrary1 = makeSeries("Series1", library.id).also { seriesRepository.insert(it) } + val bookLibrary1 = makeBook("Book1", libraryId = library.id, seriesId = seriesLibrary1.id).also { bookRepository.insert(it) } + val seriesLibrary2 = makeSeries("Series2", library2.id).also { seriesRepository.insert(it) } + val bookLibrary2 = makeBook("Book2", libraryId = library2.id, seriesId = seriesLibrary2.id).also { bookRepository.insert(it) } + + readListDao.insert( + ReadList( + name = "readListLibrary1", + bookIds = listOf(bookLibrary1.id).toIndexedMap(), + ), + ) + + readListDao.insert( + ReadList( + name = "readListLibrary2", + bookIds = listOf(bookLibrary2.id).toIndexedMap(), + ), + ) + + readListDao.insert( + ReadList( + name = "readListLibraryBoth", + bookIds = listOf(bookLibrary1.id, bookLibrary2.id).toIndexedMap(), + ), + ) + + // when + val foundLibrary1Filtered = readListDao.findAll(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content + val foundLibrary1Unfiltered = readListDao.findAll(listOf(library.id), null, pageable = Pageable.unpaged()).content + val foundLibrary2Filtered = readListDao.findAll(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content + val foundLibrary2Unfiltered = readListDao.findAll(listOf(library2.id), null, pageable = Pageable.unpaged()).content + val foundBothUnfiltered = readListDao.findAll(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content + + // then + assertThat(foundLibrary1Filtered).hasSize(2) + assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth") + with(foundLibrary1Filtered.find { it.name == "readListLibraryBoth" }!!) { + assertThat(bookIds.values as Iterable) + .hasSize(1) + .containsExactly(bookLibrary1.id) + assertThat(filtered).isTrue + } + + assertThat(foundLibrary1Unfiltered).hasSize(2) + assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth") + with(foundLibrary1Unfiltered.find { it.name == "readListLibraryBoth" }!!) { + assertThat(bookIds.values as Iterable) + .hasSize(2) + .containsExactly(bookLibrary1.id, bookLibrary2.id) + assertThat(filtered).isFalse + } + + assertThat(foundLibrary2Filtered).hasSize(2) + assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth") + with(foundLibrary2Filtered.find { it.name == "readListLibraryBoth" }!!) { + assertThat(bookIds.values as Iterable) + .hasSize(1) + .containsExactly(bookLibrary2.id) + assertThat(filtered).isTrue + } + + assertThat(foundLibrary2Unfiltered).hasSize(2) + assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth") + with(foundLibrary2Unfiltered.find { it.name == "readListLibraryBoth" }!!) { + assertThat(bookIds.values as Iterable) + .hasSize(2) + .containsExactly(bookLibrary1.id, bookLibrary2.id) + assertThat(filtered).isFalse + } + + assertThat(foundBothUnfiltered).hasSize(3) + assertThat(foundBothUnfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibrary2", "readListLibraryBoth") + with(foundBothUnfiltered.find { it.name == "readListLibraryBoth" }!!) { + assertThat(bookIds.values as Iterable) + .hasSize(2) + .containsExactly(bookLibrary1.id, bookLibrary2.id) + assertThat(filtered).isFalse + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDaoTest.kt new file mode 100644 index 000000000..3028dc72c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDaoTest.kt @@ -0,0 +1,127 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDateTime + +@SpringBootTest +class ReadProgressDaoTest( + @Autowired private val readProgressDao: ReadProgressDao, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val series = makeSeries("Series") + + private val user1 = KomgaUser("user1@example.org", "") + private val user2 = KomgaUser("user2@example.org", "") + + private val book1 = makeBook("Book1") + private val book2 = makeBook("Book2") + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + seriesRepository.insert(series.copy(libraryId = library.id)) + userRepository.insert(user1) + userRepository.insert(user2) + bookRepository.insert(book1.copy(libraryId = library.id, seriesId = series.id)) + bookRepository.insert(book2.copy(libraryId = library.id, seriesId = series.id)) + } + + @AfterEach + fun deleteReadProgress() { + readProgressDao.deleteAll() + } + + @AfterAll + fun tearDown() { + userRepository.deleteAll() + bookRepository.deleteAll() + seriesRepository.deleteAll() + libraryRepository.deleteAll() + } + + @Test + fun `given book without user progress when saving progress then progress is saved`() { + val now = LocalDateTime.now() + + readProgressDao.save( + ReadProgress( + book1.id, + user1.id, + 5, + false, + ), + ) + + val readProgressList = readProgressDao.findAllByUserId(user1.id) + + assertThat(readProgressList).hasSize(1) + with(readProgressList.first()) { + assertThat(page).isEqualTo(5) + assertThat(completed).isEqualTo(false) + assertThat(bookId).isEqualTo(book1.id) + assertThat(readDate).isCloseTo(now, offset) + assertThat(createdDate) + .isCloseTo(now, offset) + .isEqualToIgnoringNanos(lastModifiedDate) + } + } + + @Test + fun `given book with user progress when saving progress then progress is updated`() { + readProgressDao.save( + ReadProgress( + book1.id, + user1.id, + 5, + false, + ), + ) + + val modificationDate = LocalDateTime.now() + val readDateInThePast = LocalDateTime.now().minusYears(1) + + readProgressDao.save( + ReadProgress( + book1.id, + user1.id, + 10, + true, + readDate = readDateInThePast, + ), + ) + + val readProgressList = readProgressDao.findAllByUserId(user1.id) + + assertThat(readProgressList).hasSize(1) + with(readProgressList.first()) { + assertThat(page).isEqualTo(10) + assertThat(completed).isEqualTo(true) + assertThat(bookId).isEqualTo(book1.id) + assertThat(readDate).isEqualToIgnoringNanos(readDateInThePast) + assertThat(createdDate) + .isBefore(modificationDate) + .isNotEqualTo(lastModifiedDate) + assertThat(lastModifiedDate).isCloseTo(modificationDate, offset) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDaoTest.kt new file mode 100644 index 000000000..713e499fd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDaoTest.kt @@ -0,0 +1,760 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.FilterBy +import org.gotson.komga.domain.model.FilterByEntity +import org.gotson.komga.domain.model.FilterTags +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.domain.service.SeriesMetadataLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import java.time.LocalDate + +@SpringBootTest +class ReferentialDaoTest( + @Autowired private val referentialDao: ReferentialDao, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val seriesMetadataLifecycle: SeriesMetadataLifecycle, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, +) { + private val library1 = makeLibrary() + private val library2 = makeLibrary() + private val series1 = makeSeries("Series 1").copy(libraryId = library1.id) + private val series2 = makeSeries("Series 2").copy(libraryId = library2.id) + private val seriesEmpty = makeSeries("Series Empty").copy(libraryId = library2.id) + private val seriesShared = makeSeries("Series Shared").copy(libraryId = library1.id) + private val seriesAge10 = makeSeries("Series Age 10").copy(libraryId = library1.id) + private val userAll = KomgaUser("user1@example.org", "p") + private val userLib1 = KomgaUser("user2@example.org", "p", sharedLibrariesIds = setOf(library1.id), sharedAllLibraries = false) + private val userLabelAllow = KomgaUser("user3@example.org", "p", restrictions = ContentRestrictions(labelsAllow = setOf("item_shared"))) + private val userAge10 = KomgaUser("user4@example.org", "p", restrictions = ContentRestrictions(ageRestriction = AgeRestriction(10, AllowExclude.ALLOW_ONLY))) + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + every { mockEventPublisher.publishEvent(any()) } just Runs + libraryRepository.insert(library1) + libraryRepository.insert(library2) + seriesLifecycle.createSeries(series1) + seriesLifecycle.createSeries(series2) + seriesLifecycle.createSeries(seriesEmpty) + seriesLifecycle.createSeries(seriesShared) + seriesLifecycle.createSeries(seriesAge10) + userRepository.insert(userAll) + userRepository.insert(userLib1) + userRepository.insert(userLabelAllow) + userRepository.insert(userAge10) + + // setup restrictions context + seriesMetadataRepository.findById(seriesShared.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("item_shared"))) + } + + seriesMetadataRepository.findById(seriesAge10.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + + // prepare metadata + makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book -> + seriesLifecycle.addBooks(series1, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("item1", "writer")), releaseDate = LocalDate.of(2002, 1, 1), tags = setOf("bt1"))) + } + seriesMetadataRepository.findById(series1.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("item1"), sharingLabels = setOf("item1"), language = "fr", publisher = "item1", ageRating = 18, tags = setOf("st1"))) + } + } + seriesMetadataLifecycle.aggregateMetadata(series1) + + makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book -> + seriesLifecycle.addBooks(series2, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("item2", "inker"), Author("item2", "translator")), releaseDate = LocalDate.of(2002, 2, 1), tags = setOf("bt2"))) + } + seriesMetadataRepository.findById(series2.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("item2"), sharingLabels = setOf("item2"), language = "en", publisher = "item2", ageRating = 19, tags = setOf("st2"))) + } + } + seriesMetadataLifecycle.aggregateMetadata(series2) + + makeBook("Empty", libraryId = library2.id, seriesId = seriesEmpty.id).let { book -> + seriesLifecycle.addBooks(seriesEmpty, listOf(book)) + } + seriesMetadataLifecycle.aggregateMetadata(seriesEmpty) + + makeBook("shared", libraryId = library1.id, seriesId = seriesShared.id).let { book -> + seriesLifecycle.addBooks(seriesShared, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("item_shared", "penciller")), releaseDate = LocalDate.of(2003, 1, 1), tags = setOf("bt_shared"))) + } + seriesMetadataRepository.findById(seriesShared.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("item_shared"), language = "ja", publisher = "item_shared", tags = setOf("st_shared"))) + } + } + seriesMetadataLifecycle.aggregateMetadata(seriesShared) + + makeBook("10", libraryId = library1.id, seriesId = seriesAge10.id).let { book -> + seriesLifecycle.addBooks(seriesAge10, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("item_10", "cover")), releaseDate = LocalDate.of(2004, 1, 1), tags = setOf("bt_10"))) + } + seriesMetadataRepository.findById(seriesAge10.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("item_10"), sharingLabels = setOf("item_10"), language = "sp", publisher = "item_10", tags = setOf("st_10"))) + } + } + seriesMetadataLifecycle.aggregateMetadata(seriesAge10) + } + + @BeforeEach + fun resetMocks() { + every { mockEventPublisher.publishEvent(any()) } just Runs + } + + @AfterAll + fun tearDown() { + every { mockEventPublisher.publishEvent(any()) } just Runs + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @Nested + inner class Author { + @Test + fun `given search when getting authors then matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthors(context, "shared", null, null, Pageable.unpaged()).content + + assertThat(items.map { it.name }).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given role when getting authors then matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthors(context, null, "writer", null, Pageable.unpaged()).content + + assertThat(items.map { it.name }).containsExactlyInAnyOrder("item1") + } + + @Test + fun `given filter by library when getting authors then only matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthors(context, null, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items.map { it.name }.toSet()).containsExactlyInAnyOrder("item2") + } + + @Test + fun `given user without restrictions when getting authors then all authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthors(context, null, null, null, Pageable.unpaged()).content + + assertThat(items.map { it.name }.toSet()).containsExactlyInAnyOrder("item1", "item2", "item_shared", "item_10") + } + + @Test + fun `given user with restricted library access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findAuthors(context, null, null, null, Pageable.unpaged()).content + + assertThat(items.map { it.name }).containsExactlyInAnyOrder("item1", "item_shared", "item_10") + } + + @Test + fun `given user with restricted label access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findAuthors(context, null, null, null, Pageable.unpaged()).content + + assertThat(items.map { it.name }).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given user with restricted age access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findAuthors(context, null, null, null, Pageable.unpaged()).content + + assertThat(items.map { it.name }).containsExactlyInAnyOrder("item_10") + } + } + + @Nested + inner class AuthorName { + @Test + fun `given search when getting authors then matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsNames(context, "shared", null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given role when getting authors then matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsNames(context, null, "writer", null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1") + } + + @Test + fun `given filter by library when getting authors then only matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsNames(context, null, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item2") + } + + @Test + fun `given user without restrictions when getting authors then all authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsNames(context, null, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item2", "item_shared", "item_10") + } + + @Test + fun `given user with restricted library access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findAuthorsNames(context, null, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item_shared", "item_10") + } + + @Test + fun `given user with restricted label access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findAuthorsNames(context, null, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given user with restricted age access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findAuthorsNames(context, null, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_10") + } + } + + @Nested + inner class AuthorRole { + @Test + fun `given filter by library when getting authors then only matching authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsRoles(context, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("inker", "translator") + } + + @Test + fun `given user without restrictions when getting authors then all authors are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAuthorsRoles(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("writer", "inker", "penciller", "cover", "translator") + } + + @Test + fun `given user with restricted library access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findAuthorsRoles(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("writer", "penciller", "cover") + } + + @Test + fun `given user with restricted label access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findAuthorsRoles(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("penciller") + } + + @Test + fun `given user with restricted age access when getting authors then only allowed authors are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findAuthorsRoles(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("cover") + } + } + + @Nested + inner class Genre { + @Test + fun `given search when getting genres then all genres are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findGenres(context, "shared", null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given filter by library when getting genres then only matching genres are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findGenres(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item2") + } + + @Test + fun `given user without restrictions when getting genres then all genres are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findGenres(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item2", "item_shared", "item_10") + } + + @Test + fun `given user with restricted library access when getting genres then only allowed genres are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findGenres(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item_shared", "item_10") + } + + @Test + fun `given user with restricted label access when getting genres then only allowed genres are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findGenres(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given user with restricted age access when getting genres then only allowed genres are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findGenres(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_10") + } + } + + @Nested + inner class BookTag { + @Test + fun `given search when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, "shared", null, FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt_shared") + } + + @Test + fun `given filter by library when getting tags then only matching tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt2") + } + + @Test + fun `given user without restrictions when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, null, FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt1", "bt2", "bt_shared", "bt_10") + } + + @Test + fun `given user with restricted library access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findTags(context, null, null, FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt1", "bt_shared", "bt_10") + } + + @Test + fun `given user with restricted label access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findTags(context, null, null, FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt_shared") + } + + @Test + fun `given user with restricted age access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findTags(context, null, null, FilterTags.BOOK, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("bt_10") + } + } + + @Nested + inner class SeriesTag { + @Test + fun `given search when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, "shared", null, FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_shared") + } + + @Test + fun `given filter by library when getting tags then only matching tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st2") + } + + @Test + fun `given user without restrictions when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, null, FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st1", "st2", "st_shared", "st_10") + } + + @Test + fun `given user with restricted library access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findTags(context, null, null, FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st1", "st_shared", "st_10") + } + + @Test + fun `given user with restricted label access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findTags(context, null, null, FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_shared") + } + + @Test + fun `given user with restricted age access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findTags(context, null, null, FilterTags.SERIES, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_10") + } + } + + @Nested + inner class BothTag { + @Test + fun `given search when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, "shared", null, FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_shared", "bt_shared") + } + + @Test + fun `given filter by library when getting tags then only matching tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st2", "bt2") + } + + @Test + fun `given user without restrictions when getting tags then all tags are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findTags(context, null, null, FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st1", "st2", "st_shared", "st_10", "bt1", "bt2", "bt_shared", "bt_10") + } + + @Test + fun `given user with restricted library access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findTags(context, null, null, FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st1", "st_shared", "st_10", "bt1", "bt_shared", "bt_10") + } + + @Test + fun `given user with restricted label access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findTags(context, null, null, FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_shared", "bt_shared") + } + + @Test + fun `given user with restricted age access when getting tags then only allowed tags are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findTags(context, null, null, FilterTags.BOTH, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("st_10", "bt_10") + } + } + + @Nested + inner class SharingLabel { + @Test + fun `given search when getting sharing labels then all sharing labels are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findSharingLabels(context, "shared", null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given filter by library when getting sharing labels then only matching sharing labels are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findSharingLabels(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item2") + } + + @Test + fun `given user without restrictions when getting sharing labels then all sharing labels are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findSharingLabels(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item2", "item_shared", "item_10") + } + + @Test + fun `given user with restricted library access when getting sharing labels then only allowed sharing labels are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findSharingLabels(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item_shared", "item_10") + } + + @Test + fun `given user with restricted label access when getting sharing labels then only allowed sharing labels are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findSharingLabels(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given user with restricted age access when getting sharing labels then only allowed sharing labels are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findSharingLabels(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_10") + } + } + + @Nested + inner class Publisher { + @Test + fun `given search when getting publishers then all publishers are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findPublishers(context, "shared", null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given filter by library when getting publishers then only matching publishers are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findPublishers(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item2") + } + + @Test + fun `given user without restrictions when getting publishers then all publishers are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findPublishers(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item2", "item_shared", "item_10") + } + + @Test + fun `given user with restricted library access when getting publishers then only allowed publishers are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findPublishers(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item1", "item_shared", "item_10") + } + + @Test + fun `given user with restricted label access when getting publishers then only allowed publishers are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findPublishers(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_shared") + } + + @Test + fun `given user with restricted age access when getting publishers then only allowed publishers are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findPublishers(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("item_10") + } + } + + @Nested + inner class Language { + @Test + fun `given search when getting languages then all languages are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findLanguages(context, "j", null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("ja") + } + + @Test + fun `given filter by library when getting languages then only matching languages are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findLanguages(context, null, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("en") + } + + @Test + fun `given user without restrictions when getting languages then all languages are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findLanguages(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("fr", "en", "ja", "sp") + } + + @Test + fun `given user with restricted library access when getting languages then only allowed languages are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findLanguages(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("fr", "ja", "sp") + } + + @Test + fun `given user with restricted label access when getting languages then only allowed languages are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findLanguages(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("ja") + } + + @Test + fun `given user with restricted age access when getting languages then only allowed languages are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findLanguages(context, null, null, Pageable.unpaged()).content + + assertThat(items).containsExactlyInAnyOrder("sp") + } + } + + @Nested + inner class AgeRating { + @Test + fun `given filter by library when getting age ratings then only matching age ratings are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAgeRatings(context, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactly(19) + } + + @Test + fun `given user without restrictions when getting age ratings then all age ratings are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findAgeRatings(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly(10, 18, 19) + } + + @Test + fun `given user with restricted library access when getting age ratings then only allowed age ratings are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findAgeRatings(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly(10, 18) + } + + @Test + fun `given user with restricted label access when getting age ratings then only allowed age ratings are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findAgeRatings(context, null, Pageable.unpaged()).content + + assertThat(items).isEmpty() + } + + @Test + fun `given user with restricted age access when getting age ratings then only allowed age ratings are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findAgeRatings(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly(10) + } + } + + @Nested + inner class SeriesReleaseYear { + @Test + fun `given filter by library when getting series release dates then only matching series release dates are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findSeriesReleaseYears(context, FilterBy(FilterByEntity.LIBRARY, setOf(library2.id)), Pageable.unpaged()).content + + assertThat(items).containsExactly("2002") + } + + @Test + fun `given user without restrictions when getting series release dates then all series release dates are returned`() { + val context = SearchContext(userAll) + val items = referentialDao.findSeriesReleaseYears(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly("2004", "2003", "2002") + } + + @Test + fun `given user with restricted library access when getting series release dates then only allowed series release dates are returned`() { + val context = SearchContext(userLib1) + val items = referentialDao.findSeriesReleaseYears(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly("2004", "2003", "2002") + } + + @Test + fun `given user with restricted label access when getting series release dates then only allowed series release dates are returned`() { + val context = SearchContext(userLabelAllow) + val items = referentialDao.findSeriesReleaseYears(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly("2003") + } + + @Test + fun `given user with restricted age access when getting series release dates then only allowed series release dates are returned`() { + val context = SearchContext(userAge10) + val items = referentialDao.findSeriesReleaseYears(context, null, Pageable.unpaged()).content + + assertThat(items).containsExactly("2004") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDaoTest.kt new file mode 100644 index 000000000..1c88911ee --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesCollectionDaoTest.kt @@ -0,0 +1,223 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime + +@SpringBootTest +class SeriesCollectionDaoTest( + @Autowired private val collectionDao: SeriesCollectionDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + private val library2 = makeLibrary("library2") + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + libraryRepository.insert(library2) + } + + @AfterEach + fun deleteSeries() { + collectionDao.deleteAll() + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given collection with series when inserting then it is persisted`() { + // given + val series = (1..10).map { makeSeries("Series $it", library.id) } + series.forEach { seriesRepository.insert(it) } + + val collection = + SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id }, + ) + + // when + val now = LocalDateTime.now() + + collectionDao.insert(collection) + val created = collectionDao.findByIdOrNull(collection.id)!! + + // then + assertThat(created.name).isEqualTo(collection.name) + assertThat(created.ordered).isEqualTo(collection.ordered) + assertThat(created.createdDate) + .isEqualTo(created.lastModifiedDate) + .isCloseTo(now, offset) + assertThat(created.seriesIds).containsExactlyElementsOf(series.map { it.id }) + } + + @Test + fun `given collection with updated series when updating then it is persisted`() { + // given + val series = (1..10).map { makeSeries("Series $it", library.id) } + series.forEach { seriesRepository.insert(it) } + + val collection = + SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id }, + ) + + collectionDao.insert(collection) + + // when + val updatedCollection = + collection.copy( + name = "UpdatedCollection", + ordered = true, + seriesIds = collection.seriesIds.take(5), + ) + + val now = LocalDateTime.now() + collectionDao.update(updatedCollection) + val updated = collectionDao.findByIdOrNull(updatedCollection.id)!! + + // then + assertThat(updated.name).isEqualTo(updatedCollection.name) + assertThat(updated.ordered).isEqualTo(updatedCollection.ordered) + assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(updated.lastModifiedDate).isCloseTo(now, offset) + assertThat(updated.seriesIds) + .hasSize(5) + .containsExactlyElementsOf(series.map { it.id }.take(5)) + } + + @Test + fun `given collections with series when removing one series from all then it is removed from all`() { + // given + val series = (1..10).map { makeSeries("Series $it", library.id) } + series.forEach { seriesRepository.insert(it) } + + val collection1 = + SeriesCollection( + name = "MyCollection", + seriesIds = series.map { it.id }, + ) + collectionDao.insert(collection1) + + val collection2 = + SeriesCollection( + name = "MyCollection2", + seriesIds = series.map { it.id }.take(5), + ) + collectionDao.insert(collection2) + + // when + collectionDao.removeSeriesFromAll(series.first().id) + + // then + val col1 = collectionDao.findByIdOrNull(collection1.id)!! + assertThat(col1.seriesIds) + .hasSize(9) + .doesNotContain(series.first().id) + + val col2 = collectionDao.findByIdOrNull(collection2.id)!! + assertThat(col2.seriesIds) + .hasSize(4) + .doesNotContain(series.first().id) + } + + @Test + fun `given collections spanning different libraries when finding by library then only matching collections are returned`() { + // given + val seriesLibrary1 = makeSeries("Series1", library.id).also { seriesRepository.insert(it) } + val seriesLibrary2 = makeSeries("Series2", library2.id).also { seriesRepository.insert(it) } + + collectionDao.insert( + SeriesCollection( + name = "collectionLibrary1", + seriesIds = listOf(seriesLibrary1.id), + ), + ) + + collectionDao.insert( + SeriesCollection( + name = "collectionLibrary2", + seriesIds = listOf(seriesLibrary2.id), + ), + ) + + collectionDao.insert( + SeriesCollection( + name = "collectionLibraryBoth", + seriesIds = listOf(seriesLibrary1.id, seriesLibrary2.id), + ), + ) + + // when + val foundLibrary1Filtered = collectionDao.findAll(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content + val foundLibrary1Unfiltered = collectionDao.findAll(listOf(library.id), null, pageable = Pageable.unpaged()).content + val foundLibrary2Filtered = collectionDao.findAll(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content + val foundLibrary2Unfiltered = collectionDao.findAll(listOf(library2.id), null, pageable = Pageable.unpaged()).content + val foundBothUnfiltered = collectionDao.findAll(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content + + // then + assertThat(foundLibrary1Filtered).hasSize(2) + assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth") + with(foundLibrary1Filtered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(1) + .containsExactly(seriesLibrary1.id) + assertThat(filtered).isTrue + } + + assertThat(foundLibrary1Unfiltered).hasSize(2) + assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth") + with(foundLibrary1Unfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse + } + + assertThat(foundLibrary2Filtered).hasSize(2) + assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth") + with(foundLibrary2Filtered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(1) + .containsExactly(seriesLibrary2.id) + assertThat(filtered).isTrue + } + + assertThat(foundLibrary2Unfiltered).hasSize(2) + assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth") + with(foundLibrary2Unfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse + } + + assertThat(foundBothUnfiltered).hasSize(3) + assertThat(foundBothUnfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibrary2", "collectionLibraryBoth") + with(foundBothUnfiltered.find { it.name == "collectionLibraryBoth" }!!) { + assertThat(seriesIds) + .hasSize(2) + .containsExactly(seriesLibrary1.id, seriesLibrary2.id) + assertThat(filtered).isFalse + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDaoTest.kt new file mode 100644 index 000000000..ef756d13c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDaoTest.kt @@ -0,0 +1,278 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.net.URL +import java.time.LocalDateTime + +@SpringBootTest +class SeriesDaoTest( + @Autowired private val seriesDao: SeriesDao, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + } + + @AfterEach + fun deleteSeries() { + seriesDao.deleteAll() + assertThat(seriesDao.count()).isEqualTo(0) + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given a series when inserting then it is persisted`() { + val now = LocalDateTime.now() + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = now, + libraryId = library.id, + deletedDate = now, + ) + + seriesDao.insert(series) + val created = seriesDao.findByIdOrNull(series.id)!! + + assertThat(created.id).isNotEqualTo(0) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + assertThat(created.name).isEqualTo(series.name) + assertThat(created.url).isEqualTo(series.url) + assertThat(created.fileLastModified).isEqualToIgnoringNanos(series.fileLastModified) + assertThat(created.deletedDate).isEqualToIgnoringNanos(series.deletedDate) + } + + @Test + fun `given a series when updating then it is persisted`() { + val now = LocalDateTime.now() + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = now, + libraryId = library.id, + ) + + seriesDao.insert(series) + + val modificationDate = LocalDateTime.now() + + val updated = + seriesDao.findByIdOrNull(series.id)!!.copy( + name = "Updated", + url = URL("file://updated"), + fileLastModified = modificationDate, + bookCount = 5, + deletedDate = LocalDateTime.now(), + ) + + seriesDao.update(updated) + val modified = seriesDao.findByIdOrNull(updated.id)!! + + assertThat(modified.id).isEqualTo(updated.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.name).isEqualTo("Updated") + assertThat(modified.url).isEqualTo(URL("file://updated")) + assertThat(modified.fileLastModified).isEqualToIgnoringNanos(modificationDate) + assertThat(modified.bookCount).isEqualTo(5) + assertThat(modified.deletedDate).isEqualToIgnoringNanos(updated.deletedDate) + } + + @Test + fun `given a series when deleting then it is deleted`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + + seriesDao.insert(series) + assertThat(seriesDao.count()).isEqualTo(1) + + seriesDao.delete(series.id) + + assertThat(seriesDao.count()).isEqualTo(0) + } + + @Test + fun `given series when deleting all then all are deleted`() { + val now = LocalDateTime.now() + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = now, + libraryId = library.id, + ) + + val series2 = + Series( + name = "Series2", + url = URL("file://series2"), + fileLastModified = now, + libraryId = library.id, + ) + + seriesDao.insert(series) + seriesDao.insert(series2) + assertThat(seriesDao.count()).isEqualTo(2) + + seriesDao.deleteAll() + + assertThat(seriesDao.count()).isEqualTo(0) + } + + @Test + fun `given series when finding all then all are returned`() { + val now = LocalDateTime.now() + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = now, + libraryId = library.id, + ) + + val series2 = + Series( + name = "Series2", + url = URL("file://series2"), + fileLastModified = now, + libraryId = library.id, + ) + + seriesDao.insert(series) + seriesDao.insert(series2) + + val all = seriesDao.findAll() + + assertThat(all).hasSize(2) + assertThat(all.map { it.name }).containsExactlyInAnyOrder("Series", "Series2") + } + + @Test + fun `given existing series when finding by id then series is returned`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + + seriesDao.insert(series) + + val found = seriesDao.findByIdOrNull(series.id) + + assertThat(found).isNotNull + assertThat(found?.name).isEqualTo("Series") + } + + @Test + fun `given non-existing series when finding by id then null is returned`() { + val found = seriesDao.findByIdOrNull("1287746") + + assertThat(found).isNull() + } + + @Test + fun `given existing series when finding by libraryId then series are returned`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + seriesDao.insert(series) + + val found = seriesDao.findAllByLibraryId(library.id) + + assertThat(found).hasSize(1) + assertThat(found.first().name).isEqualTo("Series") + } + + @Test + fun `given existing series when finding by other libraryId then empty list is returned`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + seriesDao.insert(series) + + val found = seriesDao.findAllByLibraryId(library.id + 1) + + assertThat(found).hasSize(0) + } + + @Test + fun `given existing series when finding by libraryId and Url not in list then results are returned`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + seriesDao.insert(series) + + val found = seriesDao.findAllNotDeletedByLibraryIdAndUrlNotIn(library.id, listOf(URL("file://series2"))) + val notFound = seriesDao.findAllNotDeletedByLibraryIdAndUrlNotIn(library.id, listOf(URL("file://series"))) + + assertThat(found).hasSize(1) + assertThat(found.first().name).isEqualTo("Series") + + assertThat(notFound).hasSize(0) + } + + @Test + fun `given existing series when finding by libraryId and Url in list then results are returned`() { + val series = + Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id, + ) + seriesDao.insert(series) + + val found = seriesDao.findNotDeletedByLibraryIdAndUrlOrNull(library.id, URL("file://series")) + val notFound1 = seriesDao.findNotDeletedByLibraryIdAndUrlOrNull(library.id, URL("file://series2")) + val notFound2 = seriesDao.findNotDeletedByLibraryIdAndUrlOrNull(library.id + 1, URL("file://series")) + val notFound3 = seriesDao.findNotDeletedByLibraryIdAndUrlOrNull(library.id + 1, URL("file://series2")) + + assertThat(found).isNotNull + assertThat(found?.name).isEqualTo("Series") + + assertThat(notFound1).isNull() + assertThat(notFound2).isNull() + assertThat(notFound3).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDaoTest.kt new file mode 100644 index 000000000..f4ccb93d1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDaoTest.kt @@ -0,0 +1,773 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.domain.service.SeriesMetadataLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.search.SearchIndexLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import java.time.LocalDate +import java.time.LocalDateTime + +@SpringBootTest +class SeriesDtoDaoTest( + @Autowired private val seriesDtoDao: SeriesDtoDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataLifecycle: SeriesMetadataLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val searchIndexLifecycle: SearchIndexLifecycle, +) { + private val library = makeLibrary() + private val user = KomgaUser("user@example.org", "") + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + every { mockEventPublisher.publishEvent(any()) } just Runs + libraryRepository.insert(library) + userRepository.insert(user) + } + + @BeforeEach + fun resetMocks() { + every { mockEventPublisher.publishEvent(any()) } just Runs + } + + @AfterEach + fun deleteSeries() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + searchIndexLifecycle.rebuildIndex() + } + + @AfterAll + fun tearDown() { + every { mockEventPublisher.publishEvent(any()) } just Runs + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + private fun setupSeries() { + (1..4) + .map { makeSeries("$it", library.id) } + .forEach { series -> + val created = seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks( + created, + (1..3).map { + makeBook("$it", seriesId = created.id, libraryId = library.id) + }, + ) + seriesLifecycle.sortBooks(created) + } + + val series = seriesRepository.findAll().sortedBy { it.name } + // series "1": only in progress books + series.elementAt(0).let { + bookRepository.findAllBySeriesId(it.id).forEach { readProgressRepository.save(ReadProgress(it.id, user.id, 5, false)) } + } + // series "2": only read books + series.elementAt(1).let { + bookRepository.findAllBySeriesId(it.id).forEach { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) } + } + // series "3": only unread books + // series "4": read, unread, and in progress + series.elementAt(3).let { + val books = bookRepository.findAllBySeriesId(it.id).sortedBy { it.name } + books.elementAt(0).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, false)) } + books.elementAt(1).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) } + } + } + + @Nested + inner class SortCriteria { + @Test + fun `given series when sorting by title sort then results are ordered`() { + // given + seriesLifecycle.createSeries(makeSeries("Éb", library.id)) + seriesLifecycle.createSeries(makeSeries("Ea", library.id)) + seriesLifecycle.createSeries(makeSeries("Ec", library.id)) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(), + SearchContext(user), + UnpagedSorted(Sort.by("metadata.titleSort")), + ).content + + // then + assertThat(found.map { it.metadata.title }).containsExactly("Ea", "Éb", "Ec") + } + } + + @Nested + inner class ReadProgress { + @Test + fun `given series in various read status when searching for read series then only read series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(1) + + assertThat(found.first().booksReadCount).isEqualTo(3) + assertThat(found.first().name).isEqualTo("2") + } + + @Test + fun `given series in various read status when searching for unread series then only unread series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(1) + + assertThat(found.first().booksUnreadCount).isEqualTo(3) + assertThat(found.first().name).isEqualTo("3") + } + + @Test + fun `given series in various read status when searching for in progress series then only in progress series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(2) + + assertThat(found.first().booksInProgressCount).isEqualTo(3) + assertThat(found.first().name).isEqualTo("1") + + assertThat(found.last().booksInProgressCount).isEqualTo(1) + assertThat(found.last().name).isEqualTo("4") + } + + @Test + fun `given series in various read status when searching for read and unread series then only matching series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch( + SearchCondition.AnyOfSeries( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(2) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3") + } + + @Test + fun `given series in various read status when searching for read and in progress series then only matching series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch( + SearchCondition.AnyOfSeries( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "4") + } + + @Test + fun `given series in various read status when searching for unread and in progress series then only matching series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch( + SearchCondition.AnyOfSeries( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3", "4") + } + + @Test + fun `given series in various read status when searching for read and unread and in progress series then only matching series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch( + SearchCondition.AnyOfSeries( + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)), + SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)), + ), + ), + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(4) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + } + + @Test + fun `given series in various read status when searching without read progress then all series are returned`() { + // given + setupSeries() + + // when + val found = + seriesDtoDao + .findAll( + SearchContext(user), + PageRequest.of(0, 20), + ).sortedBy { it.name } + + // then + assertThat(found).hasSize(4) + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + } + } + + @Nested + inner class FullTextSearch { + @Test + fun `given series when searching by term then results are ordered by rank`() { + // given + seriesLifecycle.createSeries(makeSeries("The incredible adventures of Batman, the man who is also a bat!", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "batman"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(3) + assertThat(found.map { it.metadata.title }).containsExactly("Batman", "Batman and Robin", "The incredible adventures of Batman, the man who is also a bat!") + } + + @Test + fun `given series when searching by publisher then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(publisher = "Vertigo")) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "publisher:vertigo"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by status then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(status = SeriesMetadata.Status.HIATUS)) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "status:hiatus"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by reading direction then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(readingDirection = SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT)) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "reading_direction:left_to_right"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by age rating then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 12)) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "age_rating:12"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by language then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(language = "en-us")) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "language:en-us"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by tags then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + val book = makeBook("Batman 01", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(tags = setOf("seriestag"))) + } + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(tags = setOf("booktag"))) + } + + seriesMetadataLifecycle.aggregateMetadata(series) + searchIndexLifecycle.rebuildIndex() + + // when + val foundByBookTag = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "book_tag:booktag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val notFoundByBookTag = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "book_tag:seriestag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val foundBySeriesTag = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "series_tag:seriestag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val notFoundBySeriesTag = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "series_tag:booktag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val foundByTagFromBook = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "tag:booktag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val foundByTagFromSeries = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "tag:seriestag"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(foundByBookTag).hasSize(1) + assertThat(foundByBookTag.map { it.metadata.title }).containsExactly("Batman") + + assertThat(notFoundByBookTag).isEmpty() + + assertThat(foundBySeriesTag).hasSize(1) + assertThat(foundBySeriesTag.map { it.metadata.title }).containsExactly("Batman") + + assertThat(notFoundBySeriesTag).isEmpty() + + assertThat(foundByTagFromBook).hasSize(1) + assertThat(foundByTagFromBook.map { it.metadata.title }).containsExactly("Batman") + + assertThat(foundByTagFromSeries).hasSize(1) + assertThat(foundByTagFromSeries.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by genre then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("action"))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "genre:action"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by total book count then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(totalBookCount = 5)) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "total_book_count:5"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by book count then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + seriesLifecycle.addBooks( + series, + listOf( + makeBook("Batman 01", seriesId = series.id, libraryId = library.id), + makeBook("Batman 02", seriesId = series.id, libraryId = library.id), + ), + ) + seriesLifecycle.sortBooks(series) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(genres = setOf("action"))) + } + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "book_count:2"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by authors then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + val book = makeBook("Batman 01", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update( + it.copy( + authors = + listOf( + Author("David", "penciller"), + ), + ), + ) + } + + seriesMetadataLifecycle.aggregateMetadata(series) + searchIndexLifecycle.rebuildIndex() + + // when + val foundGeneric = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "author:david"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val foundByRole = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "penciller:david"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + val notFoundByRole = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "writer:david"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(foundGeneric).hasSize(1) + assertThat(foundGeneric.map { it.metadata.title }).containsExactly("Batman") + + assertThat(foundByRole).hasSize(1) + assertThat(foundByRole.map { it.metadata.title }).containsExactly("Batman") + + assertThat(notFoundByRole).isEmpty() + } + + @Test + fun `given series when searching by release year then results are matched`() { + // given + val series = seriesLifecycle.createSeries(makeSeries("Batman", library.id)) + val book = makeBook("Batman 01", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book)) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(1999, 10, 10))) + } + + seriesMetadataLifecycle.aggregateMetadata(series) + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "release_date:1999"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + + @Test + fun `given series when searching by deleted then results are matched`() { + // given + seriesLifecycle.createSeries(makeSeries("Batman", library.id).copy(deletedDate = LocalDateTime.now())) + seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id)) + + searchIndexLifecycle.rebuildIndex() + + // when + val found = + seriesDtoDao + .findAll( + SeriesSearch(fullTextSearch = "deleted:true"), + SearchContext(user), + UnpagedSorted(Sort.by("relevance")), + ).content + + // then + assertThat(found).hasSize(1) + assertThat(found.map { it.metadata.title }).containsExactly("Batman") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDaoTest.kt new file mode 100644 index 000000000..3490e88d8 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesMetadataDaoTest.kt @@ -0,0 +1,311 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.AlternateTitle +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.jooq.offset +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.net.URI +import java.time.LocalDateTime + +@SpringBootTest +class SeriesMetadataDaoTest( + @Autowired private val seriesMetadataDao: SeriesMetadataDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val library = makeLibrary() + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + } + + @AfterEach + fun deleteSeries() { + seriesRepository.findAll().forEach { + seriesMetadataDao.delete(it.id) + } + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + @Test + fun `given a seriesMetadata when inserting then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val now = LocalDateTime.now() + val metadata = + SeriesMetadata( + status = SeriesMetadata.Status.ENDED, + title = "Series", + titleSort = "Series, The", + summary = "Summary", + readingDirection = SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT, + publisher = "publisher", + ageRating = 18, + genres = setOf("Action", "Adventure"), + tags = setOf("tag", "another"), + language = "en", + totalBookCount = 5, + sharingLabels = setOf("kids"), + links = listOf(WebLink("Comicvine", URI("https://comicvine.gamespot.com/doctor-strange/4050-2676/"))), + alternateTitles = listOf(AlternateTitle("fr", "La Series")), + titleLock = true, + titleSortLock = true, + summaryLock = true, + readingDirectionLock = true, + publisherLock = true, + ageRatingLock = true, + genresLock = true, + languageLock = true, + tagsLock = true, + totalBookCountLock = true, + sharingLabelsLock = true, + linksLock = true, + alternateTitlesLock = true, + seriesId = series.id, + ) + + seriesMetadataDao.insert(metadata) + val created = seriesMetadataDao.findById(metadata.seriesId) + + assertThat(created.seriesId).isEqualTo(series.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + + assertThat(created.title).isEqualTo(metadata.title) + assertThat(created.titleSort).isEqualTo(metadata.titleSort) + assertThat(created.summary).isEqualTo(metadata.summary) + assertThat(created.status).isEqualTo(SeriesMetadata.Status.ENDED) + assertThat(created.readingDirection).isEqualTo(metadata.readingDirection) + assertThat(created.publisher).isEqualTo(metadata.publisher) + assertThat(created.ageRating).isEqualTo(metadata.ageRating) + assertThat(created.language).isEqualTo(metadata.language) + assertThat(created.genres).containsAll(metadata.genres) + assertThat(created.tags).containsAll(metadata.tags) + assertThat(created.totalBookCount).isEqualTo(metadata.totalBookCount) + assertThat(created.sharingLabels).containsAll(metadata.sharingLabels) + with(created.links.first()) { + assertThat(label).isEqualTo(metadata.links.first().label) + assertThat(url).isEqualTo(metadata.links.first().url) + } + with(created.alternateTitles.first()) { + assertThat(label).isEqualTo(metadata.alternateTitles.first().label) + assertThat(title).isEqualTo(metadata.alternateTitles.first().title) + } + + assertThat(created.titleLock).isEqualTo(metadata.titleLock) + assertThat(created.titleSortLock).isEqualTo(metadata.titleSortLock) + assertThat(created.statusLock).isEqualTo(metadata.statusLock) + assertThat(created.summaryLock).isEqualTo(metadata.summaryLock) + assertThat(created.readingDirectionLock).isEqualTo(metadata.readingDirectionLock) + assertThat(created.publisherLock).isEqualTo(metadata.publisherLock) + assertThat(created.ageRatingLock).isEqualTo(metadata.ageRatingLock) + assertThat(created.genresLock).isEqualTo(metadata.genresLock) + assertThat(created.languageLock).isEqualTo(metadata.languageLock) + assertThat(created.tagsLock).isEqualTo(metadata.tagsLock) + assertThat(created.totalBookCountLock).isEqualTo(metadata.totalBookCountLock) + assertThat(created.sharingLabelsLock).isEqualTo(metadata.sharingLabelsLock) + assertThat(created.linksLock).isEqualTo(metadata.linksLock) + assertThat(created.alternateTitlesLock).isEqualTo(metadata.alternateTitlesLock) + } + + @Test + fun `given a minimum seriesMetadata when inserting then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val now = LocalDateTime.now() + val metadata = + SeriesMetadata( + title = "Series", + seriesId = series.id, + ) + + seriesMetadataDao.insert(metadata) + val created = seriesMetadataDao.findById(metadata.seriesId) + + assertThat(created.seriesId).isEqualTo(series.id) + assertThat(created.createdDate).isCloseTo(now, offset) + assertThat(created.lastModifiedDate).isCloseTo(now, offset) + + assertThat(created.title).isEqualTo(metadata.title) + assertThat(created.titleSort).isEqualTo(metadata.title) + assertThat(created.summary).isBlank + assertThat(created.status).isEqualTo(SeriesMetadata.Status.ONGOING) + assertThat(created.readingDirection).isNull() + assertThat(created.publisher).isBlank + assertThat(created.language).isBlank + assertThat(created.ageRating).isNull() + assertThat(created.genres).isEmpty() + assertThat(created.tags).isEmpty() + assertThat(created.totalBookCount).isNull() + assertThat(created.sharingLabels).isEmpty() + assertThat(created.links).isEmpty() + assertThat(created.alternateTitles).isEmpty() + + assertThat(created.titleLock).isFalse + assertThat(created.titleSortLock).isFalse + assertThat(created.statusLock).isFalse + assertThat(created.summaryLock).isFalse + assertThat(created.readingDirectionLock).isFalse + assertThat(created.publisherLock).isFalse + assertThat(created.ageRatingLock).isFalse + assertThat(created.genresLock).isFalse + assertThat(created.languageLock).isFalse + assertThat(created.tagsLock).isFalse + assertThat(created.totalBookCountLock).isFalse + assertThat(created.sharingLabelsLock).isFalse + assertThat(created.linksLock).isFalse + assertThat(created.alternateTitlesLock).isFalse + } + + @Test + fun `given existing seriesMetadata when finding by id then metadata is returned`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val metadata = + SeriesMetadata( + status = SeriesMetadata.Status.ENDED, + title = "Series", + titleSort = "Series, The", + seriesId = series.id, + ) + + seriesMetadataDao.insert(metadata) + + val found = seriesMetadataDao.findById(series.id) + + assertThat(found).isNotNull + assertThat(found.title).isEqualTo("Series") + } + + @Test + fun `given non-existing seriesMetadata when finding by id then exception is thrown`() { + val found = catchThrowable { seriesMetadataDao.findById("128742") } + + assertThat(found).isInstanceOf(Exception::class.java) + } + + @Test + fun `given non-existing seriesMetadata when findByIdOrNull then null is returned`() { + val found = seriesMetadataDao.findByIdOrNull("128742") + + assertThat(found).isNull() + } + + @Test + fun `given a seriesMetadata when updating then it is persisted`() { + val series = makeSeries("Series", libraryId = library.id).also { seriesRepository.insert(it) } + + val metadata = + SeriesMetadata( + status = SeriesMetadata.Status.ENDED, + title = "Series", + titleSort = "Series, The", + summary = "Summary", + readingDirection = SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT, + publisher = "publisher", + ageRating = 18, + language = "en", + genres = setOf("Action"), + tags = setOf("tag"), + totalBookCount = 3, + sharingLabels = setOf("kids"), + links = listOf(WebLink("Comicvine", URI("https://comicvine.gamespot.com/doctor-strange/4050-2676/"))), + alternateTitles = listOf(AlternateTitle("fr", "La Series")), + seriesId = series.id, + ) + seriesMetadataDao.insert(metadata) + val created = seriesMetadataDao.findById(metadata.seriesId) + + val modificationDate = LocalDateTime.now() + + val updated = + with(created) { + copy( + status = SeriesMetadata.Status.HIATUS, + title = "Changed", + titleSort = "Changed, The", + summary = "SummaryUpdated", + readingDirection = SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT, + publisher = "publisher2", + ageRating = 15, + language = "jp", + genres = setOf("Adventure"), + tags = setOf("Another"), + totalBookCount = 8, + sharingLabels = setOf("adult"), + links = emptyList(), + alternateTitles = emptyList(), + statusLock = true, + titleLock = true, + titleSortLock = true, + summaryLock = true, + readingDirectionLock = true, + publisherLock = true, + ageRatingLock = true, + languageLock = true, + genresLock = true, + tagsLock = true, + totalBookCountLock = true, + sharingLabelsLock = true, + linksLock = true, + alternateTitlesLock = true, + ) + } + + seriesMetadataDao.update(updated) + val modified = seriesMetadataDao.findById(updated.seriesId) + + assertThat(modified.seriesId).isEqualTo(series.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isCloseTo(modificationDate, offset) + .isNotEqualTo(modified.createdDate) + assertThat(modified.title).isEqualTo(updated.title) + assertThat(modified.titleSort).isEqualTo(updated.titleSort) + assertThat(modified.summary).isEqualTo(updated.summary) + assertThat(modified.status).isEqualTo(updated.status) + assertThat(modified.readingDirection).isEqualTo(updated.readingDirection) + assertThat(modified.publisher).isEqualTo(updated.publisher) + assertThat(modified.ageRating).isEqualTo(updated.ageRating) + assertThat(modified.language).isEqualTo(updated.language) + assertThat(modified.genres).containsAll(updated.genres) + assertThat(modified.tags).containsAll(updated.tags) + assertThat(modified.totalBookCount).isEqualTo(updated.totalBookCount) + assertThat(modified.sharingLabels).containsAll(updated.sharingLabels) + assertThat(modified.links).isEmpty() + assertThat(modified.alternateTitles).isEmpty() + + assertThat(modified.titleLock).isTrue + assertThat(modified.titleSortLock).isTrue + assertThat(modified.statusLock).isTrue + assertThat(modified.summaryLock).isTrue + assertThat(modified.readingDirectionLock).isTrue + assertThat(modified.ageRatingLock).isTrue + assertThat(modified.languageLock).isTrue + assertThat(modified.genresLock).isTrue + assertThat(modified.publisherLock).isTrue + assertThat(modified.tagsLock).isTrue + assertThat(modified.totalBookCountLock).isTrue + assertThat(modified.sharingLabelsLock).isTrue + assertThat(modified.linksLock).isTrue + assertThat(modified.alternateTitlesLock).isTrue + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesSearchTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesSearchTest.kt new file mode 100644 index 000000000..173887a83 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesSearchTest.kt @@ -0,0 +1,1368 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.SearchCondition +import org.gotson.komga.domain.model.SearchContext +import org.gotson.komga.domain.model.SearchOperator +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.domain.service.SeriesMetadataLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime + +@SpringBootTest +class SeriesSearchTest( + @Autowired private val seriesDao: SeriesDao, + @Autowired private val seriesDtoDao: SeriesDtoDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val seriesMetadataLifecycle: SeriesMetadataLifecycle, +) { + @Autowired + private lateinit var seriesMetadataRepository: SeriesMetadataRepository + private val library1 = makeLibrary() + private val library2 = makeLibrary() + private val user1 = KomgaUser("user1@example.org", "p") + private val user2 = KomgaUser("user2@example.org", "p") + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + every { mockEventPublisher.publishEvent(any()) } just Runs + libraryRepository.insert(library1) + libraryRepository.insert(library2) + userRepository.insert(user1) + userRepository.insert(user2) + } + + @BeforeEach + fun resetMocks() { + every { mockEventPublisher.publishEvent(any()) } just Runs + } + + @AfterEach + fun deleteSeries() { + collectionRepository.deleteAll() + seriesLifecycle.deleteMany(seriesRepository.findAll()) + assertThat(seriesDao.count()).isEqualTo(0) + } + + @AfterAll + fun tearDown() { + every { mockEventPublisher.publishEvent(any()) } just Runs + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @Test + fun `given some series when searching by library then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.LibraryId(SearchOperator.Is(library1.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.LibraryId(SearchOperator.IsNot(library1.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `when searching by empty aggregate then it works`() { + val series1 = makeSeries("1", library1.id) + seriesLifecycle.createSeries(series1) + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.AllOfSeries()) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.AnyOfSeries()) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + } + + @Test + fun `given some series when searching by collection then results are accurate`() { + val series1 = makeSeries("1", library1.id) + seriesLifecycle.createSeries(series1) + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + val collection = SeriesCollection("col1", seriesIds = listOf(series1.id)) + collectionRepository.insert(collection) + + run { + val search = SeriesSearch(SearchCondition.CollectionId(SearchOperator.Is(collection.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.CollectionId(SearchOperator.IsNot(collection.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some series in multiple collections when searching by collection then results are accurate`() { + val series1 = makeSeries("1", library1.id).also { seriesLifecycle.createSeries(it) } + val series2 = makeSeries("2", library1.id).also { seriesLifecycle.createSeries(it) } + val series3 = makeSeries("3", library1.id).also { seriesLifecycle.createSeries(it) } + val collection1 = SeriesCollection("col1", seriesIds = listOf(series1.id, series2.id, series3.id), ordered = true) + val collection2 = SeriesCollection("col2", seriesIds = listOf(series3.id, series2.id, series1.id), ordered = true) + collectionRepository.insert(collection1) + collectionRepository.insert(collection2) + + // search by collection 1 + run { + val search = SeriesSearch(SearchCondition.CollectionId(SearchOperator.Is(collection1.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + + // order not guaranteed for seriesDao + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactly("1", "2", "3") + } + + // search by collection 2 + run { + val search = SeriesSearch(SearchCondition.CollectionId(SearchOperator.Is(collection2.id))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + + // order not guaranteed for seriesDao + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "2", "1") + assertThat(foundDto.map { it.name }).containsExactly("3", "2", "1") + } + // search by collection 1 or 2 - order is not guaranteed in that case + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + SearchCondition.CollectionId(SearchOperator.Is(collection1.id)), + SearchCondition.CollectionId(SearchOperator.Is(collection2.id)), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("collection.number"))).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some series when searching by deleted then results are accurate`() { + makeSeries("1", library1.id).copy(deletedDate = LocalDateTime.now()).let { series -> + seriesLifecycle.createSeries(series) + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.Deleted(SearchOperator.IsTrue)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Deleted(SearchOperator.IsFalse)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some series when searching by complete then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(makeBook("1", libraryId = series.libraryId))) + seriesLifecycle.sortBooks(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(totalBookCount = 1)) } + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(makeBook("2", libraryId = series.libraryId))) + seriesLifecycle.sortBooks(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(totalBookCount = 2)) } + } + // series without total book count - will not be returned in complete or not complete + makeSeries("3", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(makeBook("3", libraryId = series.libraryId))) + seriesLifecycle.sortBooks(series) + } + + run { + val search = SeriesSearch(SearchCondition.Complete(SearchOperator.IsTrue)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Complete(SearchOperator.IsFalse)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some series when searching by one-shot then results are accurate`() { + makeSeries("1", library1.id).copy(oneshot = true).let { series -> + seriesLifecycle.createSeries(series) + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.OneShot(SearchOperator.IsTrue)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.OneShot(SearchOperator.IsFalse)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } + + @Test + fun `given some series when searching by release date then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("1", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.now().minusDays(5))) } + seriesMetadataLifecycle.aggregateMetadata(series) + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(makeBook("2", libraryId = series.libraryId))) + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now().minusDays(10)))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now()))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(10)))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(10)))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(1)))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.IsNull)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotNull)) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + } + + @Test + fun `given some series when searching by tag then results are accurate`() { + // series with both aggregated and series tags + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("1", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { bookMetadataRepository.update(it.copy(tags = setOf("horror"))) } + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(tags = setOf("fiction"))) } + seriesMetadataLifecycle.aggregateMetadata(series) + } + // series with series tag only + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(tags = setOf("fiction"))) } + } + // series with aggregated tag only + makeSeries("3", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("1", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { bookMetadataRepository.update(it.copy(tags = setOf("fantasy"))) } + seriesMetadataLifecycle.aggregateMetadata(series) + } + // series without tags + makeSeries("4", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.Tag(SearchOperator.Is("FîCTION"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.Tag(SearchOperator.Is("FICTION")), + SearchCondition.Tag(SearchOperator.Is("hórror")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + listOf( + SearchCondition.Tag(SearchOperator.Is("horror")), + SearchCondition.Tag(SearchOperator.Is("notexist")), + SearchCondition.Tag(SearchOperator.Is("fantasy")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.Tag(SearchOperator.Is("fiction")), + SearchCondition.Tag(SearchOperator.IsNot("horror")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.Tag(SearchOperator.IsNot("fiction"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + run { + val search = SeriesSearch(SearchCondition.Tag(SearchOperator.IsNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + run { + val search = SeriesSearch(SearchCondition.Tag(SearchOperator.IsNotNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some series when searching by sharing label then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids", "teens"))) } + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kÎds"))) } + } + makeSeries("3", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) } + } + makeSeries("4", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.SharingLabel(SearchOperator.Is("kids"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.SharingLabel(SearchOperator.Is("kids")), + SearchCondition.SharingLabel(SearchOperator.Is("teens")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + listOf( + SearchCondition.SharingLabel(SearchOperator.Is("teens")), + SearchCondition.SharingLabel(SearchOperator.Is("notexist")), + SearchCondition.SharingLabel(SearchOperator.Is("adult")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.SharingLabel(SearchOperator.Is("kids")), + SearchCondition.SharingLabel(SearchOperator.IsNot("teens")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.SharingLabel(SearchOperator.IsNot("kids"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + run { + val search = SeriesSearch(SearchCondition.SharingLabel(SearchOperator.IsNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + run { + val search = SeriesSearch(SearchCondition.SharingLabel(SearchOperator.IsNotNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some series when searching by publisher then results are accurate`() { + makeSeries("1", library1.id).copy(oneshot = true).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(publisher = "marvelous")) } + } + makeSeries("2", library1.id).copy(oneshot = true).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(publisher = "infinity")) } + } + makeSeries("3", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.Publisher(SearchOperator.Is("mÂrvelOUS"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Publisher(SearchOperator.IsNot("mÂrvelOUS"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3") + } + } + + @Test + fun `given some series when searching by language then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(language = "en")) } + } + makeSeries("2", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(language = "fr")) } + } + makeSeries("3", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.Language(SearchOperator.Is("EN"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Language(SearchOperator.IsNot("en"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3") + } + } + + @Test + fun `given some series when searching by genre then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(genres = setOf("kids", "teens"))) } + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(genres = setOf("kÎds"))) } + } + makeSeries("3", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(genres = setOf("adult"))) } + } + makeSeries("4", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.Genre(SearchOperator.Is("kids"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.Genre(SearchOperator.Is("kids")), + SearchCondition.Genre(SearchOperator.Is("teens")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + listOf( + SearchCondition.Genre(SearchOperator.Is("teens")), + SearchCondition.Genre(SearchOperator.Is("notexist")), + SearchCondition.Genre(SearchOperator.Is("adult")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.Genre(SearchOperator.Is("kids")), + SearchCondition.Genre(SearchOperator.IsNot("teens")), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.Genre(SearchOperator.IsNot("kids"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + run { + val search = SeriesSearch(SearchCondition.Genre(SearchOperator.IsNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("4") + } + + run { + val search = SeriesSearch(SearchCondition.Genre(SearchOperator.IsNotNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + } + + @Test + fun `given some series when searching by series status then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(status = SeriesMetadata.Status.ENDED)) } + } + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(status = SeriesMetadata.Status.ONGOING)) } + } + + run { + val search = SeriesSearch(SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ENDED))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.SeriesStatus(SearchOperator.IsNot(SeriesMetadata.Status.ENDED))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ENDED)), + SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.HIATUS)), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + listOf( + SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ENDED)), + SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ONGOING)), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + } + + @Test + fun `given some series when searching by read progress then results are accurate`() { + // in progress series, 1/2 book read + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val books = (1..2).map { makeBook("$it", libraryId = series.libraryId) } + seriesLifecycle.addBooks(series, books) + seriesLifecycle.sortBooks(series) + readProgressRepository.save(ReadProgress(books.first().id, user1.id, 5, true)) + } + // read series + makeSeries("2", library2.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("1", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + seriesLifecycle.sortBooks(series) + readProgressRepository.save(ReadProgress(book.id, user1.id, 5, true)) + } + // unread series + makeSeries("3", library2.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3") + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = seriesDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))) + val found = seriesDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3") + } + + run { + val search = SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))) + val found = seriesDao.findAll(search.condition, SearchContext(null), Pageable.unpaged()).content + val thrown = catchThrowable { seriesDtoDao.findAll(search, SearchContext(null), Pageable.unpaged()) } + + assertThat(found).isEmpty() + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `given some series when searching by author then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("1", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "writer"), Author("jim", "cover")))) + } + seriesMetadataLifecycle.aggregateMetadata(series) + } + makeSeries("2", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("2", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "artist"), Author("amanda", "artist")))) + } + seriesMetadataLifecycle.aggregateMetadata(series) + } + makeSeries("3", library1.id).let { series -> + seriesLifecycle.createSeries(series) + val book = makeBook("3", libraryId = series.libraryId) + seriesLifecycle.addBooks(series, listOf(book)) + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(authors = listOf(Author("jack", "writer")))) + } + seriesMetadataLifecycle.aggregateMetadata(series) + } + makeSeries("4", library1.id).let { series -> + seriesLifecycle.createSeries(series) + } + + // series with an author named 'john' + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("john"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + // series with an author named 'john' with the 'writer' role + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("john", "writer"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + // series with any author with the 'writer' role + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(role = "writer"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + // series without any author named 'john' with a 'writer' role + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.IsNot(SearchCondition.AuthorMatch("john", "writer"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3", "4") + } + + // series without any author named 'john' + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.IsNot(SearchCondition.AuthorMatch("jOhn"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4") + } + + // series without any author with the 'writer' role + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.IsNot(SearchCondition.AuthorMatch(role = "writer"))), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "4") + } + + // empty AuthorMatch does not apply any condition + run { + val search = + SeriesSearch( + SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch())), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4") + } + } + + @Test + fun `given some series when searching by age rating then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 1)) + } + } + makeSeries("2", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + makeSeries("3", library1.id).let { series -> + seriesLifecycle.createSeries(series) + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.Is(10))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.IsNot(10))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.GreaterThan(0))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.GreaterThan(5))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.LessThan(11))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.IsNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("3") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3") + } + + run { + val search = SeriesSearch(SearchCondition.AgeRating(SearchOperator.IsNotNullT())) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AnyOfSeries( + listOf( + SearchCondition.AgeRating(SearchOperator.Is(10)), + SearchCondition.AgeRating(SearchOperator.Is(1)), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = + SeriesSearch( + SearchCondition.AllOfSeries( + listOf( + SearchCondition.AgeRating(SearchOperator.Is(10)), + SearchCondition.AgeRating(SearchOperator.Is(1)), + ), + ), + ) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found).isEmpty() + assertThat(foundDto).isEmpty() + } + } + + @Test + fun `given some books when searching by title then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(title = "Series 1")) + } + } + makeSeries("2", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(title = "Series two")) + } + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.Is("séRIES 1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.IsNot("séRIES 1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.Contains("séRIES"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.DoesNotContain("TWÔ"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.BeginsWith("séRIES"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.DoesNotBeginWith("séRIES"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).isEmpty() + assertThat(foundDto.map { it.name }).isEmpty() + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.EndsWith("TWÔ"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.Title(SearchOperator.DoesNotEndWith("TWÔ"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + } + + @Test + fun `given some books when searching by titleSort then results are accurate`() { + makeSeries("1", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Series 1")) + } + } + makeSeries("2", library1.id).let { series -> + seriesLifecycle.createSeries(series) + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Series 2")) + } + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.Is("seRIES 1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.IsNot("series 1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.Contains("series"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.DoesNotContain("1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.BeginsWith("series"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.DoesNotBeginWith("series"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).isEmpty() + assertThat(foundDto.map { it.name }).isEmpty() + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.EndsWith("1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("1") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1") + } + + run { + val search = SeriesSearch(SearchCondition.TitleSort(SearchOperator.DoesNotEndWith("1"))) + val found = seriesDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content + val foundDto = seriesDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content + + assertThat(found.map { it.name }).containsExactlyInAnyOrder("2") + assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDaoTest.kt new file mode 100644 index 000000000..e566057d0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/ServerSettingsDaoTest.kt @@ -0,0 +1,68 @@ +package org.gotson.komga.infrastructure.jooq.main + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ServerSettingsDaoTest( + @Autowired private val serverSettingsDao: ServerSettingsDao, +) { + @AfterEach + fun cleanup() { + serverSettingsDao.deleteAll() + } + + @Test + fun `when saving String setting then it is persisted`() { + serverSettingsDao.saveSetting("setting", "value") + + val fetch = serverSettingsDao.getSettingByKey("setting", String::class.java) + + assertThat(fetch).isEqualTo("value") + } + + @Test + fun `when saving Int setting then it is persisted`() { + serverSettingsDao.saveSetting("setting", 12) + + val fetch = serverSettingsDao.getSettingByKey("setting", Int::class.java) + + assertThat(fetch).isEqualTo(12) + } + + @Test + fun `when saving Boolean setting then it is persisted`() { + serverSettingsDao.saveSetting("setting", true) + + val fetch = serverSettingsDao.getSettingByKey("setting", Boolean::class.java) + + assertThat(fetch).isTrue + } + + @Test + fun `given existing setting when saving again then it is overridden`() { + serverSettingsDao.saveSetting("setting", "value") + + val initial = serverSettingsDao.getSettingByKey("setting", String::class.java) + assertThat(initial).isEqualTo("value") + + serverSettingsDao.saveSetting("setting", "updated") + val updated = serverSettingsDao.getSettingByKey("setting", String::class.java) + assertThat(updated).isEqualTo("updated") + } + + @Test + fun `given existing setting when deleting then it is deleted`() { + serverSettingsDao.saveSetting("setting", "value") + + val initial = serverSettingsDao.getSettingByKey("setting", String::class.java) + assertThat(initial).isEqualTo("value") + + serverSettingsDao.deleteSetting("setting") + val updated = serverSettingsDao.getSettingByKey("setting", String::class.java) + assertThat(updated).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDaoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDaoTest.kt new file mode 100644 index 000000000..c67a97cad --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/tasks/TasksDaoTest.kt @@ -0,0 +1,290 @@ +package org.gotson.komga.infrastructure.jooq.tasks + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.application.tasks.Task +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class TasksDaoTest( + @Autowired private val tasksDao: TasksDao, +) { + @AfterEach + fun cleanup() { + tasksDao.deleteAll() + assertThat(tasksDao.count()).isEqualTo(0) + } + + @Test + fun `given tasks saved when finding tasks then tasks are found`() { + // given + val task1 = Task.AnalyzeBook("book1", 0, "group1") + val task2 = Task.ConvertBook("book2", 1, "group2") + val task3 = Task.ScanLibrary("library1", true, 2) + + tasksDao.save(listOf(task1, task2, task3)) + + // when + val tasks = tasksDao.findAll() + + // then + assertThat(tasks).hasSize(3) + tasks.sortedBy { it.priority }.let { + assertThat(it[0]) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task1.bookId) + .hasFieldOrPropertyWithValue("priority", task1.priority) + .hasFieldOrPropertyWithValue("groupId", task1.groupId) + + assertThat(it[1]) + .isInstanceOf(Task.ConvertBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task2.bookId) + .hasFieldOrPropertyWithValue("priority", task2.priority) + .hasFieldOrPropertyWithValue("groupId", task2.groupId) + + assertThat(it[2]) + .isInstanceOf(Task.ScanLibrary::class.java) + .hasFieldOrPropertyWithValue("libraryId", task3.libraryId) + .hasFieldOrPropertyWithValue("scanDeep", task3.scanDeep) + .hasFieldOrPropertyWithValue("priority", task3.priority) + .hasFieldOrPropertyWithValue("groupId", task3.groupId) + } + } + + @Test + fun `given existing task saved when saving again then it is overwritten`() { + // given + val task1 = Task.AnalyzeBook("book1", 0, "group1") + tasksDao.save(task1) + + tasksDao.findAll().let { tasks -> + assertThat(tasks).hasSize(1) + assertThat(tasks.first()) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task1.bookId) + .hasFieldOrPropertyWithValue("priority", task1.priority) + .hasFieldOrPropertyWithValue("groupId", task1.groupId) + } + + // when + val task2 = Task.AnalyzeBook("book1", 5, "group2") + tasksDao.save(task2) + + // then + tasksDao.findAll().let { tasks -> + assertThat(tasks).hasSize(1) + assertThat(tasks.first()) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task2.bookId) + .hasFieldOrPropertyWithValue("priority", task2.priority) + .hasFieldOrPropertyWithValue("groupId", task2.groupId) + } + } + + @Test + fun `given no existing tasks when taking fist then returns null`() { + // when + val task = tasksDao.takeFirst() + + // then + assertThat(task).isNull() + } + + @Test + fun `given existing tasks when taking first then it is owned`() { + // given + val task1 = Task.AnalyzeBook("book1", 5, "group1") + val task2 = Task.ConvertBook("book2", 3, "group2") + tasksDao.save( + listOf( + task1, + task2, + ), + ) + + // when + val task1Owned = tasksDao.takeFirst("thread1") + val task2Owned = tasksDao.takeFirst("thread2") + val taskEmpty = tasksDao.takeFirst("thread3") + + val allTasks = tasksDao.findAllGroupedByOwner() + + // then + assertThat(task1Owned) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task1.bookId) + .hasFieldOrPropertyWithValue("priority", task1.priority) + .hasFieldOrPropertyWithValue("groupId", task1.groupId) + + assertThat(task2Owned) + .isInstanceOf(Task.ConvertBook::class.java) + .hasFieldOrPropertyWithValue("bookId", task2.bookId) + .hasFieldOrPropertyWithValue("priority", task2.priority) + .hasFieldOrPropertyWithValue("groupId", task2.groupId) + + assertThat(taskEmpty).isNull() + + assertThat(allTasks.keys).containsExactlyInAnyOrder("thread1", "thread2") + } + + @Test + fun `given existing tasks not owned when finding by owner then owner is null`() { + // given + val task1 = Task.AnalyzeBook("book1", 5, "group1") + val task2 = Task.ConvertBook("book2", 3, "group2") + tasksDao.save( + listOf( + task1, + task2, + ), + ) + + // when + val allTasks = tasksDao.findAllGroupedByOwner() + + // then + assertThat(allTasks.keys).containsExactlyInAnyOrder(null) + assertThat(allTasks[null]).hasSize(2) + } + + @Test + fun `given existing tasks with group when taking then not more than 1 task per group can be owned`() { + // given + tasksDao.save( + buildList { + (1..10).forEach { add(Task.AnalyzeBook("book$it", 5, "group1")) } + (1..10).forEach { add(Task.ConvertBook("book$it", 3, "group2")) } + }, + ) + + // when + assertThat(tasksDao.hasAvailable()).isTrue + val first = tasksDao.takeFirst("thread1") + assertThat(tasksDao.hasAvailable()).isTrue + val second = tasksDao.takeFirst("thread2") + assertThat(tasksDao.hasAvailable()).isFalse + val third = tasksDao.takeFirst("thread3") + + tasksDao.delete(first!!.uniqueId) + assertThat(tasksDao.hasAvailable()).isTrue + val fourth = tasksDao.takeFirst("thread1") + assertThat(tasksDao.hasAvailable()).isFalse + val fifth = tasksDao.takeFirst("thread4") + + // then + assertThat(first) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", "book1") + + assertThat(second) + .isInstanceOf(Task.ConvertBook::class.java) + .hasFieldOrPropertyWithValue("bookId", "book1") + + assertThat(third).isNull() + + assertThat(fourth) + .isInstanceOf(Task.AnalyzeBook::class.java) + .hasFieldOrPropertyWithValue("bookId", "book2") + + assertThat(fifth).isNull() + } + + @Test + fun `given existing tasks without group when taking then all tasks can be owned`() { + // given + tasksDao.save( + buildList { + (1..100).forEach { add(Task.HashBookPages("book$it", 5)) } + }, + ) + + // when + var count = 0 + val tasks = mutableListOf() + while (tasksDao.hasAvailable()) { + tasksDao.takeFirst("thread${count++}")?.let { + tasks.add(it) + } + } + + // then + assertThat(tasks).hasSize(100) + } + + @Test + fun `given existing tasks when deleting all tasks without owner then only tasks without owner are deleted`() { + // given + tasksDao.save( + buildList { + (1..20).forEach { add(Task.HashBookPages("book$it", 5)) } + }, + ) + + repeat(5) { + tasksDao.takeFirst("thread$it") + } + + // when + val deletedCount = tasksDao.deleteAllWithoutOwner() + + // then + assertThat(deletedCount).isEqualTo(15) + + assertThat(tasksDao.findAll()).hasSize(5) + + val byOwner = tasksDao.findAllGroupedByOwner() + val expectedOwners = buildList { repeat(5) { add("thread$it") } } + assertThat(byOwner.keys) + .containsExactlyInAnyOrderElementsOf(expectedOwners) + .hasSize(5) + } + + @Test + fun `given existing tasks with owner when disowning tasks then tasks do not have owner anymore`() { + // given + tasksDao.save( + buildList { + (1..20).forEach { add(Task.HashBookPages("book$it", 5)) } + }, + ) + + val ownedTasks = mutableListOf() + repeat(5) { + tasksDao.takeFirst("thread$it")?.let { task -> ownedTasks.add(task) } + } + + assertThat(ownedTasks).hasSize(5) + + // when + val disownCount = tasksDao.disown() + val disownedTasks = + tasksDao + .findAll() + .filter { task -> ownedTasks.map { it.uniqueId }.contains(task.uniqueId) } + + val groupedByOwner = tasksDao.findAllGroupedByOwner() + + // then + assertThat(disownCount).isEqualTo(5) + assertThat(disownedTasks).hasSize(5) + assertThat(groupedByOwner.keys).containsExactly(null) + assertThat(groupedByOwner[null]).hasSize(20) + } + + @Test + fun `given a single task with owner when counting tasks then the count is 1`() { + // given + tasksDao.save(Task.HashBookPages("book1", 5)) + tasksDao.takeFirst() + + // when + val count = tasksDao.count() + val countByType = tasksDao.countBySimpleType() + + // then + assertThat(count).isEqualTo(1) + assertThat(countByType.values.sum()).isEqualTo(1) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverterTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverterTest.kt new file mode 100644 index 000000000..3f478c68f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KepubConverterTest.kt @@ -0,0 +1,94 @@ +package org.gotson.komga.infrastructure.kobo + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.makeBook +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createFile + +@SpringBootTest +class KepubConverterTest( + @Autowired private val kepubConverter: KepubConverter, +) { + @BeforeAll + fun setup( + @TempDir tmpDir: Path, + ) { + tmpDir.resolve("kepubify").apply { + createFile() + toFile().setExecutable(true) + kepubConverter.configureKepubify(this.toAbsolutePath().toString()) + } + } + + @Test + fun `given kepub book when converting then IllegalArgument is thrown`( + @TempDir dir: Path, + ) { + // given + val book = makeBook("book", url = dir.resolve("book.epub").toUri().toURL()) + val media = Media(mediaType = MediaType.EPUB.type, epubIsKepub = true) + + // when + val thrownBy = catchThrowable { kepubConverter.convertEpubToKepub(BookWithMedia(book, media), dir) } + + // then + assertThat(thrownBy).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `given non-EPUB book when converting then IllegalArgument is thrown`( + @TempDir dir: Path, + ) { + // given + val book = makeBook("book", url = dir.resolve("book.epub").toUri().toURL()) + val media = Media(mediaType = MediaType.ZIP.type) + + // when + val thrownBy = catchThrowable { kepubConverter.convertEpubToKepub(BookWithMedia(book, media), dir) } + + // then + assertThat(thrownBy).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `given non-existent file when converting then IllegalArgument is thrown`( + @TempDir dir: Path, + ) { + // given + val book = makeBook("book", url = dir.resolve("book.epub").toUri().toURL()) + val media = Media(mediaType = MediaType.EPUB.type) + + // when + val thrownBy = catchThrowable { kepubConverter.convertEpubToKepub(BookWithMedia(book, media), dir) } + + // then + assertThat(thrownBy).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `given existing book file and dummy kepubify when converting then conversion fails`( + @TempDir dir: Path, + ) { + // given + val source = Files.createTempFile(dir, "book", ".epub") + + val book = makeBook("book", url = source.toUri().toURL()) + val media = Media(mediaType = MediaType.EPUB.type) + + // when + val result = kepubConverter.convertEpubToKepub(BookWithMedia(book, media), dir) + + // then + assertThat(result).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt new file mode 100644 index 000000000..95aac33a0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt @@ -0,0 +1,80 @@ +package org.gotson.komga.infrastructure.kobo + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaSyncToken +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.util.Base64 + +@SpringBootTest +class KomgaSyncTokenGeneratorTest( + @Autowired private val tokenGenerator: KomgaSyncTokenGenerator, +) { + private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() } + + fun encodeToBase64(token: String): String = base64Encoder.encodeToString(token.toByteArray()) + + @Test + fun `given Kobo store token when getting token then it contains the kobo store token`() { + // given + val koboToken = "fake.token" + + // when + val komgaToken = tokenGenerator.fromBase64(koboToken) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEqualTo(koboToken) + } + + @Test + fun `given calibre web token when getting token then it contains the kobo store token`() { + // given + val calibreWebToken = + """ + { + "data": { + "archive_last_modified": -62135596800.0, + "books_last_created": -62135596800.0, + "books_last_modified": -62135596800.0, + "raw_kobo_store_token": "fake.token", + "reading_state_last_modified": -62135596800.0, + "tags_last_modified": -62135596800.0 + }, + "version": "1-1-0" + } + """.trimIndent() + + // when + val komgaToken = tokenGenerator.fromBase64(encodeToBase64(calibreWebToken)) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEqualTo("fake.token") + } + + @Test + fun `given Komga token when getting token then it contains the kobo store token`() { + // given + val komgaToken = KomgaSyncToken(rawKoboSyncToken = "fake.token") + + // when + val encodedToken = tokenGenerator.toBase64(komgaToken) + val decodedToken = tokenGenerator.fromBase64(encodedToken) + + // then + assertThat(decodedToken.rawKoboSyncToken).isEqualTo("fake.token") + assertThat(encodedToken).startsWith("KOMGA.") + } + + @Test + fun `given unidentified token when getting token then it is empty`() { + // given + val token = "unrecognized" + + // when + val komgaToken = tokenGenerator.fromBase64(token) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEmpty() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractorTest.kt new file mode 100644 index 000000000..432e1ecf1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/RarExtractorTest.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.infrastructure.mediacontainer.divina + +import org.apache.tika.config.TikaConfig +import org.assertj.core.api.Assertions +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class RarExtractorTest { + private val contentDetector = ContentDetector(TikaConfig()) + private val imageAnalyzer = ImageAnalyzer() + private val rarExtractor = RarExtractor(contentDetector, imageAnalyzer) + + @Test + fun `given rar file when parsing for entries then returns all images`() { + val fileResource = ClassPathResource("archives/rar4.rar") + + val entries = rarExtractor.getEntries(fileResource.file.toPath(), true) + + Assertions.assertThat(entries).hasSize(3) + with(entries.first()) { + Assertions.assertThat(name).isEqualTo("komga-1.png") + Assertions.assertThat(mediaType).isEqualTo("image/png") + Assertions.assertThat(dimension).isEqualTo(Dimension(48, 48)) + Assertions.assertThat(fileSize).isEqualTo(3108) + } + } + + @Test + fun `given rar file when parsing for entries without analyzing dimensions then returns all images without dimensions`() { + val fileResource = ClassPathResource("archives/rar4.rar") + + val entries = rarExtractor.getEntries(fileResource.file.toPath(), false) + + Assertions.assertThat(entries).hasSize(3) + with(entries.first()) { + Assertions.assertThat(name).isEqualTo("komga-1.png") + Assertions.assertThat(mediaType).isEqualTo("image/png") + Assertions.assertThat(dimension).isNull() + Assertions.assertThat(fileSize).isEqualTo(3108) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractorTest.kt new file mode 100644 index 000000000..a5123dd6c --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/divina/ZipExtractorTest.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.infrastructure.mediacontainer.divina + +import org.apache.tika.config.TikaConfig +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class ZipExtractorTest { + private val contentDetector = ContentDetector(TikaConfig()) + private val imageAnalyzer = ImageAnalyzer() + private val zipExtractor = ZipExtractor(contentDetector, imageAnalyzer) + + @Test + fun `given zip file when parsing for entries then returns all images`() { + val fileResource = ClassPathResource("archives/zip.zip") + + val entries = zipExtractor.getEntries(fileResource.file.toPath(), true) + + assertThat(entries).hasSize(1) + with(entries.first()) { + assertThat(name).isEqualTo("komga.png") + assertThat(mediaType).isEqualTo("image/png") + assertThat(dimension).isEqualTo(Dimension(48, 48)) + assertThat(fileSize).isEqualTo(3108) + } + } + + @Test + fun `given zip file when parsing for entries without analyzing dimensions then returns all images without dimensions`() { + val fileResource = ClassPathResource("archives/zip.zip") + + val entries = zipExtractor.getEntries(fileResource.file.toPath(), false) + + assertThat(entries).hasSize(1) + with(entries.first()) { + assertThat(name).isEqualTo("komga.png") + assertThat(mediaType).isEqualTo("image/png") + assertThat(dimension).isNull() + assertThat(fileSize).isEqualTo(3108) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NavTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NavTest.kt new file mode 100644 index 000000000..489165702 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NavTest.kt @@ -0,0 +1,81 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.EpubTocEntry +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.core.io.ClassPathResource +import java.util.stream.Stream +import kotlin.io.path.Path + +class NavTest { + @ParameterizedTest + @MethodSource("paramSource") + fun `given nav document when parsing nav section then nav entries are valid`( + navType: Epub3Nav, + prefix: String?, + expectedProvider: (String) -> List, + ) { + // given + val navResource = ClassPathResource("epub/nav.xhtml") + val navString = navResource.inputStream.readAllBytes().decodeToString() + + // when + val nav = processNav(ResourceContent(Path("${prefix?.let { "$it/" }}nav.xhtml"), navString), navType) + + // then + val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "") + + assertThat(nav).isEqualTo(expectedNav) + } + + private fun paramSource(): Stream = + Stream.of( + Arguments.of(Epub3Nav.TOC, null, ::getExpectedNavToc), + Arguments.of(Epub3Nav.TOC, "PREFIX", ::getExpectedNavToc), + Arguments.of(Epub3Nav.LANDMARKS, null, ::getExpectedNavLandmarks), + Arguments.of(Epub3Nav.LANDMARKS, "PREFIX", ::getExpectedNavLandmarks), + Arguments.of(Epub3Nav.PAGELIST, null, ::getExpectedNavPageList), + Arguments.of(Epub3Nav.PAGELIST, "PREFIX", ::getExpectedNavPageList), + ) + + private fun getExpectedNavToc(prefix: String = "") = + listOf( + EpubTocEntry("Cover", "${prefix}cover.xhtml"), + EpubTocEntry("Title Page", "${prefix}titlepage.xhtml"), + EpubTocEntry("Copyright", "${prefix}copyright.xhtml"), + EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"), + EpubTocEntry("An unlinked heading", null), + EpubTocEntry( + "Introduction", + "${prefix}introduction.xhtml", + children = + listOf( + EpubTocEntry("Spring", "${prefix}chapter 001.xhtml"), + EpubTocEntry("Summer", "${prefix}chapter 027.xhtml"), + EpubTocEntry("Fall", "${prefix}chapter053.xhtml#what:why"), + EpubTocEntry("Winter", "${prefix}chapter 079.xhtml"), + ), + ), + EpubTocEntry("Acknowledgments", "${prefix}acknowledgements.xhtml"), + ) + + private fun getExpectedNavLandmarks(prefix: String = "") = + listOf( + EpubTocEntry("Begin Reading", "${prefix}cover.xhtml#coverimage"), + EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"), + ) + + private fun getExpectedNavPageList(prefix: String = "") = + listOf( + EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"), + EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"), + EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"), + EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"), + EpubTocEntry("107", "${prefix}xhtml/acknowledgments.xhtml#pg_107"), + EpubTocEntry("ii", "${prefix}xhtml/adcard.xhtml#pg_ii"), + EpubTocEntry("109", "${prefix}xhtml/abouttheauthor.xhtml#pg_109"), + EpubTocEntry("iv", "${prefix}xhtml/copyright.xhtml#pg_iv"), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NcxTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NcxTest.kt new file mode 100644 index 000000000..e98d508b3 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/NcxTest.kt @@ -0,0 +1,84 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.EpubTocEntry +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.core.io.ClassPathResource +import java.util.stream.Stream +import kotlin.io.path.Path + +class NcxTest { + @ParameterizedTest + @MethodSource("paramSource") + fun `given ncx document when parsing nav then nav entries are valid`( + navType: Epub2Nav, + prefix: String?, + expectedProvider: (String) -> List, + ) { + // given + val ncxResource = ClassPathResource("epub/toc.ncx") + val ncxString = ncxResource.inputStream.readAllBytes().decodeToString() + + // when + val ncxNav = processNcx(ResourceContent(Path("${prefix?.let { "$it/" }}toc.ncx"), ncxString), navType) + + // then + val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "") + + assertThat(ncxNav).isEqualTo(expectedNav) + } + + private fun paramSource(): Stream = + Stream.of( + Arguments.of(Epub2Nav.TOC, null, ::getExpectedNcxToc), + Arguments.of(Epub2Nav.TOC, "PREFIX", ::getExpectedNcxToc), + Arguments.of(Epub2Nav.PAGELIST, null, ::getExpectedNcxPageList), + Arguments.of(Epub2Nav.PAGELIST, "PREFIX", ::getExpectedNcxPageList), + ) + + private fun getExpectedNcxToc(prefix: String = "") = + listOf( + EpubTocEntry("COVER", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm#b02-cvi"), + EpubTocEntry("BRAN", "${prefix}Text/Mart_9780553897852_epub_c69_r1.htm"), + EpubTocEntry( + "APPENDIX", + "${prefix}Text/Mart_9780553897852_epub_app_r1.htm", + children = + listOf( + EpubTocEntry("THE KINGS AND THEIR COURTS", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.00"), + EpubTocEntry("THE KING ON THE IRON THRONE", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.01"), + EpubTocEntry("THE KING IN THE NARROW SEA", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.02"), + EpubTocEntry("THE KING IN HIGHGARDEN", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.03"), + EpubTocEntry("THE KING IN THE NORTH", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.04"), + EpubTocEntry( + "THE QUEEN ACROSS THE WATER", + "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.05", + children = + listOf( + EpubTocEntry("Another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.06"), + EpubTocEntry("Yet another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.07"), + ), + ), + ), + ), + EpubTocEntry("ACKNOWLEDGMENTS", "${prefix}Text/Mart_9780553897852_epub_ack_r1.htm"), + EpubTocEntry("Omake 1: Ichika’s Q&A", "${prefix}Text/First Omake - Ichika’s Q&A.xhtml"), + ) + + private fun getExpectedNcxPageList(prefix: String = "") = + listOf( + EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"), + EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"), + EpubTocEntry("v", "${prefix}xhtml/dedication.xhtml#pg_v"), + EpubTocEntry("vii", "${prefix}xhtml/formoreinformation.xhtml#pg_vii"), + EpubTocEntry("viii", "${prefix}xhtml/formoreinformation.xhtml#pg_viii"), + EpubTocEntry("ix", "${prefix}xhtml/formoreinformation.xhtml#pg_ix"), + EpubTocEntry("x", "${prefix}xhtml/formoreinformation.xhtml#pg_x"), + EpubTocEntry("xi", "${prefix}xhtml/formoreinformation.xhtml#pg_xi"), + EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"), + EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"), + EpubTocEntry("3", "${prefix}xhtml/chapter1.xhtml#pg_3"), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/OpfTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/OpfTest.kt new file mode 100644 index 000000000..91afc1cd6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/OpfTest.kt @@ -0,0 +1,36 @@ +package org.gotson.komga.infrastructure.mediacontainer.epub + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.EpubTocEntry +import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.core.io.ClassPathResource +import java.nio.file.Path + +class OpfTest { + @ParameterizedTest + @ValueSource(strings = ["", "PREFIX"]) + fun `given ncx document and opfDir when landmarks TOC then TOC entries are valid`(prefix: String) { + // given + val opfResource = ClassPathResource("epub/clash.opf") + val opfString = opfResource.inputStream.readAllBytes().decodeToString() + val opfDoc = Jsoup.parse(opfString, "", Parser.xmlParser()) + + // when + val opfLandmarks = processOpfGuide(opfDoc, if (prefix.isBlank()) null else Path.of(prefix)) + + // then + val expectedToc = getExpectedOpfLandmarks(if (prefix.isBlank()) "" else "$prefix/") + + assertThat(opfLandmarks).isEqualTo(expectedToc) + } + + private fun getExpectedOpfLandmarks(prefix: String = "") = + listOf( + EpubTocEntry("Table Of Contents", "${prefix}Text/Mart_9780553897852_epub_toc_r1.htm"), + EpubTocEntry("Text", "${prefix}Text/Mart_9780553897852_epub_prl_r1.htm"), + EpubTocEntry("Cover", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm"), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractorTest.kt new file mode 100644 index 000000000..44c3d483f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/pdf/PdfExtractorTest.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.mediacontainer.pdf + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.infrastructure.image.ImageType +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class PdfExtractorTest { + private val pdfExtractor = PdfExtractor(ImageType.JPEG, 1000F) + + @Test + fun `given pdf file when getting pages then pages are returned`() { + val fileResource = ClassPathResource("pdf/komga.pdf") + + val pages = pdfExtractor.getPages(fileResource.file.toPath(), true) + + assertThat(pages).hasSize(1) + assertThat(pages.first().dimension?.width).isEqualTo(512) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt new file mode 100644 index 000000000..7d25e4f66 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt @@ -0,0 +1,65 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import io.mockk.every +import io.mockk.mockk +import org.apache.commons.validator.routines.ISBNValidator +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.service.BookAnalyzer +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class IsbnBarcodeProviderTest { + private val mockAnalyzer = mockk() + private val isbnBarcodeProvider = IsbnBarcodeProvider(mockAnalyzer, ISBNValidator(true)) + + @Test + fun `given book page with barcode when getting book metadata then ISBN is returned`() { + // given + val file = ClassPathResource("barcode/page_384.jpg").file + every { mockAnalyzer.getPageContent(any(), any()) } returns file.readBytes() + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + // then + assertThat(patch?.isbn).isEqualTo("9782811632397") + } + + @Test + fun `given invalid image page when getting book metadata then patch is null`() { + // given + every { mockAnalyzer.getPageContent(any(), any()) } returns ByteArray(0) + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + // then + assertThat(patch).isNull() + } + + @Test + fun `given page without barcode when getting book metadata then patch is null`() { + // given + val file = ClassPathResource("barcode/komga.png").file + every { mockAnalyzer.getPageContent(any(), any()) } returns file.readBytes() + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + // then + assertThat(patch).isNull() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt new file mode 100644 index 000000000..129c8e9ed --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt @@ -0,0 +1,543 @@ +package org.gotson.komga.infrastructure.metadata.comicrack + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import io.mockk.every +import io.mockk.mockk +import org.apache.commons.validator.routines.ISBNValidator +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.WebLink +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.comicrack.dto.AgeRating +import org.gotson.komga.infrastructure.metadata.comicrack.dto.ComicInfo +import org.gotson.komga.infrastructure.metadata.comicrack.dto.Manga +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.net.URI +import java.time.LocalDate +import java.util.stream.Stream + +class ComicInfoProviderTest { + private val mockMapper = mockk() + private val mockAnalyzer = + mockk().also { + every { it.getFileContent(any(), "ComicInfo.xml") } returns ByteArray(0) + } + private val isbnValidator = ISBNValidator(true) + + private val comicInfoProvider = ComicInfoProvider(mockMapper, mockAnalyzer, isbnValidator) + + private val book = makeBook("book") + private val media = + Media( + status = Media.Status.READY, + mediaType = "application/zip", + files = listOf(MediaFile("ComicInfo.xml")), + ) + + @Nested + inner class Book { + @Test + fun `given comicInfo when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + title = "title" + summary = "summary" + number = "010" + year = 2020 + month = 2 + alternateSeries = "story arc" + alternateNumber = "5" + storyArc = "one, two, three" + web = " https://www.comixology.com/Sandman/digital-comic/727888 https://www.comics.com/Sandman/digital-comic/727889 " + tags = "dark, Occult" + gtin = "9783440077894" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(title).isEqualTo("title") + assertThat(summary).isEqualTo("summary") + assertThat(number).isEqualTo("010") + assertThat(numberSort).isEqualTo(10F) + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 2, 1)) + assertThat(isbn).isEqualTo("9783440077894") + + assertThat(readLists) + .hasSize(4) + .containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("story arc", 5), + BookMetadataPatch.ReadListEntry("one"), + BookMetadataPatch.ReadListEntry("two"), + BookMetadataPatch.ReadListEntry("three"), + ) + + assertThat(links) + .containsExactlyInAnyOrder( + WebLink("www.comixology.com", URI("https://www.comixology.com/Sandman/digital-comic/727888")), + WebLink("www.comics.com", URI("https://www.comics.com/Sandman/digital-comic/727889")), + ) + + assertThat(tags as Iterable) + .hasSize(2) + .containsExactlyInAnyOrder("dark", "occult") + } + } + + @Test + fun `given comicInfo with single link when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + web = "https://www.comixology.com/Sandman/digital-comic/727888" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(links) + .containsExactlyInAnyOrder( + WebLink("www.comixology.com", URI("https://www.comixology.com/Sandman/digital-comic/727888")), + ) + } + } + + @Test + fun `given comicInfo with StoryArcNumber when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + storyArc = "one" + storyArcNumber = "6" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(1) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("one", 6), + ) + } + } + + @Test + fun `given comicInfo with multiple StoryArcNumber when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + alternateSeries = "story arc" + alternateNumber = "5" + storyArc = "one, two, three" + storyArcNumber = "6, 7, 8" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(4) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("story arc", 5), + BookMetadataPatch.ReadListEntry("one", 6), + BookMetadataPatch.ReadListEntry("two", 7), + BookMetadataPatch.ReadListEntry("three", 8), + ) + } + } + + @Test + fun `given comicInfo with uneven StoryArcNumber when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + storyArc = "one, two" + storyArcNumber = "6, 7, 8" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(2) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("one", 6), + BookMetadataPatch.ReadListEntry("two", 7), + ) + } + } + + @Test + fun `given another comicInfo with uneven StoryArcNumber when getting book metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + storyArc = "one, two, three" + storyArcNumber = "6, 7" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(2) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("one", 6), + BookMetadataPatch.ReadListEntry("two", 7), + ) + } + } + + @Test + fun `given comicInfo with invalid StoryArcNumber when getting book metadata then invalid pairs are omitted`() { + val comicInfo = + ComicInfo().apply { + storyArc = "one, two, three" + storyArcNumber = "6, x, 8" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(2) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("one", 6), + BookMetadataPatch.ReadListEntry("three", 8), + ) + } + } + + @Test + fun `given comicInfo with invalid StoryArc when getting book metadata then invalid pairs are omitted`() { + val comicInfo = + ComicInfo().apply { + storyArc = "one, , three" + storyArcNumber = "6, 7, 8" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(readLists).hasSize(2) + assertThat(readLists).containsExactlyInAnyOrder( + BookMetadataPatch.ReadListEntry("one", 6), + BookMetadataPatch.ReadListEntry("three", 8), + ) + } + } + + @Test + fun `given comicInfo with blank values when getting series metadata then blank values are omitted`() { + val comicInfo = + ComicInfo().apply { + title = "" + summary = "" + number = "" + alternateSeries = "" + alternateNumber = "" + storyArc = "" + penciller = "" + gtin = "" + web = "" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(title).isNull() + assertThat(summary).isNull() + assertThat(number).isNull() + assertThat(numberSort).isNull() + assertThat(authors).isNull() + assertThat(readLists).isEmpty() + assertThat(isbn).isNull() + assertThat(links).isNull() + } + } + + @Test + fun `given comicInfo without year when getting book metadata then release date is null`() { + val comicInfo = + ComicInfo().apply { + month = 2 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(releaseDate).isNull() + } + } + + @Test + fun `given comicInfo with year but without month when getting book metadata then release date is set`() { + val comicInfo = + ComicInfo().apply { + year = 2020 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) + } + } + + @Test + fun `given comicInfo with authors when getting book metadata then authors are set`() { + val comicInfo = + ComicInfo().apply { + writer = "writer" + penciller = "penciller" + inker = "inker" + colorist = "colorist" + editor = "editor" + translator = "translator" + letterer = "letterer" + coverArtist = "coverArtist" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(authors).hasSize(8) + assertThat(authors?.map { it.name }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "translator", "coverArtist") + assertThat(authors?.map { it.role }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "translator", "cover") + } + } + + @Test + fun `given comicInfo with multiple authors when getting book metadata then authors are set`() { + val comicInfo = + ComicInfo().apply { + writer = "writer, writer2" + penciller = "penciller, penciller2" + inker = "inker, inker2" + colorist = "colorist, colorist2" + editor = "editor, editor2" + translator = "translator, translator2" + letterer = "letterer, letterer2" + coverArtist = "coverArtist, coverArtist2" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(authors).hasSize(16) + assertThat(authors?.map { it.name }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "coverArtist", "writer2", "penciller2", "inker2", "colorist2", "editor2", "letterer2", "coverArtist2", "translator", "translator2") + assertThat(authors?.map { it.role }?.distinct()).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "cover", "translator") + } + } + + @Test + fun `given book without comicInfo file when getting book metadata then return null`() { + val book = makeBook("book") + val media = Media(Media.Status.READY) + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + assertThat(patch).isNull() + } + } + + @Nested + inner class Series { + @Test + fun `given comicInfo when getting series metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + series = "séries" + seriesGroup = "multiple,collections" + publisher = "publisher" + ageRating = AgeRating.MA_15 + manga = Manga.YES_AND_RIGHT_TO_LEFT + languageISO = "en" + count = 10 + genre = "Action, Adventure" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(title).isEqualTo("séries") + assertThat(titleSort).isEqualTo("séries") + assertThat(status).isNull() + assertThat(collections).containsExactlyInAnyOrder("collections", "multiple") + assertThat(publisher).isEqualTo("publisher") + assertThat(ageRating).isEqualTo(15) + assertThat(readingDirection).isEqualTo(SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT) + assertThat(language).isEqualTo("en") + assertThat(summary).isBlank + assertThat(totalBookCount).isEqualTo(10) + assertThat(genres).containsExactlyInAnyOrder("Action", "Adventure") + } + } + + @Test + fun `given comicInfo with volume when getting series metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + series = "series" + volume = 2020 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(title).isEqualTo("series (2020)") + } + + val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), false)!! + + with(patchNoAppend) { + assertThat(title).isEqualTo("series") + } + } + + @Test + fun `given comicInfo with volume as 1 when getting series metadata then metadata title omits volume`() { + val comicInfo = + ComicInfo().apply { + series = "series" + volume = 1 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(title).isEqualTo("series") + } + + val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), false)!! + + with(patchNoAppend) { + assertThat(title).isEqualTo("series") + } + } + + @Test + fun `given comicInfo with incorrect values when getting series metadata then metadata patch is valid`() { + val comicInfo = + ComicInfo().apply { + languageISO = "japanese" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(language).isNull() + } + } + + @ParameterizedTest + @MethodSource("languagesSource") + fun `given comicInfo with malformed BCP-47 language when getting series metadata then patch language is normalized`( + source: String, + expected: String, + ) { + val comicInfo = + ComicInfo().apply { + languageISO = source + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(language).isEqualTo(expected) + } + } + + private fun languagesSource(): Stream = + Stream.of( + Arguments.of("fra", "fr"), + Arguments.of("fra-be", "fr-BE"), + Arguments.of("JA", "ja"), + Arguments.of("en-us", "en-US"), + ) + + @Test + fun `given comicInfo with blank values when getting series metadata then blank values are omitted`() { + val comicInfo = + ComicInfo().apply { + title = "" + storyArc = "" + genre = "" + languageISO = "" + publisher = "" + seriesGroup = "" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!! + + with(patch) { + assertThat(title).isNull() + assertThat(titleSort).isNull() + assertThat(genres).isNull() + assertThat(language).isNull() + assertThat(publisher).isNull() + assertThat(collections).isEmpty() + } + } + } + + companion object { + @JvmStatic + fun computeSeriesFromSeriesAndVolumeArguments(): Stream = + Stream.of( + Arguments.of("", null, null), + Arguments.of(null, null, null), + Arguments.of("Series", null, "Series"), + Arguments.of("Series", 1, "Series"), + Arguments.of("Series", 10, "Series (10)"), + ) + } + + @ParameterizedTest + @MethodSource("computeSeriesFromSeriesAndVolumeArguments") + fun `given series and volume when computing series name then it is correct`( + series: String?, + volume: Int?, + expected: String?, + ) { + assertThat(computeSeriesFromSeriesAndVolume(series, volume)).isEqualTo(expected) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt new file mode 100644 index 000000000..ee71d6b18 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt @@ -0,0 +1,175 @@ +package org.gotson.komga.infrastructure.metadata.comicrack + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.ComicRackListException +import org.gotson.komga.infrastructure.metadata.comicrack.dto.Book +import org.gotson.komga.infrastructure.metadata.comicrack.dto.ReadingList +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class ReadListProviderTest { + private val mockMapper = mockk() + private val readListProvider = ReadListProvider(mockMapper) + + @Nested + inner class ImportFromCbl { + @Test + fun `given CBL list with books when getting ReadListRequest then it is valid`() { + // given + val cbl = + ReadingList().apply { + name = "my read list" + books = + listOf( + Book().apply { + series = "series 1" + number = " 4 " + volume = 2005 + }, + Book().apply { + series = "series 2" + number = "1" + }, + ) + } + + every { mockMapper.readValue(any(), ReadingList::class.java) } returns cbl + + // when + val request = readListProvider.importFromCbl(ByteArray(0)) + + // then + with(request) { + assertThat(name).isEqualTo(cbl.name) + assertThat(books).hasSize(2) + + with(books[0]) { + assertThat(series).containsExactlyInAnyOrder("series 1 (2005)", "series 1") + assertThat(number).isEqualTo("4") + } + + with(books[1]) { + assertThat(series).containsExactlyInAnyOrder("series 2") + assertThat(number).isEqualTo("1") + } + } + } + + @Test + fun `given CBL list with invalid books when getting ReadListRequest then exception is thrown`() { + // given + val cbl = + ReadingList().apply { + name = "my read list" + books = + listOf( + Book().apply { + series = " " + number = "4" + volume = 2005 + }, + Book().apply { + series = null + number = "1" + }, + Book().apply { + series = "Series" + number = null + }, + ) + } + + every { mockMapper.readValue(any(), ReadingList::class.java) } returns cbl + + // when + val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) } + + // then + assertThat(thrown).isInstanceOf(ComicRackListException::class.java) + assertThat(thrown).hasFieldOrPropertyWithValue("code", "ERR_1031") + } + + @Test + fun `given CBL list without books when getting ReadListRequest then exception is thrown`() { + // given + val cbl = + ReadingList().apply { + name = "my read list" + books = emptyList() + } + + every { mockMapper.readValue(any(), ReadingList::class.java) } returns cbl + + // when + val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) } + + // then + assertThat(thrown).isInstanceOf(ComicRackListException::class.java) + assertThat(thrown).hasFieldOrPropertyWithValue("code", "ERR_1029") + } + + @Test + fun `given CBL list without name when getting ReadListRequest then exception is thrown`() { + // given + val cbl = + ReadingList().apply { + name = null + books = + listOf( + Book().apply { + series = "series 1" + number = "4" + volume = 2005 + }, + Book().apply { + series = "series 2" + number = "1" + }, + ) + } + + every { mockMapper.readValue(any(), ReadingList::class.java) } returns cbl + + // when + val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) } + + // then + assertThat(thrown).isInstanceOf(ComicRackListException::class.java) + assertThat(thrown).hasFieldOrPropertyWithValue("code", "ERR_1030") + } + + @Test + fun `given CBL list with blank name when getting ReadListRequest then exception is thrown`() { + // given + val cbl = + ReadingList().apply { + name = " " + books = + listOf( + Book().apply { + series = "series 1" + number = "4" + volume = 2005 + }, + Book().apply { + series = "series 2" + number = "1" + }, + ) + } + + every { mockMapper.readValue(any(), ReadingList::class.java) } returns cbl + + // when + val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) } + + // then + assertThat(thrown).isInstanceOf(ComicRackListException::class.java) + assertThat(thrown).hasFieldOrPropertyWithValue("code", "ERR_1030") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfoTest.kt new file mode 100644 index 000000000..3c0c20074 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ComicInfoTest.kt @@ -0,0 +1,188 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ComicInfoTest { + private val mapper = XmlMapper() + + @Test + fun `given valid xml file when deserializing then properties are available`() { + // language=XML + val xml = + """ + + + v01 - Preludes & Nocturnes - 30th Anniversary Edition + Sandman + https://www.comixology.com/Sandman/digital-comic/727888 + Neil Gaiman's seminal series, THE SANDMAN, celebrates its 30th anniversary with an all-new edition of THE + SANDMAN VOL. 1: PRELUDES & NOCTURNES! + +  New York Times best-selling author Neil Gaiman's transcendent series THE SANDMAN is often hailed as the + definitive Vertigo title and one of the finest achievements in graphic storytelling. Gaiman created an + unforgettable tale of the forces that exist beyond life and death by weaving ancient mythology, folklore and + fairy tales with his own distinct narrative vision. + +  In PRELUDES & NOCTURNES, an occultist attempting to capture Death to bargain for eternal life traps her + younger brother Dream instead. After his 70 year imprisonment and eventual escape, Dream, also known as + Morpheus, goes on a quest for his lost objects of power. On his arduous journey Morpheus encounters Lucifer, + John Constantine, and an all-powerful madman. + +  This book also includes the story "The Sound of Her Wings," which introduces us to the pragmatic and perky goth + girl Death. + +  Collects THE SANDMAN #1-8. + Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30] + The translator + DC + Vertigo + 10 + Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama + dark, Occult + 237 + en + Mature 17+ + No + No + Sandman + ABC123 + + + """.trimIndent() + val comicInfo = mapper.readValue(xml) + + with(comicInfo) { + assertThat(title).isEqualTo("v01 - Preludes & Nocturnes - 30th Anniversary Edition") + assertThat(series).isEqualTo("Sandman") + assertThat(web).isEqualTo("https://www.comixology.com/Sandman/digital-comic/727888") + assertThat(summary).startsWith("Neil Gaiman's seminal series") + assertThat(notes).isEqualTo("Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30]") + assertThat(publisher).isEqualTo("DC") + assertThat(imprint).isEqualTo("Vertigo") + assertThat(count).isEqualTo(10) + assertThat(genre).isEqualTo("Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama") + assertThat(tags).isEqualTo("dark, Occult") + assertThat(pageCount).isEqualTo(237) + assertThat(languageISO).isEqualTo("en") + assertThat(scanInformation).isEqualTo("") + assertThat(ageRating).isEqualTo(AgeRating.MATURE_17) + assertThat(blackAndWhite).isEqualTo(YesNo.NO) + assertThat(manga).isEqualTo(Manga.NO) + assertThat(seriesGroup).isEqualTo("Sandman") + assertThat(translator).isEqualTo("The translator") + assertThat(gtin).isEqualTo("ABC123") + } + } + + @Test + fun `given another valid xml file when deserializing then properties are available`() { + // language=XML + val xml = + """ + + + v01 - Preludes & Nocturnes - 30th Anniversary Edition + Sandman + https://www.comixology.com/Sandman/digital-comic/727888 + Neil Gaiman's seminal series, THE SANDMAN, celebrates its 30th anniversary with an all-new edition of THE + SANDMAN VOL. 1: PRELUDES & NOCTURNES! + +  New York Times best-selling author Neil Gaiman's transcendent series THE SANDMAN is often hailed as the + definitive Vertigo title and one of the finest achievements in graphic storytelling. Gaiman created an + unforgettable tale of the forces that exist beyond life and death by weaving ancient mythology, folklore and + fairy tales with his own distinct narrative vision. + +  In PRELUDES & NOCTURNES, an occultist attempting to capture Death to bargain for eternal life traps her + younger brother Dream instead. After his 70 year imprisonment and eventual escape, Dream, also known as + Morpheus, goes on a quest for his lost objects of power. On his arduous journey Morpheus encounters Lucifer, + John Constantine, and an all-powerful madman. + +  This book also includes the story "The Sound of Her Wings," which introduces us to the pragmatic and perky goth + girl Death. + +  Collects THE SANDMAN #1-8. + + Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30] + DC + Vertigo + Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama + 237 + en + MA15+ + No + 2018 + 10 + 30 + No + Sandman + + + """.trimIndent() + val comicInfo = mapper.readValue(xml) + + with(comicInfo) { + assertThat(title).isEqualTo("v01 - Preludes & Nocturnes - 30th Anniversary Edition") + assertThat(series).isEqualTo("Sandman") + assertThat(web).isEqualTo("https://www.comixology.com/Sandman/digital-comic/727888") + assertThat(summary).startsWith("Neil Gaiman's seminal series") + assertThat(notes).isEqualTo("Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30]") + assertThat(publisher).isEqualTo("DC") + assertThat(imprint).isEqualTo("Vertigo") + assertThat(count).isNull() + assertThat(genre).isEqualTo("Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama") + assertThat(pageCount).isEqualTo(237) + assertThat(languageISO).isEqualTo("en") + assertThat(scanInformation).isEqualTo("") + assertThat(ageRating).isEqualTo(AgeRating.MA_15) + assertThat(blackAndWhite).isEqualTo(YesNo.NO) + assertThat(manga).isEqualTo(Manga.NO) + assertThat(seriesGroup).isEqualTo("Sandman") + assertThat(year).isEqualTo(2018) + assertThat(month).isEqualTo(10) + assertThat(day).isEqualTo(30) + } + } + + @Test + fun `given incorrect enum values when deserializing then it is ignored`() { + // language=XML + val xml = + """ + + + Non existent + Non existent + Non existent + + """.trimIndent() + val comicInfo = mapper.readValue(xml) + + with(comicInfo) { + assertThat(ageRating).isNull() + assertThat(blackAndWhite).isNull() + assertThat(manga).isNull() + } + } + + @Test + fun `given valid xml file with StoryArc fields when deserializing then properties are available`() { + // language=XML + val xml = + """ + + + Arc + 2 + + """.trimIndent() + val comicInfo = mapper.readValue(xml) + + with(comicInfo) { + assertThat(storyArc).isEqualTo("Arc") + assertThat(storyArcNumber).isEqualTo("2") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt new file mode 100644 index 000000000..7e9b89cec --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt @@ -0,0 +1,94 @@ +package org.gotson.komga.infrastructure.metadata.comicrack.dto + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ReadingListTest { + private val mapper = XmlMapper() + + @Test + fun `given valid xml file when deserializing then properties are available`() { + // language=XML + val cbl = + """ + + + Civil War + + + 1b21c8c4-e8d7-44f6-992d-97d24ce8123e + Civil War Vol.2006 #01 (July, 2006) + + + 29a69cbf-af64-471d-889c-8fc0f0080f7c + Wolverine Vol.2003 #42 (July, 2006) + + + ec70e585-5a80-428c-a67e-fd22b668449b + X-Factor Vol.2006 #08 (August, 2006) + + + + + """.trimIndent() + val readingList = mapper.readValue(cbl) + + with(readingList) { + assertThat(name).isEqualTo("Civil War") + assertThat(books).hasSize(3) + + with(books[0]) { + assertThat(series).isEqualTo("Civil War") + assertThat(number).isEqualTo("1") + assertThat(volume).isEqualTo(2006) + assertThat(year).isEqualTo(2006) + assertThat(fileName).isEqualTo("Civil War Vol.2006 #01 (July, 2006)") + } + + with(books[1]) { + assertThat(series).isEqualTo("Wolverine") + assertThat(number).isEqualTo("42") + assertThat(volume).isEqualTo(2003) + assertThat(year).isEqualTo(2006) + assertThat(fileName).isEqualTo("Wolverine Vol.2003 #42 (July, 2006)") + } + + with(books[2]) { + assertThat(series).isEqualTo("X-Factor") + assertThat(number).isEqualTo("HS") + assertThat(volume).isEqualTo(2006) + assertThat(year).isEqualTo(2006) + assertThat(fileName).isEqualTo("X-Factor Vol.2006 #08 (August, 2006)") + } + } + } + + @Test + fun `given valid xml file for smart list when deserializing then properties are available`() { + // language=XML + val cbl = + """ + + + Golden Age + + + + Marvel Masterworks + + + Golden Age + + + + """.trimIndent() + val readingList = mapper.readValue(cbl) + + with(readingList) { + assertThat(name).isEqualTo("Golden Age") + assertThat(books).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProviderTest.kt new file mode 100644 index 000000000..59285c754 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProviderTest.kt @@ -0,0 +1,199 @@ +package org.gotson.komga.infrastructure.metadata.epub + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.apache.commons.validator.routines.ISBNValidator +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFileContent +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.core.io.ClassPathResource +import java.time.LocalDate + +class EpubMetadataProviderTest { + private val isbnValidator = ISBNValidator(true) + private val epubMetadataProvider = EpubMetadataProvider(isbnValidator) + + private val epubMetadataProviderProper = EpubMetadataProvider(ISBNValidator(true)) + + private val book = makeBook("book") + private val media = + Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + ) + + @AfterEach + fun cleanup() { + unmockkStatic(::getPackageFileContent) + } + + @Nested + inner class Book { + @ParameterizedTest + @ValueSource( + strings = [ + "epub/Panik im Paradies.opf", + "epub/Panik im Paradies - namespace.opf", + ], + ) + fun `given epub 3 opf when getting book metadata then metadata patch is valid`(opfFile: String) { + val opf = ClassPathResource(opfFile) + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(title).isEqualTo("Panik im Paradies") + assertThat(summary).isEqualTo("Bereits im ersten Band \"Panik im Paradies\" machen die drei berühmten Detektive ihrem Namen alle Ehre. Eigentlich haben sie ja gerade Ferien. Doch dann treffen sie auf diesen schrulligen Kapitän Larsson, der sich einen kleinen Privatzoo mit exotischen Tieren hält. Als plötzlich alle Tiere an rätselhaften Infektionen erkranken und die Besucher ausbleiben, werden Justus, Peter und Bob neugierig. Schon bald merken sie, daß da jemand ein düsteres Geheimnis hütet...") + assertThat(releaseDate).isEqualTo(LocalDate.of(1999, 7, 31)) + assertThat(authors).containsExactlyInAnyOrder( + Author("Ulf Blanck", "writer"), + Author("The Editor", "editor"), + ) + assertThat(isbn).isEqualTo("9783440077894") + assertThat(number).isEqualTo("1.5") + assertThat(numberSort).isEqualTo(1.5f) + } + } + + @Test + fun `given another epub 3 opf when getting book metadata then metadata patch is valid`() { + val opf = ClassPathResource("epub/Die Drei 3.opf") + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(title).isEqualTo("Die Drei Fragezeichen-Kids, Bd.3, Invasion Der Fliegen") + assertThat(summary).isEqualTo("Hunderte von Fliegen tummeln sich auf dem Schrottplatz vor Tante Mathildas Haus. Das ist ein Alptraum! Findet diese \"Invasion der Fliegen\" tatsächlich statt oder leidet Tante Mathilda etwa an Halluzinationen? Justus, Peter und Bob nehmen sich der Sache an und kommen schnell dahinter, daß es sich hier nicht um eine Einbildung von Tante Mathilda handelt. Sie verfolgen die Spur der Fliegen bis in einen düsteren Kanalschacht...") + assertThat(releaseDate).isEqualTo(LocalDate.of(1999, 7, 31)) + assertThat(authors).containsExactlyInAnyOrder( + Author("Ulf Blanck", "writer"), + Author("Stefanie Wegner", "writer"), + ) + assertThat(isbn).isEqualTo("9783440077931") + assertThat(number).isEqualTo("3") + assertThat(numberSort).isEqualTo(3f) + } + } + + @Test + fun `given real epub 3 when getting book metadata then metadata patch is valid`() { + val epubResource = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub") + val epubBook = + BookWithMedia( + makeBook("Epub", url = epubResource.url), + media, + ) + + val patch = epubMetadataProviderProper.getBookMetadataFromBook(epubBook) + + with(patch!!) { + assertThat(title).isEqualTo("The Incomplete Theft") + assertThat(summary).isNull() + assertThat(releaseDate).isEqualTo(LocalDate.of(2021, 6, 20)) + assertThat(authors).containsExactlyInAnyOrder(Author("Ralph Burke", "writer")) + assertThat(isbn).isNull() + assertThat(number).isNull() + assertThat(numberSort).isNull() + } + } + + @Test + fun `given epub 2 opf when getting book metadata then metadata patch is valid`() { + val opf = ClassPathResource("epub/1979.opf") + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(title).isEqualTo("1979") + assertThat(summary).isEqualTo("Auf dem Weg nach Teheran sah ich aus dem Autofenster, mir wurde etwas übel und ich hielt mich an Christophers Knie fest. Sein Hosenbein war von den aufgeplatzten Blasen ganz naß. Wir fuhren an endlosen Reihen von Birken vorbei. Ich schlief.Später hielten wir an, um uns zu erfrischen. Ich trank ein Glas Tee, Christopher eine Limonade. Es wurde sehr rasch Nacht.Es gab einige Militärkontrollen, denn seit September herrschte Kriegsrecht, was ja eigentlich nichts zu bedeuten hatte in diesen Ländern, sagte Christopher. Wir wurden weitergewunken, einmal sah ich einen Arm, eine weiße Bandage darum und eine Taschenlampe, die uns ins Gesicht schien, dann ging es weiter.Die Luft war staubig, ab und zu roch es im Wagen nach Mais. Wir hatten nur zwei Kassetten dabei; wir hörten erst Blondie, dann Devo, dann wieder Blondie. Es waren Christophers Kassetten.CHRISTIAN KRACHT, 1966 geboren, ist Schweizer. Das Foto zeigt den Autor auf der Landstraße nach Tashigang, Tibet, Volksrepublik China.GESTALTUNG: PAUL BARNES UND PETER SAVILLEFOTO: ECKHART NICKEL") + assertThat(releaseDate).isEqualTo(LocalDate.of(101, 1, 1)) + assertThat(authors).containsExactlyInAnyOrder( + Author("Kracht, Christian", "writer"), + Author("The Editor", "editor"), + ) + assertThat(isbn).isNull() + assertThat(number).isNull() + assertThat(numberSort).isNull() + } + } + } + + @Nested + inner class Series { + @ParameterizedTest + @ValueSource( + strings = [ + "epub/Panik im Paradies.opf", + "epub/Panik im Paradies - namespace.opf", + ], + ) + fun `given epub 3 opf when getting series metadata then metadata patch is valid`(opfFile: String) { + val opf = ClassPathResource(opfFile) + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true) + + with(patch!!) { + assertThat(title).isEqualTo("Die drei ??? Kids") + assertThat(titleSort).isEqualTo("Die drei ??? Kids") + assertThat(readingDirection).isEqualTo(SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT) + assertThat(publisher).isEqualTo("Kosmos") + assertThat(language).isEqualTo("de") + assertThat(genres).containsExactlyInAnyOrder("Kinder- und Jugendbücher") + } + } + + @Test + fun `given another epub 3 opf when getting series metadata then metadata patch is valid`() { + val opf = ClassPathResource("epub/Die Drei 3.opf") + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true) + + with(patch!!) { + assertThat(title).isEqualTo("Die drei ??? Kids") + assertThat(titleSort).isEqualTo("Die drei ??? Kids") + assertThat(readingDirection).isNull() + assertThat(publisher).isEqualTo("Franckh-Kosmos Verlag") + assertThat(language).isEqualTo("de") + assertThat(genres).containsExactlyInAnyOrder("Kinder- und Jugendbücher", "Juvenile Fiction", "Mysteries & Detective Stories") + } + } + + @Test + fun `given epub 2 opf when getting series metadata then metadata patch is valid`() { + val opf = ClassPathResource("epub/1979.opf") + mockkStatic(::getPackageFileContent) + every { getPackageFileContent(any()) } returns opf.file.readText() + + val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true) + + with(patch!!) { + assertThat(title).isNull() + assertThat(titleSort).isNull() + assertThat(readingDirection).isNull() + assertThat(publisher).isNull() + assertThat(language).isEqualTo("de") + assertThat(genres).isNull() + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProviderTest.kt new file mode 100644 index 000000000..f6a36ff55 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProviderTest.kt @@ -0,0 +1,107 @@ +package org.gotson.komga.infrastructure.metadata.localartwork + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import io.mockk.every +import io.mockk.spyk +import org.apache.commons.io.FilenameUtils +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Series +import org.gotson.komga.infrastructure.image.ImageAnalyzer +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.mediacontainer.TikaConfiguration +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.time.LocalDateTime +import kotlin.io.path.extension + +class LocalArtworkProviderTest { + private val contentDetector = + spyk(ContentDetector(TikaConfiguration().tika())).also { + every { it.detectMediaType(any()) } answers { + when (firstArg().extension.lowercase()) { + "jpg", "jpeg", "tbn" -> "image/jpeg" + "png" -> "image/png" + "webp" -> "image/webp" + "avif" -> "image/avif" + "jxl" -> "image/jxl" + else -> "application/octet-stream" + } + } + } + + private val localMediaAssetsProvider = LocalArtworkProvider(contentDetector, ImageAnalyzer()) + + @Test + fun `given book with sidecar files when getting thumbnails then return valid ones`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val bookFile = Files.createFile(root.resolve("book(e).cbz")) + val thumbsFiles = listOf("bOOk(e).jpeg", "Book(e).tbn", "book(e).PNG", "book(e).jpeg", "book(e).webp") + val thumbsDashFiles = listOf("book(e)-1.jpeg", "book(e)-2.tbn", "book(e)-23.png", "book(e)-111.jpeg", "book(e)-123.webp") + val invalidFiles = listOf("book12(e).jpeg", "book(e).gif", "cover.png", "other.jpeg", "book.webp", "book(e).avif", "book(e).jxl") + + (thumbsFiles + thumbsDashFiles + invalidFiles).forEach { Files.createFile(root.resolve(it)) } + + val book = + spyk( + Book( + name = "Book", + url = bookFile.toUri().toURL(), + fileLastModified = LocalDateTime.now(), + ), + ) + every { book.path } returns bookFile + + // when + val thumbnails = localMediaAssetsProvider.getBookThumbnails(book) + + // then + assertThat(thumbnails).hasSize(thumbsFiles.size + thumbsDashFiles.size) + assertThat(thumbnails.filter { it.selected }).hasSize(1) + assertThat(thumbnails.map { FilenameUtils.getName(it.url.toString()) }) + .containsAll(thumbsFiles) + .containsAll(thumbsDashFiles) + .doesNotContainAnyElementsOf(invalidFiles) + } + } + + @Test + fun `given series with sidecar files when getting thumbnails then return valid ones`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val seriesPath = fs.getPath("/series") + val seriesFile = Files.createDirectory(seriesPath) + + val thumbsFiles = listOf("CoVeR.jpeg", "DefauLt.tbn", "POSter.PNG", "FoLDer.jpeg", "serIES.TBN", "serIes.WebP") + val invalidFiles = listOf("cover.gif", "artwork.jpg", "other.jpeg", "cover.avif", "series.jxl") + + (thumbsFiles + invalidFiles).forEach { Files.createFile(seriesPath.resolve(it)) } + + val series = + spyk( + Series( + name = "Series", + url = seriesFile.toUri().toURL(), + fileLastModified = LocalDateTime.now(), + ), + ) + every { series.path } returns seriesFile + + // when + val thumbnails = localMediaAssetsProvider.getSeriesThumbnails(series) + + // then + assertThat(thumbnails).hasSize(thumbsFiles.size) + assertThat(thumbnails.filter { it.selected }).hasSize(1) + assertThat(thumbnails.map { FilenameUtils.getName(it.url.toString()) }) + .containsAll(thumbsFiles) + .doesNotContainAnyElementsOf(invalidFiles) + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProviderTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProviderTest.kt new file mode 100644 index 000000000..38724f95d --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/MylarSeriesProviderTest.kt @@ -0,0 +1,212 @@ +package org.gotson.komga.infrastructure.metadata.mylar + +import com.fasterxml.jackson.databind.ObjectMapper +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.infrastructure.metadata.mylar.dto.AgeRating +import org.gotson.komga.infrastructure.metadata.mylar.dto.MylarMetadata +import org.gotson.komga.infrastructure.metadata.mylar.dto.Series +import org.gotson.komga.infrastructure.metadata.mylar.dto.Status +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +class MylarSeriesProviderTest { + private val mockMapper = mockk() + + private val mylarSeriesProvider = MylarSeriesProvider(mockMapper) + + private lateinit var series: org.gotson.komga.domain.model.Series + + @BeforeAll + fun setupSeries( + @TempDir dir: Path, + ) { + Files.createFile(dir.resolve("series.json")) + series = makeSeries("series", url = dir.toUri().toURL()) + } + + @Test + fun `given seriesJson when getting series metadata then metadata patch is valid`() { + val metadata = + MylarMetadata( + type = "comicSeries", + publisher = "DC", + imprint = "Vertigo", + name = "Sàndman", + comicid = "12345", + year = 1990, + descriptionText = "Sandman comics", + descriptionFormatted = "Sandman comics formatted", + volume = null, + bookType = "TPB", + ageRating = AgeRating.ADULT, + comicImage = "unused", + totalIssues = 2, + publicationRun = "unused", + status = Status.Ended, + ) + val root = Series(metadata) + + every { mockMapper.readValue(any(), Series::class.java) } returns root + + val patch = mylarSeriesProvider.getSeriesMetadata(series)!! + + with(patch) { + assertThat(title).isEqualTo("Sàndman") + assertThat(titleSort).isEqualTo("Sàndman") + assertThat(status).isEqualTo(SeriesMetadata.Status.ENDED) + assertThat(summary).isEqualTo("Sandman comics formatted") + assertThat(readingDirection).isNull() + assertThat(publisher).isEqualTo("DC") + assertThat(ageRating).isEqualTo(18) + assertThat(language).isNull() + assertThat(genres).isNull() + assertThat(totalBookCount).isEqualTo(2) + assertThat(collections).isEmpty() + } + } + + @Test + fun `given another seriesJson when getting series metadata then metadata patch is valid`() { + val metadata = + MylarMetadata( + type = "comicSeries", + publisher = "DC", + imprint = "Vertigo", + name = "Sandman", + comicid = "12345", + year = 1990, + descriptionText = "Sandman comics", + descriptionFormatted = null, + volume = null, + bookType = "TPB", + ageRating = null, + comicImage = "unused", + totalIssues = 2, + publicationRun = "unused", + status = Status.Continuing, + ) + val root = Series(metadata) + + every { mockMapper.readValue(any(), Series::class.java) } returns root + + val patch = mylarSeriesProvider.getSeriesMetadata(series)!! + + with(patch) { + assertThat(title).isEqualTo("Sandman") + assertThat(titleSort).isEqualTo("Sandman") + assertThat(status).isEqualTo(SeriesMetadata.Status.ONGOING) + assertThat(summary).isEqualTo("Sandman comics") + assertThat(readingDirection).isNull() + assertThat(publisher).isEqualTo("DC") + assertThat(ageRating).isNull() + assertThat(language).isNull() + assertThat(genres).isNull() + assertThat(totalBookCount).isEqualTo(2) + assertThat(collections).isEmpty() + } + } + + @Test + fun `given seriesJson with volume != 1 and year when getting series metadata then metadata patch has title containing the year`() { + val metadata = + MylarMetadata( + type = "comicSeries", + publisher = "DC", + imprint = "Vertigo", + name = "Sandman", + comicid = "12345", + year = 1990, + descriptionText = "Sandman comics", + descriptionFormatted = "Sandman comics formatted", + volume = 2, + bookType = "TPB", + ageRating = AgeRating.ADULT, + comicImage = "unused", + totalIssues = 2, + publicationRun = "unused", + status = Status.Ended, + ) + val root = Series(metadata) + + every { mockMapper.readValue(any(), Series::class.java) } returns root + + val patch = mylarSeriesProvider.getSeriesMetadata(series)!! + + with(patch) { + assertThat(title).isEqualTo("Sandman (1990)") + assertThat(titleSort).isEqualTo("Sandman (1990)") + } + } + + @Test + fun `given seriesJson with volume == 1 and year when getting series metadata then metadata patch has title not containing the year`() { + val metadata = + MylarMetadata( + type = "comicSeries", + publisher = "DC", + imprint = "Vertigo", + name = "Sandman", + comicid = "12345", + year = 1990, + descriptionText = "Sandman comics", + descriptionFormatted = "Sandman comics formatted", + volume = 1, + bookType = "TPB", + ageRating = AgeRating.ADULT, + comicImage = "unused", + totalIssues = 2, + publicationRun = "unused", + status = Status.Ended, + ) + val root = Series(metadata) + + every { mockMapper.readValue(any(), Series::class.java) } returns root + + val patch = mylarSeriesProvider.getSeriesMetadata(series)!! + + with(patch) { + assertThat(title).isEqualTo("Sandman") + assertThat(titleSort).isEqualTo("Sandman") + } + } + + @Test + fun `given seriesJson with volume == null and year when getting series metadata then metadata patch has title not containing the year`() { + val metadata = + MylarMetadata( + type = "comicSeries", + publisher = "DC", + imprint = "Vertigo", + name = "Sandman", + comicid = "12345", + year = 1990, + descriptionText = "Sandman comics", + descriptionFormatted = "Sandman comics formatted", + volume = null, + bookType = "TPB", + ageRating = AgeRating.ADULT, + comicImage = "unused", + totalIssues = 2, + publicationRun = "unused", + status = Status.Ended, + ) + val root = Series(metadata) + + every { mockMapper.readValue(any(), Series::class.java) } returns root + + val patch = mylarSeriesProvider.getSeriesMetadata(series)!! + + with(patch) { + assertThat(title).isEqualTo("Sandman") + assertThat(titleSort).isEqualTo("Sandman") + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/SeriesTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/SeriesTest.kt new file mode 100644 index 000000000..89f7e27aa --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/mylar/dto/SeriesTest.kt @@ -0,0 +1,256 @@ +package org.gotson.komga.infrastructure.metadata.mylar.dto + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.module.kotlin.readValue +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class SeriesTest( + @Autowired private val mapper: ObjectMapper, +) { + @Test + fun `given valid json file when deserializing then properties are available`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "publisher": "DC Comics", + "imprint": null, + "name": "American Vampire 1976", + "cid": 130865, + "year": 2020, + "description_text": "Nine issue mini-series, the closing chapter of American Vampire", + "description_formatted": "Nine issue mini-series, the closing chapter of American Vampire", + "volume": null, + "booktype": "Print", + "age_rating": "Adult", + "collects": null, + "ComicImage": "https://comicvine.gamespot.com/a/uploads/scale_large/6/67663/7603293-01.jpg", + "total_issues": 9, + "publication_run": "December 2020 - Present", + "status": "Continuing" + } + } + """.trimIndent() + val seriesJson = mapper.readValue(json) + + assertThat(seriesJson.metadata).isNotNull + with(seriesJson.metadata) { + assertThat(type).isEqualTo("comicSeries") + assertThat(publisher).isEqualTo("DC Comics") + assertThat(imprint).isNull() + assertThat(name).isEqualTo("American Vampire 1976") + assertThat(comicid).isEqualTo("130865") + assertThat(year).isEqualTo(2020) + assertThat(descriptionText).isEqualTo("Nine issue mini-series, the closing chapter of American Vampire") + assertThat(descriptionFormatted).isEqualTo("Nine issue mini-series, the closing chapter of American Vampire") + assertThat(volume).isNull() + assertThat(bookType).isEqualTo("Print") + assertThat(ageRating).isEqualTo(AgeRating.ADULT) + assertThat(comicImage).isEqualTo("https://comicvine.gamespot.com/a/uploads/scale_large/6/67663/7603293-01.jpg") + assertThat(totalIssues).isEqualTo(9) + assertThat(publicationRun).isEqualTo("December 2020 - Present") + assertThat(status).isEqualTo(Status.Continuing) + } + } + + @Test + fun `given another valid json file when deserializing then properties are available`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "publisher": "IDW Publishing", + "imprint": null, + "name": "Usagi Yojimbo", + "comicid": 119731, + "year": 2019, + "description_text": null, + "description_formatted": null, + "volume": 4, + "booktype": "Print", + "age_rating": null, + "collects": null, + "comic_image": "https://comicvine1.cbsistatic.com/uploads/scale_large/6/67663/6974029-01a.jpg", + "total_issues": 20, + "publication_run": "June 2019 - Present", + "status": "Ended" + } + } + """.trimIndent() + val seriesJson = mapper.readValue(json) + + assertThat(seriesJson.metadata).isNotNull + with(seriesJson.metadata) { + assertThat(type).isEqualTo("comicSeries") + assertThat(publisher).isEqualTo("IDW Publishing") + assertThat(imprint).isNull() + assertThat(name).isEqualTo("Usagi Yojimbo") + assertThat(comicid).isEqualTo("119731") + assertThat(year).isEqualTo(2019) + assertThat(descriptionText).isNull() + assertThat(descriptionFormatted).isNull() + assertThat(volume).isEqualTo(4) + assertThat(bookType).isEqualTo("Print") + assertThat(ageRating).isNull() + assertThat(comicImage).isEqualTo("https://comicvine1.cbsistatic.com/uploads/scale_large/6/67663/6974029-01a.jpg") + assertThat(totalIssues).isEqualTo(20) + assertThat(publicationRun).isEqualTo("June 2019 - Present") + assertThat(status).isEqualTo(Status.Ended) + } + } + + @Test + fun `given yet another valid json file when deserializing then properties are available`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "publisher": "Kodansha Comics USA", + "imprint": null, + "name": "Vinland Saga", + "comicid": 69157, + "year": 2013, + "description_text": "English translation of Vinland Saga (ヴィンランド・サガ).Vinland Saga is the first series from Kodansha Comics USA to be released in hardcovers, each collection collects and translates two volumes of the original Japanese manga", + "description_formatted": "English translation of Vinland Saga (ヴィンランド・サガ).Vinland Saga is the first series from Kodansha Comics USA to be released in hardcovers, each collection collects and translates two volumes of the original Japanese manga", + "volume": null, + "booktype": "Print", + "age_rating": null, + "collects": null, + "ComicImage": "https://comicvine.gamespot.com/a/uploads/scale_large/6/67663/3439178-01.jpg", + "total_issues": 12, + "publication_run": "November 2013 - December 2021", + "status": "Ended" + } + } + """.trimIndent() + val seriesJson = mapper.readValue(json) + + assertThat(seriesJson.metadata).isNotNull + with(seriesJson.metadata) { + assertThat(type).isEqualTo("comicSeries") + assertThat(publisher).isEqualTo("Kodansha Comics USA") + assertThat(imprint).isNull() + assertThat(name).isEqualTo("Vinland Saga") + assertThat(comicid).isEqualTo("69157") + assertThat(year).isEqualTo(2013) + assertThat(descriptionText).isEqualTo("English translation of Vinland Saga (ヴィンランド・サガ).Vinland Saga is the first series from Kodansha Comics USA to be released in hardcovers, each collection collects and translates two volumes of the original Japanese manga") + assertThat(descriptionFormatted).isEqualTo("English translation of Vinland Saga (ヴィンランド・サガ).Vinland Saga is the first series from Kodansha Comics USA to be released in hardcovers, each collection collects and translates two volumes of the original Japanese manga") + assertThat(volume).isNull() + assertThat(bookType).isEqualTo("Print") + assertThat(ageRating).isNull() + assertThat(comicImage).isEqualTo("https://comicvine.gamespot.com/a/uploads/scale_large/6/67663/3439178-01.jpg") + assertThat(totalIssues).isEqualTo(12) + assertThat(publicationRun).isEqualTo("November 2013 - December 2021") + assertThat(status).isEqualTo(Status.Ended) + } + } + + @Test + fun `given invalid json file missing year when deserializing then it fails`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "publisher": "IDW Publishing", + "imprint": null, + "name": "Usagi Yojimbo", + "comicid": 119731, + "description_text": null, + "description_formatted": null, + "volume": 4, + "booktype": "Print", + "age_rating": null, + "collects": null, + "ComicImage": "https://comicvine1.cbsistatic.com/uploads/scale_large/6/67663/6974029-01a.jpg", + "total_issues": 20, + "publication_run": "June 2019 - Present", + "status": "Ended" + } + } + """.trimIndent() + val thrown = catchThrowable { mapper.readValue(json) } + + assertThat(thrown).isInstanceOf(MismatchedInputException::class.java) + } + + @Test + fun `given invalid json file missing publisher when deserializing then it fails`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "imprint": null, + "name": "Usagi Yojimbo", + "comicid": 119731, + "year": 2019, + "description_text": null, + "description_formatted": null, + "volume": 4, + "booktype": "Print", + "age_rating": null, + "collects": null, + "ComicImage": "https://comicvine1.cbsistatic.com/uploads/scale_large/6/67663/6974029-01a.jpg", + "total_issues": 20, + "publication_run": "June 2019 - Present", + "status": "Ended" + } + } + """.trimIndent() + val thrown = catchThrowable { mapper.readValue(json) } + + assertThat(thrown).isInstanceOf(MismatchedInputException::class.java) + } + + @Test + fun `given invalid json file missing status when deserializing then it fails`() { + // language=JSON + val json = + """ + { + "version": "1.0.1", + "metadata": { + "type": "comicSeries", + "publisher": "IDW Publishing", + "imprint": null, + "name": "Usagi Yojimbo", + "comicid": 119731, + "year": 2019, + "description_text": null, + "description_formatted": null, + "volume": 4, + "booktype": "Print", + "age_rating": null, + "collects": null, + "ComicImage": "https://comicvine1.cbsistatic.com/uploads/scale_large/6/67663/6974029-01a.jpg", + "total_issues": 20, + "publication_run": "June 2019 - Present" + } + } + + """.trimIndent() + val thrown = catchThrowable { mapper.readValue(json) } + + assertThat(thrown).isInstanceOf(MismatchedInputException::class.java) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualAnalyzerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualAnalyzerTest.kt new file mode 100644 index 000000000..8e7e98975 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualAnalyzerTest.kt @@ -0,0 +1,116 @@ +package org.gotson.komga.infrastructure.search + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class MultilingualAnalyzerTest { + private val analyzer = MultiLingualAnalyzer() + + @Test + fun `english text`() { + // given + val text = "The incredible adventures of Batman, the man who is also a bat!" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("the", "incredible", "adventures", "of", "batman", "the", "man", "who", "is", "also", "a", "bat") + } + + @Test + fun accents() { + // given + val text = "Éric èl rojo" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactlyInAnyOrder("eric", "el", "rojo") + } + + @Test + fun isbn() { + // given + val text = "9782413016878" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("9782413016878") + } + + @Test + fun `single letter`() { + // given + val text = "J" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("j") + } + + @Test + fun `chinese mixed`() { + // given + val text = "[不道德公會][河添太一 ][東立]Vol.04-搬运" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("不道", "道德", "德公", "公會", "河添", "添太", "太一", "東立", "vol", "04", "搬运") + } + + @Test + fun `chinese only`() { + // given + val text = "不道德公會河添太一東立搬运" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("不道", "道德", "德公", "公會", "會河", "河添", "添太", "太一", "一東", "東立", "立搬", "搬运") + } + + @Test + fun `hiragana only`() { + // given + val text = "探偵はもう、死んでいる。" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("探偵", "偵は", "はも", "もう", "死ん", "んで", "でい", "いる") + } + + @Test + fun `katakana only`() { + // given + val text = "ワンパンマン" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("ワン", "ンパ", "パン", "ンマ", "マン") + } + + @Test + fun `korean only`() { + // given + val text = "고교생을 환불해 주세요" + + // when + val tokens = analyzer.getTokens(text) + + // then + assertThat(tokens).containsExactly("고교", "교생", "생을", "환불", "불해", "주세", "세요") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualNGramAnalyzerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualNGramAnalyzerTest.kt new file mode 100644 index 000000000..e582e50cf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/MultilingualNGramAnalyzerTest.kt @@ -0,0 +1,80 @@ +package org.gotson.komga.infrastructure.search + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class MultilingualNGramAnalyzerTest { + @Test + fun `single letter`() { + // given + val text = "J" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, false).getTokens(text) + val tokensPreserveOriginal = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokensPreserveOriginal).containsExactly("j") + assertThat(tokens).isEmpty() + } + + @Test + fun `chinese mixed`() { + // given + val text = "[不道德公會][河添太一 ][東立]Vol.04-搬运" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokens).containsExactly("不道", "道德", "德公", "公會", "河添", "添太", "太一", "東立", "vol", "04", "搬运") + } + + @Test + fun `chinese only`() { + // given + val text = "不道德公會河添太一東立搬运" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokens).containsExactly("不道", "道德", "德公", "公會", "會河", "河添", "添太", "太一", "一東", "東立", "立搬", "搬运") + } + + @Test + fun `hiragana only`() { + // given + val text = "探偵はもう、死んでいる。" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokens).containsExactly("探偵", "偵は", "はも", "もう", "死ん", "んで", "でい", "いる") + } + + @Test + fun `katakana only`() { + // given + val text = "ワンパンマン" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokens).containsExactly("ワン", "ンパ", "パン", "ンマ", "マン") + } + + @Test + fun `korean only`() { + // given + val text = "고교생을 환불해 주세요" + + // when + val tokens = MultiLingualNGramAnalyzer(3, 8, true).getTokens(text) + + // then + assertThat(tokens).containsExactly("고교", "교생", "생을", "환불", "불해", "주세", "세요") + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycleTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycleTest.kt new file mode 100644 index 000000000..22ecbce36 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/SearchIndexLifecycleTest.kt @@ -0,0 +1,319 @@ +package org.gotson.komga.infrastructure.search + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import io.mockk.slot +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.ReadListLifecycle +import org.gotson.komga.domain.service.SeriesCollectionLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Pageable + +@SpringBootTest +class SearchIndexLifecycleTest( + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val collectionLifecycle: SeriesCollectionLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val searchIndexLifecycle: SearchIndexLifecycle, + @Autowired private val luceneHelper: LuceneHelper, +) { + private val library = makeLibrary() + + @MockkBean + private lateinit var mockEventPublisher: ApplicationEventPublisher + + @BeforeAll + fun setup() { + captureEvents() + libraryRepository.insert(library) + } + + @BeforeEach + fun resetMocks() { + captureEvents() + } + + @AfterEach + fun deleteEntities() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + collectionRepository.findAll(pageable = Pageable.unpaged()).forEach { + collectionLifecycle.deleteCollection(it) + } + readListRepository.findAll(pageable = Pageable.unpaged()).forEach { + readListLifecycle.deleteReadList(it) + } + } + + @AfterAll + fun tearDown() { + captureEvents() + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + private fun captureEvents() { + val eventSlot = slot() + every { mockEventPublisher.publishEvent(capture(eventSlot)) } answers { + searchIndexLifecycle.consumeEvents(eventSlot.captured) + } + } + + @Nested + inner class Book { + @Test + fun `given empty index when adding an entity then it is added to the index`() { + val series = seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + seriesLifecycle.addBooks(series, listOf(makeBook("book", seriesId = series.id, libraryId = library.id))) + + val found = luceneHelper.searchEntitiesIds("book", LuceneEntity.Book) + + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + @Test + fun `given an entity when updating then it is updated in the index`() { + val series = seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + val book = makeBook("book", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book)) + + luceneHelper.searchEntitiesIds("book", LuceneEntity.Book).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(title = "updated")) + } + mockEventPublisher.publishEvent(DomainEvent.BookUpdated(book)) + + luceneHelper.searchEntitiesIds("book", LuceneEntity.Book).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + luceneHelper.searchEntitiesIds("updated", LuceneEntity.Book).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + } + + @Test + fun `given an entity when deleting then it is removed from the index`() { + val series = seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + val book = makeBook("book", seriesId = series.id, libraryId = library.id) + seriesLifecycle.addBooks(series, listOf(book)) + + luceneHelper.searchEntitiesIds("book", LuceneEntity.Book).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + bookLifecycle.deleteOne(book) + + luceneHelper.searchEntitiesIds("book", LuceneEntity.Book).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + } + } + + @Nested + inner class Series { + @Test + fun `given empty index when adding an entity then it is added to the index`() { + seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + + val found = luceneHelper.searchEntitiesIds("series", LuceneEntity.Series) + + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + @Test + fun `given an entity when updating then it is updated in the index`() { + val series = seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + + luceneHelper.searchEntitiesIds("series", LuceneEntity.Series).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(title = "updated", titleSort = "updated")) + } + mockEventPublisher.publishEvent(DomainEvent.SeriesUpdated(series)) + + luceneHelper.searchEntitiesIds("series", LuceneEntity.Series).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + luceneHelper.searchEntitiesIds("updated", LuceneEntity.Series).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + } + + @Test + fun `given an entity when deleting then it is removed from the index`() { + val series = seriesLifecycle.createSeries(makeSeries("Series", libraryId = library.id)) + + luceneHelper.searchEntitiesIds("series", LuceneEntity.Series).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + seriesLifecycle.deleteMany(listOf(series)) + + luceneHelper.searchEntitiesIds("series", LuceneEntity.Series).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + } + } + + @Nested + inner class Collection { + @Test + fun `given empty index when adding an entity then it is added to the index`() { + val collection = SeriesCollection("collection") + collectionLifecycle.addCollection(collection) + + val found = luceneHelper.searchEntitiesIds("collection", LuceneEntity.Collection) + + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + @Test + fun `given an entity when updating then it is updated in the index`() { + val collection = SeriesCollection("collection") + collectionLifecycle.addCollection(collection) + + luceneHelper.searchEntitiesIds("collection", LuceneEntity.Collection).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + collectionRepository.findByIdOrNull(collection.id)?.let { + collectionRepository.update(it.copy(name = "updated")) + } + mockEventPublisher.publishEvent(DomainEvent.CollectionUpdated(collection)) + + luceneHelper.searchEntitiesIds("collection", LuceneEntity.Collection).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + luceneHelper.searchEntitiesIds("updated", LuceneEntity.Collection).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + } + + @Test + fun `given an entity when deleting then it is removed from the index`() { + val collection = SeriesCollection("collection") + collectionLifecycle.addCollection(collection) + + luceneHelper.searchEntitiesIds("collection", LuceneEntity.Collection).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + collectionLifecycle.deleteCollection(collection) + + luceneHelper.searchEntitiesIds("collection", LuceneEntity.Collection).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + } + } + + @Nested + inner class ReadList { + @Test + fun `given empty index when adding an entity then it is added to the index`() { + val readList = ReadList("readlist") + readListLifecycle.addReadList(readList) + + val found = luceneHelper.searchEntitiesIds("readlist", LuceneEntity.ReadList) + + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + @Test + fun `given an entity when updating then it is updated in the index`() { + val readList = ReadList("readlist") + readListLifecycle.addReadList(readList) + + luceneHelper.searchEntitiesIds("readlist", LuceneEntity.ReadList).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + readListRepository.findByIdOrNull(readList.id)?.let { + readListRepository.update(it.copy(name = "updated")) + } + mockEventPublisher.publishEvent(DomainEvent.ReadListUpdated(readList)) + + luceneHelper.searchEntitiesIds("readlist", LuceneEntity.ReadList).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + luceneHelper.searchEntitiesIds("updated", LuceneEntity.ReadList).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + } + + @Test + fun `given an entity when deleting then it is removed from the index`() { + val readList = ReadList("readlist") + readListLifecycle.addReadList(readList) + + luceneHelper.searchEntitiesIds("readlist", LuceneEntity.ReadList).let { found -> + assertThat(found).isNotNull + assertThat(found).hasSize(1) + } + + readListLifecycle.deleteReadList(readList) + + luceneHelper.searchEntitiesIds("readlist", LuceneEntity.ReadList).let { found -> + assertThat(found).isNotNull + assertThat(found).isEmpty() + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/Utils.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/Utils.kt new file mode 100644 index 000000000..3d282d1bd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/search/Utils.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.infrastructure.search + +import org.apache.lucene.analysis.Analyzer + +fun Analyzer.getTokens(text: String): List { + val tokenStream = tokenStream("text", text) + + val tokens = mutableListOf() + tokenStream.use { ts -> + ts.reset() + while (ts.incrementToken()) { + ts.reflectWith { _, key, value -> if (key == "term") tokens += value.toString() } + } + ts.end() + } + + return tokens +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/unicode/CollatorsTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/unicode/CollatorsTest.kt new file mode 100644 index 000000000..5bffd8656 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/unicode/CollatorsTest.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.infrastructure.unicode + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CollatorsTest { + @Test + fun collator1() { + assertThat(Collators.collator1.compare("café", "cafe")).isEqualTo(0) // accents ignored + assertThat(Collators.collator1.compare("CAFE", "cafe")).isEqualTo(0) // case ignored + assertThat(Collators.collator1.compare("안녕하세요", "안녕하세요")).isEqualTo(0) // hangul + assertThat(Collators.collator1.compare("あ", "ア")).isEqualTo(0) // katakana = hiragana + assertThat(Collators.collator1.compare("が", "か")).isEqualTo(0) // dakuten + } + + @Test + fun collator3() { + assertThat(Collators.collator3.compare("café", "cafe")).isNotEqualTo(0) // accents not ignored + assertThat(Collators.collator3.compare("CAFE", "cafe")).isNotEqualTo(0) // case not ignored + assertThat(Collators.collator3.compare("안녕하세요", "안녕하세요")).isEqualTo(0) // hangul + assertThat(Collators.collator3.compare("あ", "ア")).isNotEqualTo(0) // katakana != hiragana + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapperTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapperTest.kt new file mode 100644 index 000000000..de9d55af0 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/infrastructure/web/BracketParamsRequestWrapperTest.kt @@ -0,0 +1,258 @@ +package org.gotson.komga.infrastructure.web + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest + +class BracketParamsRequestWrapperTest { + @Nested + inner class ParameterNames { + @Test + fun `given parameters with and without brackets when getting parameter names then only the name without bracket is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param") + request.setParameter("param[]") + + // when + val filtered = BracketParamsRequestWrapper(request) + + // then + assertThat(filtered.parameterNames.toList()).containsExactlyInAnyOrder("param") + } + + @Test + fun `given parameters with brackets when getting parameter names then only the name without bracket is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]") + + // when + val filtered = BracketParamsRequestWrapper(request) + + // then + assertThat(filtered.parameterNames.toList()).containsExactlyInAnyOrder("param") + } + + @Test + fun `given parameters without brackets when getting parameter names then only the name without bracket is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param") + + // when + val filtered = BracketParamsRequestWrapper(request) + + // then + assertThat(filtered.parameterNames.toList()).containsExactlyInAnyOrder("param") + } + + @Test + fun `given empty parameters when getting parameter names then it is empty`() { + // given + val request = MockHttpServletRequest() + + // when + val filtered = BracketParamsRequestWrapper(request) + + // then + assertThat(filtered.parameterNames.toList()).isEmpty() + } + } + + @Nested + inner class ParameterValue { + @Test + fun `given parameters with and without brackets when getting parameter value then both values are joined to string`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param", "a") + request.setParameter("param[]", "b") + + // when + val filtered = BracketParamsRequestWrapper(request) + val value = filtered.getParameter("param") + val valueBracket = filtered.getParameter("param[]") + + // then + assertThat(value).isEqualTo(valueBracket) + assertThat(value).isEqualTo("a,b") + } + + @Test + fun `given parameters with brackets when getting parameter value single value is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]", "b") + + // when + val filtered = BracketParamsRequestWrapper(request) + val value = filtered.getParameter("param") + val valueBracket = filtered.getParameter("param[]") + + // then + assertThat(value).isEqualTo(valueBracket) + assertThat(value).isEqualTo("b") + } + + @Test + fun `given parameters without brackets when getting parameter value single value is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param", "b") + + // when + val filtered = BracketParamsRequestWrapper(request) + val value = filtered.getParameter("param") + val valueBracket = filtered.getParameter("param[]") + + // then + assertThat(value).isEqualTo(valueBracket) + assertThat(value).isEqualTo("b") + } + + @Test + fun `given empty parameters when getting parameter value then return null`() { + // given + val request = MockHttpServletRequest() + + // when + val filtered = BracketParamsRequestWrapper(request) + val value = filtered.getParameter("param") + + // then + assertThat(value).isNull() + } + } + + @Nested + inner class ParameterValues { + @Test + fun `given parameters with and without brackets when getting parameter values then both values are returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]", "a") + request.setParameter("param", "b") + + // when + val filtered = BracketParamsRequestWrapper(request) + val values = filtered.getParameterValues("param") + val valuesBracket = filtered.getParameterValues("param[]") + + // then + assertThat(values).isEqualTo(valuesBracket) + assertThat(values).containsExactlyInAnyOrder("a", "b") + } + + @Test + fun `given parameters with brackets when getting parameter values then value is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]", "a") + + // when + val filtered = BracketParamsRequestWrapper(request) + val values = filtered.getParameterValues("param") + val valuesBracket = filtered.getParameterValues("param[]") + + // then + assertThat(values).isEqualTo(valuesBracket) + assertThat(values).containsExactlyInAnyOrder("a") + } + + @Test + fun `given parameters without brackets when getting parameter values then value is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param", "a") + + // when + val filtered = BracketParamsRequestWrapper(request) + val values = filtered.getParameterValues("param") + val valuesBracket = filtered.getParameterValues("param[]") + + // then + assertThat(values).isEqualTo(valuesBracket) + assertThat(values).containsExactlyInAnyOrder("a") + } + + @Test + fun `given empty parameters when getting parameter values then return null`() { + // given + val request = MockHttpServletRequest() + + // when + val filtered = BracketParamsRequestWrapper(request) + val values = filtered.getParameterValues("param") + + // then + assertThat(values).isNull() + } + } + + @Nested + inner class ParameterMap { + @Test + fun `given parameters with and without brackets when getting parameter map then both values are returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]", "a") + request.setParameter("param", "b") + + // when + val filtered = BracketParamsRequestWrapper(request) + val map = filtered.parameterMap + + // then + assertThat(map.keys).hasSize(1) + assertThat(map.keys).containsExactly("param") + assertThat(map["param"]).containsExactlyInAnyOrder("a", "b") + } + + @Test + fun `given parameters with brackets when getting parameter map then key without bracket is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param[]", "a") + + // when + val filtered = BracketParamsRequestWrapper(request) + val map = filtered.parameterMap + + // then + assertThat(map.keys).hasSize(1) + assertThat(map.keys).containsExactly("param") + assertThat(map["param"]).containsExactlyInAnyOrder("a") + } + + @Test + fun `given parameters without brackets when getting parameter map then key without bracket is returned`() { + // given + val request = MockHttpServletRequest() + request.setParameter("param", "a") + + // when + val filtered = BracketParamsRequestWrapper(request) + val map = filtered.parameterMap + + // then + assertThat(map.keys).hasSize(1) + assertThat(map.keys).containsExactly("param") + assertThat(map["param"]).containsExactlyInAnyOrder("a") + } + + @Test + fun `given empty parameters when getting parameter map then empty map is returned`() { + // given + val request = MockHttpServletRequest() + + // when + val filtered = BracketParamsRequestWrapper(request) + val map = filtered.parameterMap + + // then + assertThat(map).isEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/SessionTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/SessionTest.kt new file mode 100644 index 000000000..21890d47a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/SessionTest.kt @@ -0,0 +1,144 @@ +package org.gotson.komga.interfaces.api + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.hamcrest.Matchers.containsString +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class SessionTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val sessionHeaderName: String, + @Autowired private val sessionCookieName: String, +) { + private lateinit var user: KomgaUser + + private val rememberMeCookieName = "komga-remember-me" + + @BeforeAll + fun setup() { + user = KomgaUser("user@example.org", "user") + userLifecycle.createUser(user) + } + + @AfterAll + fun tearDown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + } + + @Test + fun `given valid basic credentials when hitting an endpoint then session cookie is returned`() { + mockMvc + .get("/api/v2/users/me") { + with(httpBasic(user.email, user.password)) + }.andExpect { + header { + string(HttpHeaders.SET_COOKIE, containsString("$sessionCookieName=")) + } + cookie { + exists(sessionCookieName) + httpOnly(sessionCookieName, true) + } + } + } + + @Test + fun `given remember-me parameter when hitting an endpoint then remember-me cookie is returned`() { + mockMvc + .get("/api/v2/users/me") { + with(httpBasic(user.email, user.password)) + param("remember-me", "true") + }.andExpect { + header { + string(HttpHeaders.SET_COOKIE, containsString("$rememberMeCookieName=")) + } + cookie { + exists(rememberMeCookieName) + httpOnly(rememberMeCookieName, true) + } + } + } + + @Test + fun `given valid basic credentials when providing the auth header then session is returned in headers`() { + mockMvc + .get("/api/v2/users/me") { + with(httpBasic(user.email, user.password)) + header(sessionHeaderName, "") + }.andExpect { + header { + exists(sessionHeaderName) + } + } + } + + @Test + fun `given existing session when exchanging for cookies then session is returned in cookies`() { + val sessionId = + mockMvc + .get("/api/v2/users/me") { + with(httpBasic(user.email, user.password)) + header(sessionHeaderName, "") + }.andReturn() + .response + .getHeader(this.sessionHeaderName) + + assertThat(sessionId).isNotNull + + mockMvc + .get("/api/v1/login/set-cookie") { + header(sessionHeaderName, sessionId!!) + }.andExpect { + header { + string(HttpHeaders.SET_COOKIE, containsString("$sessionCookieName=")) + doesNotExist(sessionHeaderName) + } + cookie { + exists(sessionCookieName) + httpOnly(sessionCookieName, true) + } + } + } + + @Test + fun `given existing session when logging out then session cookie is cleared`() { + val sessionId = + mockMvc + .get("/api/v2/users/me") { + with(httpBasic(user.email, user.password)) + header(sessionHeaderName, "") + }.andReturn() + .response + .getHeader(this.sessionHeaderName) + + assertThat(sessionId).isNotNull + + mockMvc + .get("/api/logout") { + header(sessionHeaderName, sessionId!!) + }.andExpect { + header { + string(HttpHeaders.SET_COOKIE, containsString("$sessionCookieName=;")) + } + cookie { + maxAge(sessionCookieName, 0) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt new file mode 100644 index 000000000..2d7645d5f --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt @@ -0,0 +1,198 @@ +package org.gotson.komga.interfaces.api.kobo + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.kobo.KoboHeaders +import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator +import org.gotson.komga.infrastructure.web.WebServerEffectiveSettings +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.util.stream.Stream + +@SpringBootTest(properties = ["komga.kobo.sync-item-limit=1"]) +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class KoboControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val komgaUserLifecycle: KomgaUserLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + @Autowired private val komgaSettingsProvider: KomgaSettingsProvider, + @Autowired private val serverSettings: WebServerEffectiveSettings, +) { + private val library1 = makeLibrary() + private val user1 = + KomgaUser( + "user@example.org", + "", + roles = setOf(UserRoles.KOBO_SYNC), + ) + private lateinit var apiKey: String + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library1) + userRepository.insert(user1) + apiKey = komgaUserLifecycle.createApiKey(user1, "test")!!.key + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + komgaUserLifecycle.deleteUser(user1) + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given user when syncing for the first time then books are synced`() { + // given + val book1 = makeBook("valid", libraryId = library1.id) + val book2 = makeBook("valid", libraryId = library1.id) + + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2)) + } + } + + mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + // first sync + val mvcResult1 = + mockMvc + .get("/kobo/$apiKey/v1/library/sync") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + header { string(KoboHeaders.X_KOBO_SYNC, "continue") } + }.andReturn() + + val syncToken1Base64 = mvcResult1.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String + val syncToken1 = komgaSyncTokenGenerator.fromBase64(syncToken1Base64) + assertThat(syncToken1.ongoingSyncPointId).isNotEmpty() + assertThat(syncToken1.lastSuccessfulSyncPointId).isNull() + + // second sync + val mvcResult2 = + mockMvc + .get("/kobo/$apiKey/v1/library/sync") { + header(KoboHeaders.X_KOBO_SYNCTOKEN, syncToken1Base64) + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + header { doesNotExist(KoboHeaders.X_KOBO_SYNC) } + }.andReturn() + + val syncToken2 = komgaSyncTokenGenerator.fromBase64(mvcResult2.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String) + assertThat(syncToken2.ongoingSyncPointId).isNull() + assertThat(syncToken2.lastSuccessfulSyncPointId).isEqualTo(syncToken1.ongoingSyncPointId) + } + + @Test + fun `given kobo proxy is enabled when requesting book cover for non-existent book then redirect response is returned`() { + komgaSettingsProvider.koboProxy = true + + try { + mockMvc + .get("/kobo/$apiKey/v1/books/nonexistent/thumbnail/800/800/false/image.jpg") + .andExpect { + status { isTemporaryRedirect() } + header { string(HttpHeaders.LOCATION, "https://cdn.kobo.com/book-images/nonexistent/800/800/false/image.jpg") } + } + } finally { + komgaSettingsProvider.koboProxy = false + } + } + + @Nested + inner class HostHeader( + @param:Value($$"${server.port:#{null}}") private val configServerPort: Int?, + ) { + @ParameterizedTest + @MethodSource("headers") + fun `given partial host header when getting initialization then img urls are correct`( + hostHeader: String, + expected: String, + extraHeaders: Map?, + koboPort: Int?, + ) { + // ServletWebServerInitializedEvent is not triggered during tests + serverSettings.effectiveServerPort = configServerPort + + val oldPort = komgaSettingsProvider.koboPort + koboPort?.let { komgaSettingsProvider.koboPort = it } + + try { + mockMvc + .get("/kobo/$apiKey/v1/initialization") { + header(HttpHeaders.HOST, hostHeader) + extraHeaders?.forEach { (h, v) -> header(h, v) } + }.andExpect { + jsonPath("Resources.image_host") { value(expected) } + }.andReturn() + } finally { + komgaSettingsProvider.koboPort = oldPort + } + } + + private fun headers(): Stream = + Stream.of( + Arguments.of("127.0.0.1", "http://127.0.0.1:$configServerPort", null, null), + Arguments.of("localhost", "http://localhost:$configServerPort", null, null), + Arguments.of( + "127.0.0.1", + "https://demo.komga.org", + mapOf( + "X-Forwarded-Proto" to "https", + "X-Forwarded-Host" to "demo.komga.org", + ), + null, + ), + Arguments.of("127.0.0.1", "http://127.0.0.1:8085", null, 8085), + Arguments.of("localhost", "http://localhost:8085", null, 8085), + Arguments.of( + "127.0.0.1", + "https://demo.komga.org", + mapOf( + "X-Forwarded-Proto" to "https", + "X-Forwarded-Host" to "demo.komga.org", + ), + 8085, + ), + ) + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt new file mode 100644 index 000000000..16011c5bd --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt @@ -0,0 +1,88 @@ +package org.gotson.komga.interfaces.api.kosync + +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class KoreaderSyncControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val komgaUserLifecycle: KomgaUserLifecycle, +) { + private val user1 = + KomgaUser( + "user@example.org", + "", + roles = setOf(UserRoles.KOREADER_SYNC), + ) + private lateinit var apiKey: String + + @BeforeAll + fun setup() { + userRepository.insert(user1) + apiKey = komgaUserLifecycle.createApiKey(user1, "test")!!.key + } + + @AfterAll + fun teardown() { + komgaUserLifecycle.deleteUser(user1) + } + + @Test + fun `when creating user then forbidden is thrown`() { + mockMvc + .post("/koreader/users/create") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `given missing X-Auth-User header when authenticating user then forbidden is thrown`() { + mockMvc + .get("/koreader/users/auth") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `given api key in X-Auth-User header when authenticating user then returns OK`() { + mockMvc + .get("/koreader/users/auth") { + header("x-auth-user", apiKey) + }.andExpect { + status { isOk() } + jsonPath("authorized") { value("OK") } + } + } + + @ParameterizedTest + @ValueSource(strings = [MediaType.APPLICATION_JSON_VALUE, "application/vnd.koreader.v1+json"]) + fun `given accept header when calling API user then returns OK`(acceptHeader: String) { + mockMvc + .get("/koreader/users/auth") { + header("x-auth-user", apiKey) + header(HttpHeaders.ACCEPT, acceptHeader) + }.andExpect { + status { isOk() } + jsonPath("authorized") { value("OK") } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt new file mode 100644 index 000000000..086dd6538 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt @@ -0,0 +1,388 @@ +package org.gotson.komga.interfaces.api.opds + +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.interfaces.api.rest.WithMockCustomUser +import org.hamcrest.Matchers +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.time.LocalDateTime + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class OpdsControllerTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val bookRepository: BookRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val mockMvc: MockMvc, +) { + private val library = makeLibrary(id = "1") + private val user = KomgaUser("user@example.org", "", id = "1") + private val user2 = KomgaUser("user2@example.org", "", id = "2") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + userRepository.insert(user) + userRepository.insert(user2) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Nested + inner class LimitedUser { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting series then only gets series from this library`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val otherLibrary = makeLibrary("other") + libraryRepository.insert(otherLibrary) + makeSeries(name = "otherSeries", libraryId = otherLibrary.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val otherBooks = listOf(makeBook("2", libraryId = otherLibrary.id)) + seriesLifecycle.addBooks(created, otherBooks) + } + } + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { + nodeCount(1) + string(createdSeries.id) + } + } + } + } + + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series5 = + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val series15 = + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/opds/v1.2/series/${series5.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/opds/v1.2/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { nodeCount(2) } + xpath("/feed/entry[1]/id") { string(series10.id) } + xpath("/feed/entry[2]/id") { string(series5.id) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series18 = + makeSeries(name = "series_18", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val series16 = + makeSeries(name = "series_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/opds/v1.2/series/${series.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series16.id}").andExpect { status { isForbidden() } } + mockMvc.get("/opds/v1.2/series/${series18.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { nodeCount(2) } + xpath("/feed/entry[1]/id") { string(series10.id) } + xpath("/feed/entry[2]/id") { string(series.id) } + } + } + } + + @Nested + inner class SeriesSort { + @Test + @WithMockCustomUser + fun `given series with titleSort when requesting via opds then series are sorted by titleSort`() { + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) + } + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("TheAlpha") } + xpath("/feed/entry[2]/title") { string("Beta") } + } + } + + @Test + @WithMockCustomUser + fun `given series when requesting via opds then series are sorted insensitive of case`() { + listOf("a", "b", "B", "C") + .map { name -> makeSeries(name, libraryId = library.id) } + .forEach { + seriesLifecycle.createSeries(it) + } + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("a") } + xpath("/feed/entry[2]/title") { string(Matchers.equalToIgnoringCase("b")) } + xpath("/feed/entry[3]/title") { string(Matchers.equalToIgnoringCase("b")) } + xpath("/feed/entry[4]/title") { string("C") } + } + } + } + + @Nested + inner class SeriesStatus { + @Test + @WithMockCustomUser + fun `given series when requesting via opds then deleted series are not returned`() { + seriesLifecycle.createSeries(makeSeries("Alpha", libraryId = library.id)).also { + seriesRepository.update(it.copy(deletedDate = LocalDateTime.now())) + } + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) + + mockMvc + .get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(1) } + xpath("/feed/entry[1]/title") { string("Beta") } + } + } + } + + @Nested + inner class BookOrdering { + @Test + @WithMockCustomUser + fun `given books with unordered index when requesting via opds then books are ordered`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + } + + mockMvc + .get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("2") } + xpath("/feed/entry[3]/title") { string("3") } + } + } + } + + @Nested + inner class BookStatus { + @Test + @WithMockCustomUser + fun `given books not ready when requesting via opds then no books are returned`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + mockMvc + .get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(2) } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("3") } + } + } + + @Test + @WithMockCustomUser + fun `given deleted ready books when requesting via opds then no books are returned`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + if (it.id == addedBook.id) + bookRepository.update(it.copy(deletedDate = LocalDateTime.now())) + } + + mockMvc + .get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(2) } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("3") } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ActuatorTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ActuatorTest.kt new file mode 100644 index 000000000..17298e3e1 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ActuatorTest.kt @@ -0,0 +1,76 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@ActiveProfiles("test") +class ActuatorTest( + @Autowired private val mockMvc: MockMvc, +) { + @Test + @WithAnonymousUser + fun `given anonymous user when getting actuator endpoints then returns unauthorized`() { + mockMvc + .get("/actuator") + .andExpect { + status { isUnauthorized() } + } + + mockMvc + .get("/actuator/beans") + .andExpect { + status { isUnauthorized() } + } + } + + @Test + @WithAnonymousUser + fun `given anonymous user when getting actuator health endpoint then returns ok`() { + mockMvc + .get("/actuator/health") + .andExpect { + status { isOk() } + } + } + + @Test + @WithMockUser + fun `given regular user when getting actuator endpoints then returns forbidden`() { + mockMvc + .get("/actuator") + .andExpect { + status { isForbidden() } + } + + mockMvc + .get("/actuator/beans") + .andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockUser(roles = ["ADMIN"]) + fun `given admin user when getting actuator endpoints then returns ok`() { + mockMvc + .get("/actuator") + .andExpect { + status { isOk() } + } + + mockMvc + .get("/actuator/beans") + .andExpect { + status { isOk() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt new file mode 100644 index 000000000..2d2ea096b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt @@ -0,0 +1,74 @@ +package org.gotson.komga.interfaces.api.rest + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import io.mockk.verify +import org.gotson.komga.interfaces.api.rest.dto.JsonFeedDto +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class AnnouncementControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @SpykBean + private lateinit var announcementController: AnnouncementController + + private val mockFeed = + JsonFeedDto( + "https://jsonfeed.org/version/1", + "Announcements", + "https://komga.org/blog", + "Latest Komga announcements", + listOf( + JsonFeedDto.ItemDto( + "https://komga.org/blog/prepare-v1", + "https://komga.org/blog/prepare-v1", + "Prepare for v1.0.0", + "The future v1.0.0 will bring some breaking changes, this guide will help you to prepare for the next major version.", + """ + You can still change the port through configuration + Another link here. + A normal link. + """.trimIndent(), + OffsetDateTime.of( + LocalDate.of(2023, 3, 21).atStartOfDay(), + ZoneOffset.UTC, + ), + JsonFeedDto.ItemAuthorDto("gotson", "https://github.com/gotson"), + setOf( + "breaking change", + "upgrade", + "komga", + ), + null, + ), + ), + ) + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when getting announcements multiple times then the server announcements are only fetched once`() { + every { announcementController.fetchWebsiteAnnouncements() } returns mockFeed + + repeat(2) { + mockMvc + .get("/api/v1/announcements") + .andExpect { + status { isOk() } + jsonPath("$.items.length()") { value(1) } + jsonPath("$.items[0].date_modified") { value("2023-03-21T00:00:00Z") } + } + } + + verify(exactly = 1) { announcementController.fetchWebsiteAnnouncements() } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ApiKeyTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ApiKeyTest.kt new file mode 100644 index 000000000..b08ff6345 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ApiKeyTest.kt @@ -0,0 +1,69 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class ApiKeyTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val komgaUserLifecycle: KomgaUserLifecycle, +) { + private val user1 = + KomgaUser( + "user@example.org", + "password", + ) + private lateinit var apiKey: String + + @BeforeAll + fun setup() { + userRepository.insert(user1) + apiKey = komgaUserLifecycle.createApiKey(user1, "test")!!.key + } + + @AfterAll + fun teardown() { + komgaUserLifecycle.deleteUser(user1) + } + + @Test + fun `when getting user information then unauthorized is thrown`() { + mockMvc + .get("/api/v2/users/me") + .andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `given invalid api key in X-API-Key header when getting user information then unauthorized is thrown`() { + mockMvc + .get("/api/v2/users/me") { + header("x-api-key", "abc123") + }.andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `given api key in X-API-Key header when getting user information then returns OK`() { + mockMvc + .get("/api/v2/users/me") { + header("x-api-key", apiKey) + }.andExpect { + status { isOk() } + jsonPath("email") { value(user1.email) } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerPageTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerPageTest.kt new file mode 100644 index 000000000..022db2882 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerPageTest.kt @@ -0,0 +1,181 @@ +package org.gotson.komga.interfaces.api.rest + +import com.ninjasquad.springmockk.MockkBean +import com.ninjasquad.springmockk.SpykBean +import io.mockk.every +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.TypedBytes +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.util.stream.Stream + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class BookControllerPageTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val bookRepository: BookRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val mockMvc: MockMvc, +) { + @MockkBean + private lateinit var mockAnalyzer: BookAnalyzer + + @SpykBean + private lateinit var bookLifecycle: BookLifecycle + + private val library = makeLibrary(id = "1") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + } + + @AfterAll + fun teardown() { + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @ParameterizedTest + @MethodSource("arguments") + @WithMockCustomUser + fun `given pdf book when getting page with Accept header then returns page in correct format`( + bookType: String, + acceptTypes: List, + success: Boolean, + resultType: String?, + ) { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { + mediaRepository.update( + it.copy( + status = Media.Status.READY, + mediaType = bookType, + pages = listOf(BookPage("file", "image/jpeg")), + ), + ) + } + + every { mockAnalyzer.getPageContentRaw(any(), 1) } returns TypedBytes(ByteArray(0), "application/pdf") + every { bookLifecycle.getBookPage(any(), 1, any(), any()) } returns TypedBytes(ByteArray(0), "image/jpeg") + + mockMvc + .get("/api/v1/books/${book.id}/pages/1") { + if (acceptTypes.isNotEmpty()) accept(*acceptTypes.toTypedArray()) + }.andExpect { + status { if (success) isOk() else isBadRequest() } + if (resultType != null) + header { string(HttpHeaders.CONTENT_TYPE, resultType) } + } + } + + fun arguments(): Stream = + Stream.of( + // PDF book: request nothing, get image + Arguments.of( + "application/pdf", + emptyList(), + true, + "image/jpeg", + ), + // PDF book: request PDF, get PDF + Arguments.of( + "application/pdf", + listOf(MediaType.APPLICATION_PDF), + true, + "application/pdf", + ), + // PDF book: request PDF + others, get PDF + Arguments.of( + "application/pdf", + listOf(MediaType.APPLICATION_PDF, MediaType("image")), + true, + "application/pdf", + ), + // PDF book: request image, get image + Arguments.of( + "application/pdf", + listOf(MediaType("image")), + true, + "image/jpeg", + ), + // PDF book: request unhandled image, still get image. We don't check image subtypes. + Arguments.of( + "application/pdf", + listOf(MediaType("image", "avif")), + true, + "image/jpeg", + ), + // PDF book: request random, get image. We ignore non-pdf types. + Arguments.of( + "application/pdf", + listOf(MediaType.APPLICATION_ATOM_XML), + true, + "image/jpeg", + ), + // PDF book: request PDF with lower priority than image, get image + Arguments.of( + "application/pdf", + listOf(MediaType.IMAGE_JPEG, MediaType.APPLICATION_PDF), + true, + "image/jpeg", + ), + // PDF book: request PDF with lower quality than image, get image + Arguments.of( + "application/pdf", + listOf(MediaType("application", "pdf", 0.5), MediaType.IMAGE_JPEG), + true, + "image/jpeg", + ), + // PDF book: request PDF with higher quality than image, get pdf + Arguments.of( + "application/pdf", + listOf(MediaType("image", "jpeg", 0.5), MediaType("application", "pdf", 0.8)), + true, + "application/pdf", + ), + ) +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt new file mode 100644 index 000000000..03fe5b538 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt @@ -0,0 +1,1462 @@ +package org.gotson.komga.interfaces.api.rest + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.groups.Tuple.tuple +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.hamcrest.Matchers.containsString +import org.hamcrest.core.IsNull +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MockMvcResultMatchersDsl +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.time.LocalDate +import kotlin.random.Random + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class BookControllerTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val mockMvc: MockMvc, +) { + private val library = makeLibrary(id = "1") + private val user = KomgaUser("user@example.org", "", id = "1") + private val user2 = KomgaUser("user2@example.org", "", id = "2") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + userRepository.insert(user) + userRepository.insert(user2) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Nested + inner class LimitedUser { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting books then only gets books from this library`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val otherLibrary = makeLibrary("other") + libraryRepository.insert(otherLibrary) + makeSeries(name = "otherSeries", libraryId = otherLibrary.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val otherBooks = listOf(makeBook("2", libraryId = otherLibrary.id)) + seriesLifecycle.addBooks(created, otherBooks) + } + } + + mockMvc + .get("/api/v1/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(1) } + jsonPath("$.content[0].name") { value("1") } + } + } + } + + @Nested + inner class RestrictedContent { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting books then only gets books that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library.id) + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book5 = makeBook("book_5", libraryId = library.id) + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book5) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val book15 = makeBook("book_15", libraryId = library.id) + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book15) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${book5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/books?sort=metadata.title") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(book10.name) } + jsonPath("$.content[1].name") { value(book5.name) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 10) + fun `given user disallowed content with specific age rating when getting books then only gets books that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library.id) + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book5 = makeBook("book_5", libraryId = library.id) + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book5) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val book15 = makeBook("book_15", libraryId = library.id) + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book15) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${book5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book10.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book15.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/books?sort=metadata.title") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(book.name) } + jsonPath("$.content[1].name") { value(book5.name) } + } + } + + @Test + @WithMockCustomUser(allowLabels = ["kids", "cute"]) + fun `given user allowed content with specific labels when getting series then only gets books that satisfies this criteria`() { + val bookKids = makeBook("book_kids", libraryId = library.id) + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookKids) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val bookCute = makeBook("book_cute", libraryId = library.id) + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookCute) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val bookAdult = makeBook("book_adult", libraryId = library.id) + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookAdult) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${bookKids.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${bookCute.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${bookAdult.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/books?sort=metadata.title") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(bookCute.name) } + jsonPath("$.content[1].name") { value(bookKids.name) } + } + } + + @Test + @WithMockCustomUser(excludeLabels = ["kids", "cute"]) + fun `given user disallowed content with specific labels when getting books then only gets books that satisfies this criteria`() { + val bookKids = makeBook("book_kids", libraryId = library.id) + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookKids) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val bookCute = makeBook("book_cute", libraryId = library.id) + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookCute) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val bookAdult = makeBook("book_adult", libraryId = library.id) + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookAdult) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${bookKids.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${bookCute.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${bookAdult.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isOk() } } + + mockMvc + .get("/api/v1/books?sort=metadata.title") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(book.name) } + jsonPath("$.content[1].name") { value(bookAdult.name) } + } + } + + @Test + @WithMockCustomUser(allowAgeUnder = 10, allowLabels = ["kids"], excludeLabels = ["adult", "teen"]) + fun `given user allowed and disallowed content labels when getting books then only gets books that satisfies this criteria`() { + val bookKids = makeBook("book_kids", libraryId = library.id) + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookKids) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val bookCute = makeBook("book_cute", libraryId = library.id) + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookCute) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5, sharingLabels = setOf("cute", "other"))) + } + } + + val bookAdult = makeBook("book_adult", libraryId = library.id) + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookAdult) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${bookKids.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${bookCute.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${bookAdult.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/books?sort=metadata.title") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(bookCute.name) } + jsonPath("$.content[1].name") { value(bookKids.name) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16, allowLabels = ["teen"]) + fun `given user allowed and disallowed content labels when getting books then only gets books that satisfies this criteria (2)`() { + val bookTeen16 = makeBook("book_teen_16", libraryId = library.id) + makeSeries(name = "series_teen_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookTeen16) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"), ageRating = 16)) + } + } + + mockMvc.get("/api/v1/books/${bookTeen16.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(0) } + } + } + } + + @Nested + inner class UserWithoutLibraryAccess { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific book then returns forbidden`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific book thumbnail then returns forbidden`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/thumbnail") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific book file then returns forbidden`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/file") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific book pages then returns forbidden`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/pages") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific book page then returns forbidden`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/pages/1") + .andExpect { status { isForbidden() } } + } + } + + @Nested + inner class RestrictedUserByRole { + @Test + @WithMockCustomUser(roles = []) + fun `given user without page streaming role when getting specific book page then returns unauthorized`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/pages/1") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(roles = []) + fun `given user without file download role when getting specific book file then returns unauthorized`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/file") + .andExpect { status { isForbidden() } } + } + } + + @Nested + inner class MediaNotReady { + @Test + @WithMockCustomUser + fun `given book without thumbnail when getting book thumbnail then returns not found`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/thumbnail") + .andExpect { status { isNotFound() } } + } + + @Test + @WithMockCustomUser + fun `given book without file when getting book file then returns not found`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/file") + .andExpect { status { isNotFound() } } + } + + @ParameterizedTest + @EnumSource(value = Media.Status::class, names = ["READY"], mode = EnumSource.Mode.EXCLUDE) + @WithMockCustomUser + fun `given book with media status not ready when getting book pages then returns not found`(status: Media.Status) { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(status = status)) + } + + mockMvc + .get("/api/v1/books/${book.id}/pages") + .andExpect { status { isNotFound() } } + } + + @ParameterizedTest + @EnumSource(value = Media.Status::class, names = ["READY"], mode = EnumSource.Mode.EXCLUDE) + @WithMockCustomUser + fun `given book with media status not ready when getting specific book page then returns not found`(status: Media.Status) { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(status = status)) + } + + mockMvc + .get("/api/v1/books/${book.id}/pages/1") + .andExpect { status { isNotFound() } } + } + } + + @ParameterizedTest + @ValueSource(strings = ["25", "-5", "0"]) + @WithMockCustomUser + fun `given book with pages when getting non-existent page then returns bad request`(page: String) { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { + mediaRepository.update( + it.copy( + status = Media.Status.READY, + pages = listOf(BookPage("file", "image/jpeg")), + ), + ) + } + + mockMvc + .get("/api/v1/books/${book.id}/pages/$page") + .andExpect { status { isBadRequest() } } + } + + @Nested + inner class Siblings { + @Test + @WithMockCustomUser + fun `given series with multiple books when getting siblings then it is returned or not found`() { + val book1 = makeBook("1", libraryId = library.id) + val book2 = makeBook("2", libraryId = library.id) + val book3 = makeBook("3", libraryId = library.id) + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book1, book2, book3) + seriesLifecycle.addBooks(created, books) + seriesLifecycle.sortBooks(created) + } + } + + mockMvc + .get("/api/v1/books/${book1.id}/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/books/${book1.id}/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("2") } + } + + mockMvc + .get("/api/v1/books/${book2.id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("1") } + } + mockMvc + .get("/api/v1/books/${book2.id}/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("3") } + } + + mockMvc + .get("/api/v1/books/${book3.id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("2") } + } + mockMvc + .get("/api/v1/books/${book3.id}/next") + .andExpect { status { isNotFound() } } + } + } + + @Nested + inner class DtoUrlSanitization { + @Test + @WithMockCustomUser + fun `given regular user when getting books then full url is hidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + val validation: MockMvcResultMatchersDsl.() -> Unit = { + status { isOk() } + jsonPath("$.content[0].url") { value("1.cbr") } + } + + mockMvc + .get("/api/v1/books") + .andExpect(validation) + + mockMvc + .get("/api/v1/books/latest") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/${createdSeries.id}/books") + .andExpect(validation) + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { + status { isOk() } + jsonPath("$.url") { value("1.cbr") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when getting books then full url is available`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + val validation: MockMvcResultMatchersDsl.() -> Unit = { + status { isOk() } + jsonPath("$.content[0].url") { value(containsString("1.cbr")) } + } + + mockMvc + .get("/api/v1/books") + .andExpect(validation) + + mockMvc + .get("/api/v1/books/latest") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/${createdSeries.id}/books") + .andExpect(validation) + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { + status { isOk() } + jsonPath("$.url") { value(containsString("1.cbr")) } + } + } + } + + @Nested + inner class HttpCache { + @Test + @WithMockCustomUser + fun `given request with cache headers when getting thumbnail then returns 304 not modified`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + thumbnail = Random.nextBytes(100), + bookId = book.id, + type = ThumbnailBook.Type.GENERATED, + fileSize = 0, + mediaType = "", + dimension = Dimension(0, 0), + ), + MarkSelectedPreference.YES, + ) + + val url = "/api/v1/books/${book.id}/thumbnail" + + val response = + mockMvc + .get(url) + .andReturn() + .response + + mockMvc + .get(url) { + headers { + ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + } + }.andExpect { + status { isNotModified() } + } + } + + @Test + @WithMockCustomUser + fun `given request with If-Modified-Since headers when getting page then returns 304 not modified`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + val url = "/api/v1/books/${book.id}/pages/1" + + val lastModified = + mockMvc + .get(url) + .andReturn() + .response + .getHeader(HttpHeaders.LAST_MODIFIED) + + mockMvc + .get(url) { + headers { + set(HttpHeaders.IF_MODIFIED_SINCE, lastModified!!) + } + }.andExpect { + status { isNotModified() } + } + } + + @Test + @WithMockCustomUser + fun `given request with cache headers and modified resource when getting thumbnail then returns 200 ok`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + thumbnail = Random.nextBytes(1), + bookId = book.id, + type = ThumbnailBook.Type.GENERATED, + fileSize = 0, + mediaType = "", + dimension = Dimension(0, 0), + ), + MarkSelectedPreference.YES, + ) + + val url = "/api/v1/books/${book.id}/thumbnail" + + val response = mockMvc.get(url).andReturn().response + + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + thumbnail = Random.nextBytes(1), + bookId = book.id, + type = ThumbnailBook.Type.GENERATED, + fileSize = 0, + mediaType = "", + dimension = Dimension(0, 0), + ), + MarkSelectedPreference.YES, + ) + + mockMvc + .get(url) { + headers { + ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + } + }.andExpect { + status { isOk() } + } + } + } + + @Nested + inner class MetadataUpdate { + @Test + @WithMockCustomUser + fun `given non-admin user when updating metadata then raise forbidden`() { + mockMvc + .patch("/api/v1/books/1/metadata") { + contentType = MediaType.APPLICATION_JSON + content = "{}" + }.andExpect { + status { isForbidden() } + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + """{"title":""}""", + """{"number":""}""", + """{"authors":"[{"name":""}]"}""", + """{"isbn":"1617290459"}""", // isbn 10 + """{"isbn":"978-123-456-789-6"}""", // invalid check digit + ], + ) + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given invalid json when updating metadata then raise validation error`(jsonString: String) { + mockMvc + .patch("/api/v1/books/1/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given valid json when updating metadata then fields are updated`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val bookId = bookRepository.findAll().first().id + + // language=JSON + val jsonString = + """ + { + "title":"newTitle", + "titleLock":true, + "summary":"newSummary", + "summaryLock":true, + "number":"newNumber", + "numberLock":true, + "numberSort": 1.0, + "numberSortLock":true, + "releaseDate":"2020-01-01", + "releaseDateLock":true, + "authors":[ + { + "name":"newAuthor", + "role":"newAuthorRole" + }, + { + "name":"newAuthor2", + "role":"newAuthorRole2" + } + ], + "authorsLock":true, + "tags":["tag"], + "tagsLock":true, + "isbn":"978-161-729-045-9abc xxxoefj", + "isbnLock":true + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/$bookId/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(title).isEqualTo("newTitle") + assertThat(summary).isEqualTo("newSummary") + assertThat(number).isEqualTo("newNumber") + assertThat(numberSort).isEqualTo(1F) + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) + assertThat(authors) + .hasSize(2) + .extracting("name", "role") + .containsExactlyInAnyOrder( + tuple("newAuthor", "newauthorrole"), + tuple("newAuthor2", "newauthorrole2"), + ) + assertThat(tags).containsExactly("tag") + assertThat(isbn).isEqualTo("9781617290459") + + assertThat(titleLock).isEqualTo(true) + assertThat(summaryLock).isEqualTo(true) + assertThat(numberLock).isEqualTo(true) + assertThat(numberSortLock).isEqualTo(true) + assertThat(releaseDateLock).isEqualTo(true) + assertThat(authorsLock).isEqualTo(true) + assertThat(tagsLock).isEqualTo(true) + assertThat(isbnLock).isEqualTo(true) + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given json with blank fields when updating metadata then fields with blanks are unset`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val bookId = bookRepository.findAll().first().id + bookMetadataRepository.findById(bookId).let { metadata -> + val updated = + metadata.copy( + summary = "summary", + isbn = "9781617290459", + ) + + bookMetadataRepository.update(updated) + } + + // language=JSON + val jsonString = + """ + { + "summary":"", + "isbn":"" + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/$bookId/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val updatedMetadata = bookMetadataRepository.findById(bookId) + with(updatedMetadata) { + assertThat(summary).isBlank + assertThat(isbn).isBlank + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given json with null fields when updating metadata then fields with null are unset`() { + val testDate = LocalDate.of(2020, 1, 1) + + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val bookId = bookRepository.findAll().first().id + bookMetadataRepository.findById(bookId).let { metadata -> + val updated = + metadata.copy( + authors = metadata.authors.toMutableList().also { it.add(Author("Author", "role")) }, + releaseDate = testDate, + tags = setOf("tag"), + summary = "summary", + isbn = "9781617290459", + ) + + bookMetadataRepository.update(updated) + } + + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(authors).hasSize(1) + assertThat(releaseDate).isEqualTo(testDate) + } + + // language=JSON + val jsonString = + """ + { + "authors":null, + "releaseDate":null, + "tags":null, + "summary":null, + "isbn":null + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/$bookId/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val updatedMetadata = bookMetadataRepository.findById(bookId) + with(updatedMetadata) { + assertThat(authors).isEmpty() + assertThat(releaseDate).isNull() + assertThat(tags).isEmpty() + assertThat(summary).isBlank + assertThat(isbn).isBlank + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given json without fields when updating metadata then existing fields are untouched`() { + val testDate = LocalDate.of(2020, 1, 1) + + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val bookId = bookRepository.findAll().first().id + bookMetadataRepository.findById(bookId).let { metadata -> + val updated = + metadata.copy( + authors = metadata.authors.toMutableList().also { it.add(Author("Author", "role")) }, + releaseDate = testDate, + summary = "summary", + number = "number", + numberLock = true, + numberSort = 2F, + numberSortLock = true, + title = "title", + isbn = "9781617290459", + ) + + bookMetadataRepository.update(updated) + } + + // language=JSON + val jsonString = + """ + { + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/$bookId/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(authors).hasSize(1) + assertThat(releaseDate).isEqualTo(testDate) + assertThat(summary).isEqualTo("summary") + assertThat(number).isEqualTo("number") + assertThat(numberSort).isEqualTo(2F) + assertThat(title).isEqualTo("title") + assertThat(isbn).isEqualTo("9781617290459") + } + } + } + + @Nested + inner class ReadProgress { + @ParameterizedTest + @ValueSource( + strings = [ + """{"completed": false}""", + """{}""", + """{"page":0}""", + ], + ) + @WithMockCustomUser + fun `given invalid payload when marking book in progress then validation error is returned`(jsonString: String) { + mockMvc + .patch("/api/v1/books/1/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(id = "1") + fun `given user when marking book in progress with page read then progress is marked accordingly`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + // language=JSON + val jsonString = + """ + { + "page": 5 + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/${book.id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { + status { isOk() } + jsonPath("$.readProgress.page") { value(5) } + jsonPath("$.readProgress.completed") { value(false) } + } + } + + @Test + @WithMockCustomUser(id = "1") + fun `given user when marking book completed then progress is marked accordingly`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + // language=JSON + val jsonString = + """ + { + "completed": true + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/${book.id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { + status { isOk() } + jsonPath("$.readProgress.page") { value(10) } + jsonPath("$.readProgress.completed") { value(true) } + } + } + + @Test + @WithMockCustomUser(id = "1") + fun `given user when deleting read progress then progress is removed`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + + // language=JSON + val jsonString = + """ + { + "page": 5, + "completed": false + } + """.trimIndent() + + mockMvc + .patch("/api/v1/books/${book.id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .delete("/api/v1/books/${book.id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/books/${book.id}") + .andExpect { + status { isOk() } + jsonPath("$.readProgress") { value(IsNull.nullValue()) } + } + } + } + + @Test + fun `given a user with read progress when getting books for the other user then books are returned correctly`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id), makeBook("2.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + ), + ) + } + + // language=JSON + val jsonString = + """ + { + "completed": true + } + """.trimIndent() + + mockMvc.perform( + MockMvcRequestBuilders + .patch("/api/v1/books/${book.id}/read-progress") + .with(user(KomgaPrincipal(user))) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonString), + ) + + mockMvc + .perform( + MockMvcRequestBuilders + .get("/api/v1/books") + .with(user(KomgaPrincipal(user))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect( + jsonPath("$.totalElements").value(2), + ) + + mockMvc + .perform( + MockMvcRequestBuilders + .get("/api/v1/books") + .with(user(KomgaPrincipal(user2))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect( + jsonPath("$.totalElements").value(2), + ) + } + + @Test + @WithMockCustomUser + fun `given book with Unicode name when getting book file then attachment name is correct`() { + val bookName = "アキラ" + val tempFile = + Files + .createTempFile(bookName, ".cbz") + .also { it.toFile().deleteOnExit() } + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook(bookName, libraryId = library.id, url = tempFile.toUri().toURL())) + seriesLifecycle.addBooks(created, books) + } + } + + val book = bookRepository.findAll().first() + + mockMvc + .get("/api/v1/books/${book.id}/file") + .andExpect { + status { isOk() } + header { string("Content-Disposition", containsString(URLEncoder.encode(bookName, StandardCharsets.UTF_8.name()))) } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClaimControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClaimControllerTest.kt new file mode 100644 index 000000000..99ceba84e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClaimControllerTest.kt @@ -0,0 +1,44 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@ActiveProfiles("test") +class ClaimControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @ParameterizedTest + @ValueSource(strings = ["user", "user@domain"]) + fun `given unclaimed server when claiming with invalid email address then returns bad request`(email: String) { + val password = "password" + + mockMvc + .post("/api/v1/claim") { + header("X-Komga-Email", email) + header("X-Komga-Password", password) + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithAnonymousUser + fun `given anonymous user when getting claim status then returns OK`() { + mockMvc + .get("/api/v1/claim") + .andExpect { + status { isOk() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsControllerTest.kt new file mode 100644 index 000000000..4a14e90b4 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ClientSettingsControllerTest.kt @@ -0,0 +1,423 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.infrastructure.jooq.main.ClientSettingsDtoDao +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class ClientSettingsControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val clientSettingsDtoDao: ClientSettingsDtoDao, + @Autowired private val userRepository: KomgaUserRepository, +) { + @AfterEach + fun cleanup() { + clientSettingsDtoDao.deleteAll() + } + + fun validKeys() = + listOf( + "single", + "one.two", + "one.with-dash", + "one.with_underscore", + "one.two.three-four_five", + "start2", + "start2.0value", + "start2.value2", + "start_2.value2", + "start-2.value2", + ) + + fun invalidKeys() = + listOf( + "UPPERCASE", + " ", + "", + "symbols!", + "two..dots", + ".start.with.dot", + "end.with.dot.", + "setting.-secondstartwithdash", + "setting.-secondstartwithunderscore", + "setting.secondendwithdash-", + "setting.secondendwithunderscore_", + "-first", + "_first", + "first-", + "first_", + ".", + ) + + @Nested + inner class AnonymousUser { + @Test + @WithAnonymousUser + fun `given anonymous user when retrieving settings then returns only settings allowed for unauthorized users`() { + clientSettingsDtoDao.saveGlobal("forall", "value", true) + clientSettingsDtoDao.saveGlobal("restricted", "value", false) + + mockMvc + .get("/api/v1/client-settings/global/list") + .andExpect { + status { isOk() } + jsonPath("$.size()") { value(1) } + jsonPath("$.forall.value") { value("value") } + jsonPath("$.forall.allowUnauthorized") { value(true) } + } + } + + @Test + @WithAnonymousUser + fun `given anonymous user when updating global settings then returns unauthorized`() { + //language=JSON + val jsonString = + """ + { + "setting": { + "value": "value", + "allowUnauthorized": false + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isUnauthorized() } + } + } + } + + @Nested + inner class RegularUser { + private val user1 = KomgaUser("user1@example.org", "", id = "user1") + + @BeforeAll + fun setup() { + userRepository.insert(user1) + } + + @AfterAll + fun tearDown() { + userRepository.delete(user1.id) + } + + @Test + @WithMockCustomUser + fun `given authenticated user when retrieving global settings then returns all settings`() { + clientSettingsDtoDao.saveGlobal("forall", "value", true) + clientSettingsDtoDao.saveGlobal("restricted", "value", false) + + mockMvc + .get("/api/v1/client-settings/global/list") + .andExpect { + status { isOk() } + jsonPath("$.size()") { value(2) } + jsonPath("$.forall.value") { value("value") } + jsonPath("$.forall.allowUnauthorized") { value(true) } + jsonPath("$.restricted.value") { value("value") } + jsonPath("$.restricted.allowUnauthorized") { value(false) } + } + } + + @Test + @WithMockCustomUser + fun `given non-admin user when updating global settings then returns forbidden`() { + //language=JSON + val jsonString = + """ + { + "setting": { + "value": "value", + "allowUnauthorized": false + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @ParameterizedTest + @MethodSource("validKeys") + @WithMockCustomUser(id = "user1") + fun `given non-admin user when updating user settings then settings are updated`(key: String) { + //language=JSON + val jsonString = + """ + { + "$key": { + "value": "value" + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/user") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/client-settings/user/list") + .andExpect { + status { isOk() } + jsonPath("$.size()") { value(1) } + jsonPath("$.['$key'].value") { value("value") } + } + } + + @ParameterizedTest + @MethodSource("invalidKeys") + @WithMockCustomUser(id = "user1") + fun `given non-admin user when updating user settings with invalid key then validation error is thrown`(key: String) { + //language=JSON + val jsonString = + """ + { + "$key": { + "value": "value" + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/user") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @ParameterizedTest + @MethodSource("invalidKeys") + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given non-admin user when deleting user settings with invalid key then validation error is thrown`(key: String) { + //language=JSON + val jsonString = + """ + ["$key"] + """.trimIndent() + + mockMvc + .delete("/api/v1/client-settings/user") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + //language=JSON + "null", + //language=JSON + """{"value": null }""", + //language=JSON + """{ "value": " " }""", + //language=JSON + """{ "value": "" }""", + ], + ) + @WithMockCustomUser(id = "user1") + fun `given non-admin user when updating user settings with invalid value then validation error is thrown`(value: String) { + //language=JSON + val jsonString = + """ + { + "key": $value + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/user") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + private fun validKeys() = this@ClientSettingsControllerTest.validKeys() + + private fun invalidKeys() = this@ClientSettingsControllerTest.invalidKeys() + } + + @Nested + inner class AdminUser { + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when retrieving settings then returns all settings`() { + clientSettingsDtoDao.saveGlobal("forall", "value", true) + clientSettingsDtoDao.saveGlobal("restricted", "value", false) + + mockMvc + .get("/api/v1/client-settings/global/list") + .andExpect { + status { isOk() } + jsonPath("$.size()") { value(2) } + jsonPath("$.forall.value") { value("value") } + jsonPath("$.forall.allowUnauthorized") { value(true) } + jsonPath("$.restricted.value") { value("value") } + jsonPath("$.restricted.allowUnauthorized") { value(false) } + } + } + + @ParameterizedTest + @MethodSource("validKeys") + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating global settings then settings are updated`(key: String) { + //language=JSON + val jsonString = + """ + { + "$key": { + "value": "value", + "allowUnauthorized": false + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/client-settings/global/list") + .andExpect { + status { isOk() } + jsonPath("$.size()") { value(1) } + jsonPath("$.['$key'].value") { value("value") } + jsonPath("$.['$key'].allowUnauthorized") { value(false) } + } + } + + @ParameterizedTest + @MethodSource("invalidKeys") + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating global settings with invalid key then validation error is thrown`(key: String) { + //language=JSON + val jsonString = + """ + { + "$key": { + "value": "value", + "allowUnauthorized": false + } + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @ParameterizedTest + @MethodSource("invalidKeys") + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when deleting global settings with invalid key then validation error is thrown`(key: String) { + //language=JSON + val jsonString = + """ + ["$key"] + """.trimIndent() + + mockMvc + .delete("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + //language=JSON + "null", + //language=JSON + """{ + "value": "1", + "allowUnauthorized": null + }""", + //language=JSON + """{ + "value": " ", + "allowUnauthorized": true + }""", + //language=JSON + """{ + "value": " ", + "allowUnauthorized": false + }""", + //language=JSON + """{ + "value": null, + "allowUnauthorized": false + }""", + ], + ) + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating global settings with invalid value then validation error is thrown`(value: String) { + //language=JSON + val jsonString = + """ + { + "setting": $value + } + """.trimIndent() + + mockMvc + .patch("/api/v1/client-settings/global") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + private fun validKeys() = this@ClientSettingsControllerTest.validKeys() + + private fun invalidKeys() = this@ClientSettingsControllerTest.invalidKeys() + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemControllerTest.kt new file mode 100644 index 000000000..6aa82fce6 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FileSystemControllerTest.kt @@ -0,0 +1,62 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +import java.nio.file.Files +import java.nio.file.Path + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class FileSystemControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + private val route = "/api/v1/filesystem" + + @Test + @WithAnonymousUser + fun `given anonymous user when getDirectoryListing then return unauthorized`() { + mockMvc + .post(route) + .andExpect { status { isUnauthorized() } } + } + + @Test + @WithMockUser + fun `given regular user when getDirectoryListing then return forbidden`() { + mockMvc + .post(route) + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockUser(roles = ["ADMIN"]) + fun `given relative path param when getDirectoryListing then return bad request`() { + mockMvc + .post(route) { + contentType = MediaType.APPLICATION_JSON + content = "." + }.andExpect { status { isBadRequest() } } + } + + @Test + @WithMockUser(roles = ["ADMIN"]) + fun `given non-existent path param when getDirectoryListing then return bad request`( + @TempDir parent: Path, + ) { + Files.delete(parent) + + mockMvc + .post(route) { + contentType = MediaType.APPLICATION_JSON + content = parent.toString() + }.andExpect { status { isBadRequest() } } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FontsControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FontsControllerTest.kt new file mode 100644 index 000000000..b3782f4e2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/FontsControllerTest.kt @@ -0,0 +1,87 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class FontsControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @Test + @WithMockCustomUser + fun `when getting font families then the embedded fonts are returned`() { + mockMvc + .get("/api/v1/fonts/families") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0]") { value("OpenDyslexic") } + } + } + + @Test + @WithMockCustomUser + fun `when getting font file then the content type headers are correct`() { + mockMvc + .get("/api/v1/fonts/resource/OpenDyslexic/OpenDyslexic-Bold.woff") + .andExpect { + status { isOk() } + header { + string(HttpHeaders.CONTENT_TYPE, "font/woff") + } + } + } + + @Test + @WithMockCustomUser + fun `when getting font css file then the content is correct`() { + mockMvc + .get("/api/v1/fonts/resource/OpenDyslexic/css") + .andExpect { + status { isOk() } + header { + string(HttpHeaders.CONTENT_TYPE, "text/css") + } + content { + string( + """ + @font-face { + font-family: 'OpenDyslexic'; + src: url('OpenDyslexic-Bold-Italic.woff') format('woff'),url('OpenDyslexic-Bold-Italic.woff2') format('woff2'); + font-weight: bold; + font-style: italic; + } + + @font-face { + font-family: 'OpenDyslexic'; + src: url('OpenDyslexic-Bold.woff') format('woff'),url('OpenDyslexic-Bold.woff2') format('woff2'); + font-weight: bold; + font-style: normal; + } + + @font-face { + font-family: 'OpenDyslexic'; + src: url('OpenDyslexic-Italic.woff') format('woff'),url('OpenDyslexic-Italic.woff2') format('woff2'); + font-weight: normal; + font-style: italic; + } + + @font-face { + font-family: 'OpenDyslexic'; + src: url('OpenDyslexic-Regular.woff') format('woff'),url('OpenDyslexic-Regular.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + } + + """.trimIndent(), + ) + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/LibraryControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/LibraryControllerTest.kt new file mode 100644 index 000000000..decbe055e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/LibraryControllerTest.kt @@ -0,0 +1,202 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.LibraryRepository +import org.hamcrest.Matchers +import org.hamcrest.Matchers.hasItems +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import java.nio.file.Path + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class LibraryControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val libraryRepository: LibraryRepository, +) { + private val route = "/api/v1/libraries" + + private val library = makeLibrary(path = "file:/library1", id = "1") + + @BeforeEach + fun `setup library`() { + libraryRepository.insert(library) + } + + @AfterEach + fun `teardown library`() { + libraryRepository.deleteAll() + } + + @Nested + inner class AnonymousUser { + @Test + @WithAnonymousUser + fun `given anonymous user when getAll then return unauthorized`() { + mockMvc + .get(route) + .andExpect { status { isUnauthorized() } } + } + + @Test + @WithAnonymousUser + fun `given anonymous user when addOne then return unauthorized`() { + // language=JSON + val jsonString = """{"name":"test", "root": "C:\\Temp"}""" + + mockMvc + .post(route) { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { status { isUnauthorized() } } + } + } + + @Nested + inner class UserRoles { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getAll then return ok`() { + mockMvc + .get(route) + .andExpect { status { isOk() } } + } + + @Test + @WithMockUser + fun `given user with USER role when addOne then return forbidden`() { + // language=JSON + val jsonString = """{"name":"test", "root": "C:\\Temp"}""" + + mockMvc + .post(route) { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { status { isForbidden() } } + } + } + + @Nested + inner class LimitedUser { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getAll then only gets this library`() { + mockMvc + .get(route) + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].id") { value(1) } + } + } + } + + @Nested + inner class DtoRootSanitization { + @Test + @WithMockCustomUser + fun `given regular user when getting libraries then root is hidden`() { + mockMvc + .get(route) + .andExpect { + status { isOk() } + jsonPath("$[0].root") { value("") } + } + + mockMvc + .get("$route/${library.id}") + .andExpect { + status { isOk() } + jsonPath("$.root") { value("") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when getting books then root is available`() { + mockMvc + .get(route) + .andExpect { + status { isOk() } + jsonPath("$[0].root") { value(Matchers.containsString("library1")) } + } + + mockMvc + .get("$route/${library.id}") + .andExpect { + status { isOk() } + jsonPath("$.root") { value(Matchers.containsString("library1")) } + } + } + } + + @Nested + inner class DirectoryExclusions { + @Test + @WithMockCustomUser + fun `given library with exclusions when getting libraries then exclusions are present`() { + libraryRepository.update(library.copy(scanDirectoryExclusions = setOf("test", "value"))) + + mockMvc + .get(route) + .andExpect { + status { isOk() } + jsonPath("$[0].scanDirectoryExclusions.length()") { value(2) } + jsonPath("$[0].scanDirectoryExclusions") { hasItems("test", "value") } + } + + mockMvc + .get("$route/${library.id}") + .andExpect { + status { isOk() } + jsonPath("$.scanDirectoryExclusions.length()") { value(2) } + jsonPath("$.scanDirectoryExclusions") { hasItems("test", "value") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given library with exclusions when updating library then exclusions are updated`( + @TempDir tmp: Path, + ) { + libraryRepository.update(library.copy(root = tmp.toUri().toURL(), scanDirectoryExclusions = setOf("test", "value"))) + + // language=JSON + val jsonString = + """ + { + "scanDirectoryExclusions": ["updated"] + } + """.trimIndent() + + mockMvc + .patch("$route/${library.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("$route/${library.id}") + .andExpect { + status { isOk() } + jsonPath("$.scanDirectoryExclusions.length()") { value(1) } + jsonPath("$.scanDirectoryExclusions") { hasItems("updated") } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt new file mode 100644 index 000000000..240adba80 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt @@ -0,0 +1,67 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.test.context.support.TestExecutionEvent +import org.springframework.security.test.context.support.WithSecurityContext +import org.springframework.security.test.context.support.WithSecurityContextFactory + +@Retention(AnnotationRetention.RUNTIME) +@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class, setupBefore = TestExecutionEvent.TEST_EXECUTION) +annotation class WithMockCustomUser( + val email: String = "user@example.org", + val roles: Array = ["PAGE_STREAMING", "FILE_DOWNLOAD", "KOBO_SYNC"], + val sharedAllLibraries: Boolean = true, + val sharedLibraries: Array = [], + val id: String = "0", + val allowAgeUnder: Int = -1, + val excludeAgeOver: Int = -1, + val allowLabels: Array = [], + val excludeLabels: Array = [], + val apiKey: String = "", +) + +class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory { + override fun createSecurityContext(customUser: WithMockCustomUser): SecurityContext { + val context = SecurityContextHolder.createEmptyContext() + + val principal = + KomgaPrincipal( + KomgaUser( + email = customUser.email, + password = "", + roles = UserRoles.valuesOf(customUser.roles.toList()), + sharedLibrariesIds = customUser.sharedLibraries.toSet(), + sharedAllLibraries = customUser.sharedAllLibraries, + restrictions = + ContentRestrictions( + ageRestriction = + if (customUser.allowAgeUnder >= 0) + AgeRestriction(customUser.allowAgeUnder, AllowExclude.ALLOW_ONLY) + else if (customUser.excludeAgeOver >= 0) + AgeRestriction(customUser.excludeAgeOver, AllowExclude.EXCLUDE) + else + null, + labelsAllow = customUser.allowLabels.toSet(), + labelsExclude = customUser.excludeLabels.toSet(), + ), + id = customUser.id, + ), + ) + val auth = + if (customUser.apiKey.isNotEmpty()) + ApiKeyAuthenticationToken.authenticated(customUser.apiKey, customUser.apiKey, principal.authorities) + else + UsernamePasswordAuthenticationToken(principal, "", principal.authorities) + context.authentication = auth + return context + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2ControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2ControllerTest.kt new file mode 100644 index 000000000..8d22c7e4b --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/OAuth2ControllerTest.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@ActiveProfiles("test") +class OAuth2ControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @Test + @WithAnonymousUser + fun `given anonymous user when getting oauth2 providers then returns OK`() { + mockMvc + .get("/api/v1/oauth2/providers") + .andExpect { + status { isOk() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt new file mode 100644 index 000000000..fea984947 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt @@ -0,0 +1,1473 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.ReadListLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.language.toIndexedMap +import org.hamcrest.Matchers +import org.hamcrest.Matchers.contains +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.multipart +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.time.LocalDate + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class ReadListControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val readListLifecycle: ReadListLifecycle, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, +) { + private val library1 = makeLibrary("Library1", id = "1") + private val library2 = makeLibrary("Library2", id = "2") + private val seriesLib1 = makeSeries("Series1", library1.id) + private val seriesLib2 = makeSeries("Series2", library2.id) + private lateinit var booksLibrary1: List + private lateinit var booksLibrary2: List + private lateinit var rlLib1: ReadList + private lateinit var rlLib2: ReadList + private lateinit var rlLibBoth: ReadList + + @BeforeAll + fun setup() { + libraryRepository.insert(library1) + libraryRepository.insert(library2) + + seriesLifecycle.createSeries(seriesLib1) + seriesLifecycle.createSeries(seriesLib2) + + booksLibrary1 = (1..5).map { makeBook("Book_$it", libraryId = library1.id, seriesId = seriesLib1.id) } + seriesLifecycle.addBooks(seriesLib1, booksLibrary1) + + booksLibrary2 = (6..10).map { makeBook("Book_$it", libraryId = library2.id, seriesId = seriesLib2.id) } + seriesLifecycle.addBooks(seriesLib2, booksLibrary2) + } + + @AfterAll + fun teardown() { + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun clear() { + readListRepository.deleteAll() + } + + private fun makeReadLists() { + rlLib1 = + readListLifecycle.addReadList( + ReadList( + name = "Lib1", + bookIds = booksLibrary1.map { it.id }.toIndexedMap(), + ), + ) + + rlLib2 = + readListLifecycle.addReadList( + ReadList( + name = "Lib2", + bookIds = booksLibrary2.map { it.id }.toIndexedMap(), + ), + ) + + rlLibBoth = + readListLifecycle.addReadList( + ReadList( + name = "Lib1+2", + bookIds = (booksLibrary1 + booksLibrary2).map { it.id }.toIndexedMap(), + ), + ) + } + + @Nested + inner class GetAndFilter { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting read lists then get all read lists`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(3) } + jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib2')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting read lists then only get read lists from this library`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting single read list then it is not filtered`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(10) } + jsonPath("$.filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting single read list with items from 2 libraries then it is filtered`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(5) } + jsonPath("$.filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting single read list from another library then return not found`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLib2.id}") + .andExpect { + status { isNotFound() } + } + } + } + + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting read lists then only get read lists that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library1.id) + makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book = makeBook("book", libraryId = library1.id) + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + val rlAllowed = + readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(book10.id).toIndexedMap(), + ), + ) + + val rlFiltered = + readListLifecycle.addReadList( + ReadList( + name = "Filtered", + bookIds = listOf(book10.id, book.id).toIndexedMap(), + ), + ) + + val rlDenied = + readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(book.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/books/${book10.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(1) } + jsonPath("$.content[0].id") { value(book10.id) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book10.id}/previous") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book10.id}/next") + .andExpect { + status { isNotFound() } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting read lists then only get read lists that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library1.id) + makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book18 = makeBook("1", libraryId = library1.id) + makeSeries(name = "series_18", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book18) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val book16 = makeBook("1", libraryId = library1.id) + makeSeries(name = "series_16", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book16) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val book = makeBook("book", libraryId = library1.id) + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + bookMetadataRepository.findById(book10.id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2000, 1, 1))) } + bookMetadataRepository.findById(book.id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2001, 1, 1))) } + bookMetadataRepository.findById(book16.id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2002, 1, 1))) } + bookMetadataRepository.findById(book18.id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2003, 1, 1))) } + + val rlAllowed = + readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(book10.id, book.id).toIndexedMap(), + ), + ) + + val rlFiltered = + readListLifecycle.addReadList( + ReadList( + name = "Filtered", + ordered = false, + bookIds = listOf(book10.id, book.id, book16.id, book18.id).toIndexedMap(), + ), + ) + + val rlDenied = + readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(book16.id, book18.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/books/${book10.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content.[*].['id']") { contains(listOf(book10, book).map { it.id }) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book10.id}/previous") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book10.id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(book.id) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book.id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(book10.id) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}/books/${book.id}/next") + .andExpect { + status { isNotFound() } + } + } + + @Test + @WithMockCustomUser(excludeLabels = ["kids", "cute"]) + fun `given user disallowed content with specific labels when getting read lists then only get read lists that satisfies this criteria`() { + val bookKids = makeBook("book_kids", libraryId = library1.id) + makeSeries(name = "series_kids", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookKids) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val bookCute = makeBook("1", libraryId = library1.id) + makeSeries(name = "series_cute", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookCute) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val bookAdult = makeBook("1", libraryId = library1.id) + makeSeries(name = "series_adult", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookAdult) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val book = makeBook("book", libraryId = library1.id) + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + val rlAllowed = + readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(bookAdult.id, book.id).toIndexedMap(), + ), + ) + + val rlFiltered = + readListLifecycle.addReadList( + ReadList( + name = "Filtered", + bookIds = listOf(bookKids.id, book.id, bookAdult.id, bookCute.id).toIndexedMap(), + ), + ) + + val rlDenied = + readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(bookKids.id, bookCute.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/books/${bookAdult.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(allowAgeUnder = 10, allowLabels = ["kids"], excludeLabels = ["adult", "teen"]) + fun `given user allowed and disallowed content when getting read lists then only get read lists that satisfies this criteria`() { + val bookKids = makeBook("book_kids", libraryId = library1.id) + makeSeries(name = "series_kids", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookKids) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val bookCute = makeBook("book_cute", libraryId = library1.id) + makeSeries(name = "series_cute", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookCute) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5, sharingLabels = setOf("cute", "other"))) + } + } + + val bookAdult = makeBook("book_adult", libraryId = library1.id) + makeSeries(name = "series_adult", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookAdult) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val book = makeBook("book", libraryId = library1.id) + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + val rlAllowed = + readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(bookKids.id, bookCute.id).toIndexedMap(), + ), + ) + + val rlFiltered = + readListLifecycle.addReadList( + ReadList( + name = "Filtered", + bookIds = listOf(bookKids.id, book.id, bookAdult.id, bookCute.id).toIndexedMap(), + ), + ) + + val rlDenied = + readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(bookAdult.id, book.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/books/${bookKids.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16, allowLabels = ["teen"]) + fun `given user allowed and disallowed content when getting read lists then only get read lists that satisfies this criteria (2)`() { + val bookTeen16 = makeBook("book_teen_16", libraryId = library1.id) + makeSeries(name = "series_teen_16", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookTeen16) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"), ageRating = 16)) + } + } + + val bookTeen = makeBook("1", libraryId = library1.id) + makeSeries(name = "series_teen", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(bookTeen) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"))) + } + } + + val rlAllowed = + readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(bookTeen.id).toIndexedMap(), + ), + ) + + val rlFiltered = + readListLifecycle.addReadList( + ReadList( + name = "Filtered", + bookIds = listOf(bookTeen16.id, bookTeen.id).toIndexedMap(), + ), + ) + + val rlDenied = + readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(bookTeen16.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/books/${bookTeen.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + } + } + + @Nested + inner class GetBooksAndFilter { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting books from single read list then it is not filtered`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(10) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting books from single read list with items from 2 libraries then it is filtered`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(5) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting books from single read list from another library then return not found`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLib2.id}/books") + .andExpect { + status { isNotFound() } + } + } + } + + @Nested + inner class Siblings { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting book siblings then it is returned or not found`() { + makeReadLists() + + val first = booksLibrary1.first().id // Book_1 + val second = booksLibrary1[1].id // Book_2 + val last = booksLibrary2.last().id // Book_10 + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$first/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$first/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_2") } + } + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$second/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_1") } + } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$second/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_3") } + } + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$last/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_9") } + } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$last/next") + .andExpect { status { isNotFound() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting book siblings then it takes into account the library filter`() { + makeReadLists() + + val first = booksLibrary1.first().id // Book_1 + val second = booksLibrary1[1].id // Book_2 + val last = booksLibrary1.last().id // Book_5 + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$first/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$first/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_2") } + } + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$second/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_1") } + } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$second/next") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_3") } + } + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$last/previous") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Book_4") } + } + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}/books/$last/next") + .andExpect { status { isNotFound() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting books from single read list from another library then return not found`() { + makeReadLists() + + mockMvc + .get("/api/v1/readlists/${rlLib2.id}/books") + .andExpect { + status { isNotFound() } + } + } + } + + @Nested + inner class Creation { + @Test + @WithMockCustomUser + fun `given non-admin user when creating read list then return forbidden`() { + // language=JSON + val jsonString = + """ + {"name":"readlist","bookIds":["3"]} + """.trimIndent() + + mockMvc + .post("/api/v1/readlists") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when creating read list then return ok`() { + // language=JSON + val jsonString = + """ + {"name":"readlist","summary":"summary","bookIds":["${booksLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/readlists") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.name") { value("readlist") } + jsonPath("$.summary") { value("summary") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing read lists when creating read list with existing name then return bad request`() { + makeReadLists() + + // language=JSON + val jsonString = + """ + {"name":"Lib1","bookIds":["${booksLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/readlists") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given read list with duplicate bookIds when creating read list then return bad request`() { + // language=JSON + val jsonString = + """ + {"name":"Lib1","bookIds":["${booksLibrary1.first().id}","${booksLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/readlists") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + } + + @Nested + inner class Update { + @Test + @WithMockCustomUser + fun `given non-admin user when updating read list then return forbidden`() { + // language=JSON + val jsonString = + """ + {"name":"readlist","bookIds":["3"]} + """.trimIndent() + + mockMvc + .patch("/api/v1/readlists/5") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating read list then return no content`() { + makeReadLists() + + // language=JSON + val jsonString = + """ + {"name":"updated","summary":"updatedSummary","bookIds":["${booksLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .patch("/api/v1/readlists/${rlLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/readlists/${rlLib1.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("updated") } + jsonPath("$.summary") { value("updatedSummary") } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing read lists when updating read list with existing name then return bad request`() { + makeReadLists() + + // language=JSON + val jsonString = """{"name":"Lib2"}""" + + mockMvc + .patch("/api/v1/readlists/${rlLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing read list when updating read list with duplicate bookIds then return bad request`() { + makeReadLists() + + // language=JSON + val jsonString = """{"bookIds":["${booksLibrary1.first().id}","${booksLibrary1.first().id}"]}""" + + mockMvc + .patch("/api/v1/readlists/${rlLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating read list then only updated fields are modified`() { + makeReadLists() + + mockMvc.patch("/api/v1/readlists/${rlLib2.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"newName"}""" + } + + mockMvc + .get("/api/v1/readlists/${rlLib2.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("newName") } + jsonPath("$.summary") { value("") } + jsonPath("$.bookIds.length()") { value(5) } + } + + mockMvc.patch("/api/v1/readlists/${rlLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"summary":"newSummary"}""" + } + + mockMvc + .get("/api/v1/readlists/${rlLib1.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Lib1") } + jsonPath("$.summary") { value("newSummary") } + jsonPath("$.bookIds.length()") { value(5) } + } + + mockMvc.patch("/api/v1/readlists/${rlLibBoth.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"bookIds":["${booksLibrary1.first().id}"]}""" + } + + mockMvc + .get("/api/v1/readlists/${rlLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Lib1+2") } + jsonPath("$.summary") { value("") } + jsonPath("$.bookIds.length()") { value(1) } + } + } + } + + @Nested + inner class Delete { + @Test + @WithMockCustomUser + fun `given non-admin user when deleting read list then return forbidden`() { + mockMvc + .delete("/api/v1/readlists/5") + .andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when deleting read list then return no content`() { + makeReadLists() + + mockMvc + .delete("/api/v1/readlists/${rlLib1.id}") + .andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/readlists/${rlLib1.id}") + .andExpect { + status { isNotFound() } + } + } + } + + @Nested + inner class FileDownload { + @Test + @WithMockCustomUser + fun `given readlist with Unicode name when getting readlist file then attachment name is correct`() { + val name = "アキラ" + val tempFile = + Files + .createTempFile(name, ".cbz") + .also { it.toFile().deleteOnExit() } + val book = makeBook(name, libraryId = library1.id, url = tempFile.toUri().toURL()) + makeSeries(name = "series", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + val readlist = + readListLifecycle.addReadList( + ReadList( + name = name, + bookIds = listOf(book.id).toIndexedMap(), + ), + ) + + mockMvc + .get("/api/v1/readlists/${readlist.id}/file") + .andExpect { + status { isOk() } + header { string("Content-Disposition", Matchers.containsString(URLEncoder.encode(name, StandardCharsets.UTF_8.name()))) } + } + } + } + + @Nested + inner class Unordered { + private val library = makeLibrary("Library") + private val series = makeSeries("Series", library.id) + private lateinit var books: List + private lateinit var rlAllDiffDates: ReadList + private lateinit var rlAllNullDates: ReadList + private lateinit var rlAllBooks: ReadList + + @BeforeAll + fun setup() { + libraryRepository.insert(library) + + seriesLifecycle.createSeries(series) + + books = (1..5).map { makeBook("Book_$it", libraryId = library.id, seriesId = series.id) } + seriesLifecycle.addBooks(series, books) + + bookMetadataRepository.findById(books[0].id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2020, 1, 1))) } + bookMetadataRepository.findById(books[1].id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2020, 1, 1))) } + bookMetadataRepository.findById(books[2].id).let { bookMetadataRepository.update(it.copy(releaseDate = LocalDate.of(2021, 1, 1))) } + } + + @BeforeEach + fun makeReadLists() { + rlAllDiffDates = + readListLifecycle.addReadList( + ReadList( + name = "All different dates", + ordered = false, + bookIds = listOf(2, 1).map { books[it].id }.toIndexedMap(), + ), + ) + + rlAllNullDates = + readListLifecycle.addReadList( + ReadList( + name = "All null dates", + ordered = false, + bookIds = books.drop(3).map { it.id }.toIndexedMap(), + ), + ) + + rlAllBooks = + readListLifecycle.addReadList( + ReadList( + name = "All books", + ordered = false, + bookIds = books.map { it.id }.toIndexedMap(), + ), + ) + } + + @Test + @WithMockCustomUser + fun `given unordered read lists when getting books then books are sorted by release date`() { + mockMvc + .get("/api/v1/readlists/${rlAllDiffDates.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.[*].['id']") { contains(listOf(1, 2).map { books[it].id }) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllNullDates.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.[*].['id']") { contains(listOf(3, 4).map { books[it].id }) } + } + + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content.[*].['id']") { contains(listOf(3, 4, 0, 1, 2).map { books[it].id }) } + } + } + + @Test + @WithMockCustomUser + fun `given unordered read lists when getting book siblings then it is returned according to release date sort or not found`() { + // rlAllDiffDates: 1, 2 + // first book: id=1 + mockMvc + .get("/api/v1/readlists/${rlAllDiffDates.id}/books/${books[1].id}/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/readlists/${rlAllDiffDates.id}/books/${books[1].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[2].id) } + } + + // second book: id=2 + mockMvc + .get("/api/v1/readlists/${rlAllDiffDates.id}/books/${books[2].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[1].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllDiffDates.id}/books/${books[2].id}/next") + .andExpect { status { isNotFound() } } + + // rlAllNullDates: 3, 4 + // first book: id=3 + mockMvc + .get("/api/v1/readlists/${rlAllNullDates.id}/books/${books[3].id}/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/readlists/${rlAllNullDates.id}/books/${books[3].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[4].id) } + } + + // second book: id=4 + mockMvc + .get("/api/v1/readlists/${rlAllNullDates.id}/books/${books[4].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[3].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllNullDates.id}/books/${books[4].id}/next") + .andExpect { status { isNotFound() } } + + // rlAllBooks: 3, 4, 0, 1, 2 + // first book: id=3 + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[3].id}/previous") + .andExpect { status { isNotFound() } } + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[3].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[4].id) } + } + + // second book: id=4 + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[4].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[3].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[4].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[0].id) } + } + + // third book: id=0 + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[0].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[4].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[0].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[1].id) } + } + + // fourth book: id=1 + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[1].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[0].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[1].id}/next") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[2].id) } + } + + // last book: id=2 + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[2].id}/previous") + .andExpect { + status { isOk() } + jsonPath("$.id") { value(books[1].id) } + } + mockMvc + .get("/api/v1/readlists/${rlAllBooks.id}/books/${books[2].id}/next") + .andExpect { status { isNotFound() } } + } + } + + @Nested + inner class Match { + @Test + @WithMockCustomUser + fun `given non-admin user when matching cbl file then return forbidden`() { + mockMvc + .multipart("/api/v1/readlists/match/comicrack") { + file("file", byteArrayOf()) + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given invalid cbl file when matching then return bad request`() { + val content = "garbled" + + mockMvc + .multipart("/api/v1/readlists/match/comicrack") { + file("file", content.toByteArray()) + }.andExpect { + status { + isBadRequest() + reason("ERR_1015") + } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given cbl file without books when matching then return bad request`() { + val content = + """ + + + RL + + + + """.trimIndent() + + mockMvc + .multipart("/api/v1/readlists/match/comicrack") { + file("file", content.toByteArray()) + }.andExpect { + status { + isBadRequest() + reason("ERR_1029") + } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given cbl file without name when matching then return bad request`() { + val content = + """ + + + + + + + + """.trimIndent() + + mockMvc + .multipart("/api/v1/readlists/match/comicrack") { + file("file", content.toByteArray()) + }.andExpect { + status { + isBadRequest() + reason("ERR_1030") + } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given cbl file with book without series when matching then return bad request`() { + val content = + """ + + + RL + + + + + """.trimIndent() + + mockMvc + .multipart("/api/v1/readlists/match/comicrack") { + file("file", content.toByteArray()) + }.andExpect { + status { + isBadRequest() + reason("ERR_1031") + } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt new file mode 100644 index 000000000..4240d0812 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt @@ -0,0 +1,1043 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesCollectionLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class SeriesCollectionControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val collectionLifecycle: SeriesCollectionLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, +) { + private val library1 = makeLibrary("Library1", id = "1") + private val library2 = makeLibrary("Library2", id = "2") + private lateinit var seriesLibrary1: List + private lateinit var seriesLibrary2: List + private lateinit var colLib1: SeriesCollection + private lateinit var colLib2: SeriesCollection + private lateinit var colLibBoth: SeriesCollection + + @BeforeAll + fun setup() { + libraryRepository.insert(library1) + libraryRepository.insert(library2) + + seriesLibrary1 = + (1..5) + .map { makeSeries("Series_$it", library1.id) } + .map { seriesLifecycle.createSeries(it) } + + seriesLibrary2 = + (6..10) + .map { makeSeries("Series_$it", library2.id) } + .map { seriesLifecycle.createSeries(it) } + } + + @AfterAll + fun teardown() { + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun clear() { + collectionRepository.deleteAll() + } + + private fun makeCollections() { + colLib1 = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Lib1", + seriesIds = seriesLibrary1.map { it.id }, + ), + ) + + colLib2 = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Lib2", + seriesIds = seriesLibrary2.map { it.id }, + ), + ) + + colLibBoth = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Lib1+2", + seriesIds = (seriesLibrary1 + seriesLibrary2).map { it.id }, + ordered = true, + ), + ) + } + + @Nested + inner class GetAndFilter { + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting collections then get all collections`() { + makeCollections() + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(3) } + jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib2')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting collections then only get collections from this library`() { + makeCollections() + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) } + jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser + fun `given user with access to all libraries when getting single collection then it is not filtered`() { + makeCollections() + + mockMvc + .get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(10) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colLibBoth.id}/series") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(10) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting single collection with items from 2 libraries then it is filtered`() { + makeCollections() + + mockMvc + .get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(5) } + jsonPath("$.filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting single collection from another library then return not found`() { + makeCollections() + + mockMvc + .get("/api/v1/collections/${colLib2.id}") + .andExpect { + status { isNotFound() } + } + } + } + + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting collections then only get collections that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(series10.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(series10.id, series.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(series.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${series10.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting collections then only gets collections that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series18 = + makeSeries(name = "series_18", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val series16 = + makeSeries(name = "series_16", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(series10.id, series.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(series10.id, series16.id, series18.id, series.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(series16.id, series18.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${series10.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(allowLabels = ["kids", "cute"]) + fun `given user allowed only content with specific labels when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(seriesKids.id, seriesCute.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(series.id, seriesKids.id, seriesCute.id, seriesAdult.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(seriesAdult.id, series.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${seriesKids.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(excludeLabels = ["kids", "cute"]) + fun `given user disallowed content with specific labels when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(seriesAdult.id, series.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(seriesAdult.id, seriesCute.id, seriesKids.id, series.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(seriesKids.id, seriesCute.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${series.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(allowAgeUnder = 10, allowLabels = ["kids"], excludeLabels = ["adult", "teen"]) + fun `given user allowed and disallowed content when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5, sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(seriesKids.id, seriesCute.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(series.id, seriesKids.id, seriesCute.id, seriesAdult.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(seriesAdult.id, series.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(2) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${seriesKids.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16, allowLabels = ["teen"]) + fun `given user allowed and disallowed content when getting series then only gets series that satisfies this criteria (2)`() { + val seriesTeen16 = + makeSeries(name = "series_teen_16", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"), ageRating = 16)) + } + } + + val seriesTeen = + makeSeries(name = "series_teen", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"))) + } + } + + val colAllowed = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(seriesTeen.id), + ), + ) + + val colFiltered = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(seriesTeen16.id, seriesTeen.id), + ), + ) + + val colDenied = + collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(seriesTeen16.id), + ), + ) + + mockMvc + .get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc + .get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc + .get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc + .get("/api/v1/series/${seriesTeen.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + } + + @Nested + inner class Creation { + @Test + @WithMockCustomUser + fun `given non-admin user when creating collection then return forbidden`() { + // language=JSON + val jsonString = + """ + {"name":"collection","ordered":false,"seriesIds":["3"]} + """.trimIndent() + + mockMvc + .post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when creating collection then return ok`() { + // language=JSON + val jsonString = + """ + {"name":"collection","ordered":false,"seriesIds":["${seriesLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.name") { value("collection") } + jsonPath("$.ordered") { value(false) } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing collections when creating collection with existing name then return bad request`() { + makeCollections() + + // language=JSON + val jsonString = + """ + {"name":"Lib1","ordered":false,"seriesIds":["${seriesLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given collection with duplicate seriesIds when creating collection then return bad request`() { + // language=JSON + val jsonString = + """ + {"name":"Lib1","ordered":false,"seriesIds":["${seriesLibrary1.first().id}","${seriesLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .post("/api/v1/collections") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + } + + @Nested + inner class Update { + @Test + @WithMockCustomUser + fun `given non-admin user when updating collection then return forbidden`() { + // language=JSON + val jsonString = + """ + {"name":"collection","ordered":false,"seriesIds":["3"]} + """.trimIndent() + + mockMvc + .patch("/api/v1/collections/5") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating collection then return no content`() { + makeCollections() + + // language=JSON + val jsonString = + """ + {"name":"updated","ordered":true,"seriesIds":["${seriesLibrary1.first().id}"]} + """.trimIndent() + + mockMvc + .patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("updated") } + jsonPath("$.ordered") { value(true) } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing collections when updating collection with existing name then return bad request`() { + makeCollections() + + // language=JSON + val jsonString = """{"name":"Lib2"}""" + + mockMvc + .patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given existing collection when updating collection with duplicate seriesIds then return bad request`() { + makeCollections() + + // language=JSON + val jsonString = """{"seriesIds":["${seriesLibrary1.first().id}","${seriesLibrary1.first().id}"]}""" + + mockMvc + .patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating collection then only updated fields are modified`() { + makeCollections() + + mockMvc.patch("/api/v1/collections/${colLib1.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"ordered":true}""" + } + + mockMvc + .get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Lib1") } + jsonPath("$.ordered") { value(true) } + jsonPath("$.seriesIds.length()") { value(5) } + } + + mockMvc.patch("/api/v1/collections/${colLib2.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"newName"}""" + } + + mockMvc + .get("/api/v1/collections/${colLib2.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("newName") } + jsonPath("$.ordered") { value(false) } + jsonPath("$.seriesIds.length()") { value(5) } + } + + mockMvc.patch("/api/v1/collections/${colLibBoth.id}") { + contentType = MediaType.APPLICATION_JSON + content = """{"seriesIds":["${seriesLibrary1.first().id}"]}""" + } + + mockMvc + .get("/api/v1/collections/${colLibBoth.id}") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("Lib1+2") } + jsonPath("$.ordered") { value(true) } + jsonPath("$.seriesIds.length()") { value(1) } + } + } + } + + @Nested + inner class Delete { + @Test + @WithMockCustomUser + fun `given non-admin user when deleting collection then return forbidden`() { + mockMvc + .delete("/api/v1/collections/5") + .andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when deleting collection then return no content`() { + makeCollections() + + mockMvc + .delete("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/collections/${colLib1.id}") + .andExpect { + status { isNotFound() } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt new file mode 100644 index 000000000..9b9dfe2d2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt @@ -0,0 +1,1227 @@ +package org.gotson.komga.interfaces.api.rest + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Dimension +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.FileSystemScanner +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryContentLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.toScanResult +import org.hamcrest.Matchers +import org.hamcrest.core.IsNull +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MockMvcResultMatchersDsl +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import kotlin.random.Random + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class SeriesControllerTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val libraryContentLifecycle: LibraryContentLifecycle, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val mockMvc: MockMvc, +) { + @MockkBean + private lateinit var mockScanner: FileSystemScanner + + private val library = makeLibrary(id = "1") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + userRepository.insert(KomgaUser("user@example.org", "", id = "1")) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Nested + inner class Search { + @Test + @WithMockCustomUser + fun `given series when searching by regex then series are found`() { + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) + } + seriesLifecycle.createSeries(makeSeries("TheBeta", libraryId = library.id)) + + mockMvc + .get("/api/v1/series") { + param("search_regex", "a$,title_sort") + }.andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(1) } + jsonPath("$.content[0].metadata.title") { value("TheBeta") } + } + + mockMvc + .get("/api/v1/series") { + param("search_regex", "^the,title") + }.andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].metadata.title") { value("TheAlpha") } + jsonPath("$.content[1].metadata.title") { value("TheBeta") } + } + } + } + + @Nested + inner class SeriesSort { + @Test + @WithMockCustomUser + fun `given series with titleSort when requesting via api then series are sorted by titleSort`() { + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) + } + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) + + mockMvc + .get("/api/v1/series") + .andExpect { + status { isOk() } + jsonPath("$.content[0].metadata.title") { value("TheAlpha") } + jsonPath("$.content[1].metadata.title") { value("Beta") } + } + } + + @Test + @WithMockCustomUser + fun `given series when requesting via api then series are sorted insensitive of case`() { + listOf("a", "b", "B", "C") + .map { name -> makeSeries(name, libraryId = library.id) } + .forEach { + seriesLifecycle.createSeries(it) + } + + mockMvc + .get("/api/v1/series") { + param("sort", "metadata.titleSort,asc") + }.andExpect { + status { isOk() } + jsonPath("$.content[0].metadata.title") { value("a") } + jsonPath("$.content[1].metadata.title") { value(Matchers.equalToIgnoringCase("b")) } + jsonPath("$.content[2].metadata.title") { value(Matchers.equalToIgnoringCase("b")) } + jsonPath("$.content[3].metadata.title") { value("C") } + } + } + } + + @Nested + inner class BookOrdering { + @Test + @WithMockCustomUser + fun `given books with unordered index when requesting via api then books are ordered`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + mockMvc + .get("/api/v1/series/${createdSeries.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content[0].name") { value("1") } + jsonPath("$.content[1].name") { value("2") } + jsonPath("$.content[2].name") { value("3") } + } + } + + @Test + @WithMockCustomUser + fun `given many books with unordered index when requesting via api then books are ordered and paged`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = (1..100 step 2).map { makeBook("$it", libraryId = library.id) } + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + mockMvc + .get("/api/v1/series/${createdSeries.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content[0].name") { value("1") } + jsonPath("$.content[1].name") { value("2") } + jsonPath("$.content[2].name") { value("3") } + jsonPath("$.content[3].name") { value("5") } + jsonPath("$.size") { value(20) } + jsonPath("$.first") { value(true) } + jsonPath("$.number") { value(0) } + } + } + } + + @Nested + inner class LimitedUser { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting series then only gets series from this library`() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val otherLibrary = makeLibrary("other") + libraryRepository.insert(otherLibrary) + makeSeries(name = "otherSeries", libraryId = otherLibrary.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("2", libraryId = otherLibrary.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(1) } + jsonPath("$.content[0].name") { value("series") } + } + } + } + + @Nested + inner class ContentRestrictedUser { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series5 = + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val series15 = + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${series5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/series?sort=metadata.titleSort") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value("series_10") } + jsonPath("$.content[1].name") { value("series_5") } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series18 = + makeSeries(name = "series_18", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val series16 = + makeSeries(name = "series_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series16.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series18.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/series?sort=metadata.titleSort") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value("series_10") } + jsonPath("$.content[1].name") { value("series_no") } + } + } + + @Test + @WithMockCustomUser(allowLabels = ["kids", "cute"]) + fun `given user allowed only content with specific labels when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${seriesKids.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${seriesCute.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${seriesAdult.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/series?sort=metadata.titleSort") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(seriesCute.name) } + jsonPath("$.content[1].name") { value(seriesKids.name) } + } + } + + @Test + @WithMockCustomUser(excludeLabels = ["kids", "cute"]) + fun `given user disallowed content with specific labels when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${seriesKids.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${seriesCute.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${seriesAdult.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isOk() } } + + mockMvc + .get("/api/v1/series?sort=metadata.titleSort") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(seriesAdult.name) } + jsonPath("$.content[1].name") { value(series.name) } + } + } + + @Test + @WithMockCustomUser(allowAgeUnder = 10, allowLabels = ["kids"], excludeLabels = ["adult", "teen"]) + fun `given user allowed and disallowed content when getting series then only gets series that satisfies this criteria`() { + val seriesKids = + makeSeries(name = "series_kids", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("kids"))) + } + } + + val seriesCute = + makeSeries(name = "series_cute", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5, sharingLabels = setOf("cute", "other"))) + } + } + + val seriesAdult = + makeSeries(name = "series_adult", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("adult"))) + } + } + + val series = + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${seriesKids.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${seriesCute.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${seriesAdult.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/series?sort=metadata.titleSort") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(seriesCute.name) } + jsonPath("$.content[1].name") { value(seriesKids.name) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16, allowLabels = ["teen"]) + fun `given user allowed and disallowed content when getting series then only gets series that satisfies this criteria (2)`() { + val seriesTeen16 = + makeSeries(name = "series_teen_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(sharingLabels = setOf("teen"), ageRating = 16)) + } + } + + mockMvc.get("/api/v1/series/${seriesTeen16.id}").andExpect { status { isForbidden() } } + + mockMvc + .get("/api/v1/series") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(0) } + } + } + } + + @Nested + inner class UserWithoutLibraryAccess { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific series then returns forbidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific series thumbnail then returns forbidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}/thumbnail") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific series books then returns forbidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}/books") + .andExpect { status { isForbidden() } } + } + + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) + fun `given user with no access to any library when getting specific series file then returns forbidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}/file") + .andExpect { status { isForbidden() } } + } + } + + @Nested + inner class RestrictedUserByRole { + @Test + @WithMockCustomUser(roles = []) + fun `given user without file download role when getting specific series file then returns forbidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}/file") + .andExpect { status { isForbidden() } } + } + } + + @Nested + inner class MediaNotReady { + @Test + @WithMockCustomUser + fun `given book without thumbnail when getting series thumbnail then returns not found`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc + .get("/api/v1/series/${createdSeries.id}/thumbnail") + .andExpect { status { isNotFound() } } + } + } + + @Nested + inner class DtoUrlSanitization { + @Test + @WithMockCustomUser + fun `given regular user when getting series then url is hidden`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val validation: MockMvcResultMatchersDsl.() -> Unit = { + status { isOk() } + jsonPath("$.content[0].url") { value("") } + } + + mockMvc + .get("/api/v1/series") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/latest") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/new") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/${createdSeries.id}") + .andExpect { + status { isOk() } + jsonPath("$.url") { value("") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when getting series then url is available`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val validation: MockMvcResultMatchersDsl.() -> Unit = { + status { isOk() } + jsonPath("$.content[0].url") { value(Matchers.containsString("series")) } + } + + mockMvc + .get("/api/v1/series") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/latest") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/new") + .andExpect(validation) + + mockMvc + .get("/api/v1/series/${createdSeries.id}") + .andExpect { + status { isOk() } + jsonPath("$.url") { value(Matchers.containsString("series")) } + } + } + } + + @Nested + inner class MetadataUpdate { + @Test + @WithMockCustomUser + fun `given non-admin user when updating metadata then raise forbidden`() { + mockMvc + .patch("/api/v1/series/1/metadata") { + contentType = MediaType.APPLICATION_JSON + content = "{}" + }.andExpect { + status { isForbidden() } + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + """{"title":""}""", + """{"titleSort":""}""", + """{"ageRating":-1}""", + """{"totalBookCount":0}""", + """{"language":"japanese"}""", + ], + ) + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given invalid json when updating metadata then raise validation error`(jsonString: String) { + mockMvc + .patch("/api/v1/series/1/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given valid json when updating metadata then fields are updated`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + // language=JSON + val jsonString = + """ + { + "title":"newTitle", + "titleLock":true, + "titleSort":"newTitleSort", + "titleSortLock":true, + "status":"HIATUS", + "statusLock":true, + "summary":"newSummary", + "summaryLock":true, + "readingDirection":"LEFT_TO_RIGHT", + "readingDirectionLock":true, + "ageRating":12, + "ageRatingLock":true, + "publisher":"newPublisher", + "publisherLock":true, + "language":"fra", + "languageLock":true, + "genres":["Action"], + "genresLock":true, + "tags":["tag"], + "tagsLock":true, + "totalBookCount":5, + "totalBookCountLock":true + } + """.trimIndent() + + mockMvc + .patch("/api/v1/series/${createdSeries.id}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val updatedMetadata = seriesMetadataRepository.findById(createdSeries.id) + with(updatedMetadata) { + assertThat(title).isEqualTo("newTitle") + assertThat(titleSort).isEqualTo("newTitleSort") + assertThat(status).isEqualTo(SeriesMetadata.Status.HIATUS) + assertThat(readingDirection).isEqualTo(SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT) + assertThat(publisher).isEqualTo("newPublisher") + assertThat(summary).isEqualTo("newSummary") + assertThat(language).isEqualTo("fr") + assertThat(ageRating).isEqualTo(12) + assertThat(genres).containsExactly("action") + assertThat(tags).containsExactly("tag") + assertThat(totalBookCount).isEqualTo(5) + + assertThat(titleLock).isEqualTo(true) + assertThat(titleSortLock).isEqualTo(true) + assertThat(statusLock).isEqualTo(true) + assertThat(readingDirectionLock).isEqualTo(true) + assertThat(publisherLock).isEqualTo(true) + assertThat(ageRatingLock).isEqualTo(true) + assertThat(languageLock).isEqualTo(true) + assertThat(summaryLock).isEqualTo(true) + assertThat(genresLock).isEqualTo(true) + assertThat(tagsLock).isEqualTo(true) + assertThat(totalBookCountLock).isEqualTo(true) + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given json with null fields when updating metadata then fields with null are unset`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + seriesMetadataRepository.findById(createdSeries.id).let { metadata -> + val updated = + metadata.copy( + ageRating = 12, + readingDirection = SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT, + genres = setOf("Action"), + tags = setOf("tag"), + totalBookCount = 5, + ) + + seriesMetadataRepository.update(updated) + } + + val metadata = seriesMetadataRepository.findById(createdSeries.id) + with(metadata) { + assertThat(readingDirection).isEqualTo(SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT) + assertThat(ageRating).isEqualTo(12) + assertThat(genres).hasSize(1) + } + + // language=JSON + val jsonString = + """ + { + "readingDirection":null, + "ageRating":null, + "genres":null, + "tags":null, + "totalBookCount":null + } + """.trimIndent() + + mockMvc + .patch("/api/v1/series/${createdSeries.id}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + val updatedMetadata = seriesMetadataRepository.findById(createdSeries.id) + with(updatedMetadata) { + assertThat(readingDirection).isNull() + assertThat(ageRating).isNull() + assertThat(genres).isEmpty() + assertThat(tags).isEmpty() + assertThat(totalBookCount).isNull() + } + } + } + + @Nested + inner class HttpCache { + @Test + @WithMockCustomUser + fun `given request with cache headers when getting series thumbnail then returns 304 not modified`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + bookRepository.findAll().first().let { book -> + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + thumbnail = Random.nextBytes(1), + bookId = book.id, + type = ThumbnailBook.Type.GENERATED, + fileSize = 0, + mediaType = "", + dimension = Dimension(0, 0), + ), + MarkSelectedPreference.YES, + ) + } + + val url = "/api/v1/series/${createdSeries.id}/thumbnail" + + val response = + mockMvc + .get(url) + .andReturn() + .response + + mockMvc + .get(url) { + headers { + ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + } + }.andExpect { + status { isNotModified() } + } + } + + @Test + @WithMockCustomUser + fun `given request with cache headers and modified first book when getting series thumbnail then returns 200 ok`() { + val createdSeries = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("2", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + bookRepository.findAll().forEach { book -> + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + thumbnail = Random.nextBytes(1), + bookId = book.id, + type = ThumbnailBook.Type.GENERATED, + fileSize = 0, + mediaType = "", + dimension = Dimension(0, 0), + ), + MarkSelectedPreference.YES, + ) + } + + val url = "/api/v1/series/${createdSeries.id}/thumbnail" + + val response = mockMvc.get(url).andReturn().response + + bookRepository.findAll().first { it.name == "1" }.let { book -> + bookMetadataRepository.findById(book.id).let { + bookMetadataRepository.update(it.copy(numberSort = 3F)) + } + } + + mockMvc + .get(url) { + headers { + ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + } + }.andExpect { + status { isOk() } + } + } + } + + @Nested + inner class ReadProgress { + @Test + @WithMockCustomUser(id = "1") + fun `given user when marking series as read then progress is marked for all books`() { + val series = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id), makeBook("2.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + seriesLifecycle.sortBooks(created) + } + } + + bookRepository.findAll().forEach { book -> + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + ), + ) + } + } + + mockMvc + .post("/api/v1/series/${series.id}/read-progress") + .andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/series/${series.id}") + .andExpect { + status { isOk() } + jsonPath("$.booksUnreadCount") { value(0) } + jsonPath("$.booksReadCount") { value(2) } + } + + mockMvc + .get("/api/v1/series/${series.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content[0].readProgress.completed") { value(true) } + jsonPath("$.content[1].readProgress.completed") { value(true) } + jsonPath("$.numberOfElements") { value(2) } + } + } + + @Test + @WithMockCustomUser(id = "1") + fun `given user when marking series as unread then progress is removed for all books`() { + val series = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1.cbr", libraryId = library.id), makeBook("2.cbr", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + seriesLifecycle.sortBooks(created) + } + } + + bookRepository.findAll().forEach { book -> + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + ), + ) + } + } + + mockMvc + .post("/api/v1/series/${series.id}/read-progress") + .andExpect { + status { isNoContent() } + } + + mockMvc + .delete("/api/v1/series/${series.id}/read-progress") + .andExpect { + status { isNoContent() } + } + + mockMvc + .get("/api/v1/series/${series.id}") + .andExpect { + status { isOk() } + jsonPath("$.booksUnreadCount") { value(2) } + jsonPath("$.booksReadCount") { value(0) } + } + + mockMvc + .get("/api/v1/series/${series.id}/books") + .andExpect { + status { isOk() } + jsonPath("$.content[0].readProgress") { value(IsNull.nullValue()) } + jsonPath("$.content[1].readProgress") { value(IsNull.nullValue()) } + jsonPath("$.numberOfElements") { value(2) } + } + } + + @Test + @WithMockCustomUser(id = "1") + fun `given user when marking book as in progress then progress series return books count accordingly`() { + val series = + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = + listOf( + makeBook("1.cbr", libraryId = library.id), + makeBook("2.cbr", libraryId = library.id), + makeBook("3.cbr", libraryId = library.id), + ) + seriesLifecycle.addBooks(created, books) + seriesLifecycle.sortBooks(created) + } + } + + bookRepository.findAll().forEach { book -> + mediaRepository.findById(book.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") }, + pageCount = 10, + ), + ) + } + } + + val books = bookRepository.findAll().sortedBy { it.name } + + mockMvc.patch("/api/v1/books/${books.elementAt(0).id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = """{"page": 5,"completed":false}""" + } + mockMvc.patch("/api/v1/books/${books.elementAt(1).id}/read-progress") { + contentType = MediaType.APPLICATION_JSON + content = """{"completed":true}""" + } + + mockMvc + .get("/api/v1/series/${series.id}") + .andExpect { + status { isOk() } + jsonPath("$.booksUnreadCount") { value(1) } + jsonPath("$.booksReadCount") { value(1) } + jsonPath("$.booksInProgressCount") { value(1) } + } + } + } + + @Nested + inner class FileDownload { + @Test + @WithMockCustomUser + fun `given series with Unicode name when getting series file then attachment name is correct`() { + val name = "アキラ" + val tempFile = + Files + .createTempFile(name, ".cbz") + .also { it.toFile().deleteOnExit() } + val series = + makeSeries(name = name, libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook(name, libraryId = library.id, url = tempFile.toUri().toURL())) + seriesLifecycle.addBooks(created, books) + } + series + } + + mockMvc + .get("/api/v1/series/${series.id}/file") + .andExpect { + status { isOk() } + header { string("Content-Disposition", Matchers.containsString(URLEncoder.encode(name, StandardCharsets.UTF_8.name()))) } + } + } + } + + @Nested + inner class RecentSeries { + @Test + @WithMockCustomUser + fun `given series that was just created when getting updated series then series is omitted`() { + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1").copy(fileSize = 1))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) + + mockMvc + .get("/api/v1/series/updated") + .andExpect { + status { isOk() } + jsonPath("$.content") { isEmpty() } + } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt new file mode 100644 index 000000000..3dec331ff --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt @@ -0,0 +1,197 @@ +package org.gotson.komga.interfaces.api.rest + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.ThumbnailSize +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import kotlin.time.Duration.Companion.days + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class SettingsControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val komgaSettingsProvider: KomgaSettingsProvider, +) { + @Nested + inner class NonAdminUser { + @Test + @WithAnonymousUser + fun `given anonymous user when retrieving settings then returns unauthorized`() { + mockMvc + .get("/api/v1/settings") + .andExpect { + status { isUnauthorized() } + } + } + + @Test + @WithMockCustomUser + fun `given restricted user when retrieving settings then returns forbidden`() { + mockMvc + .get("/api/v1/settings") + .andExpect { + status { isForbidden() } + } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when retrieving settings then settings are returned`() { + komgaSettingsProvider.deleteEmptyCollections = true + komgaSettingsProvider.deleteEmptyReadLists = false + komgaSettingsProvider.rememberMeDuration = 5.days + komgaSettingsProvider.thumbnailSize = ThumbnailSize.LARGE + komgaSettingsProvider.taskPoolSize = 4 + komgaSettingsProvider.serverPort = 1234 + komgaSettingsProvider.serverContextPath = "/example" + + mockMvc + .get("/api/v1/settings") + .andExpect { + status { isOk() } + jsonPath("deleteEmptyCollections") { value(true) } + jsonPath("deleteEmptyReadLists") { value(false) } + jsonPath("rememberMeDurationDays") { value(5) } + jsonPath("thumbnailSize") { value("LARGE") } + jsonPath("taskPoolSize") { value(4) } + jsonPath("serverPort.configurationSource") { value(25600) } + jsonPath("serverPort.databaseSource") { value(1234) } + jsonPath("serverPort.effectiveValue") { value(null) } // somehow we don't get the effective value with the mock server + jsonPath("serverContextPath.configurationSource") { value(null) } + jsonPath("serverContextPath.databaseSource") { value("/example") } + jsonPath("serverContextPath.effectiveValue") { value("") } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when updating settings then settings are updated`() { + komgaSettingsProvider.deleteEmptyCollections = true + komgaSettingsProvider.deleteEmptyReadLists = true + komgaSettingsProvider.rememberMeDuration = 5.days + komgaSettingsProvider.thumbnailSize = ThumbnailSize.LARGE + komgaSettingsProvider.taskPoolSize = 4 + komgaSettingsProvider.serverPort = 1234 + komgaSettingsProvider.serverContextPath = "/example" + + val rememberMeKey = komgaSettingsProvider.rememberMeKey + + //language=JSON + val jsonString = + """ + { + "deleteEmptyCollections": false, + "rememberMeDurationDays": 15, + "renewRememberMeKey": true, + "thumbnailSize": "MEDIUM", + "taskPoolSize": 8, + "serverPort": 5678, + "serverContextPath": "/komga-hyphen/subpath123" + } + """.trimIndent() + + mockMvc + .patch("/api/v1/settings") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + assertThat(komgaSettingsProvider.deleteEmptyCollections).isFalse + assertThat(komgaSettingsProvider.deleteEmptyReadLists).isTrue + assertThat(komgaSettingsProvider.rememberMeDuration).isEqualTo(15.days) + assertThat(komgaSettingsProvider.rememberMeKey).isNotEqualTo(rememberMeKey) + assertThat(komgaSettingsProvider.thumbnailSize).isEqualTo(ThumbnailSize.MEDIUM) + assertThat(komgaSettingsProvider.taskPoolSize).isEqualTo(8) + assertThat(komgaSettingsProvider.serverPort).isEqualTo(5678) + assertThat(komgaSettingsProvider.serverContextPath).isEqualTo("/komga-hyphen/subpath123") + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given admin user when deleting settings then deletable settings are deleted`() { + komgaSettingsProvider.deleteEmptyCollections = true + komgaSettingsProvider.deleteEmptyReadLists = true + komgaSettingsProvider.rememberMeDuration = 5.days + komgaSettingsProvider.thumbnailSize = ThumbnailSize.LARGE + komgaSettingsProvider.taskPoolSize = 4 + komgaSettingsProvider.serverPort = 1234 + komgaSettingsProvider.serverContextPath = "/example" + + val rememberMeKey = komgaSettingsProvider.rememberMeKey + + //language=JSON + val jsonString = + """ + { + "deleteEmptyCollections": null, + "rememberMeDurationDays": null, + "renewRememberMeKey": null, + "thumbnailSize": null, + "taskPoolSize": null, + "serverPort": null, + "serverContextPath": null + } + """.trimIndent() + + mockMvc + .patch("/api/v1/settings") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + assertThat(komgaSettingsProvider.deleteEmptyCollections).isTrue + assertThat(komgaSettingsProvider.deleteEmptyReadLists).isTrue + assertThat(komgaSettingsProvider.rememberMeDuration).isEqualTo(5.days) + assertThat(komgaSettingsProvider.rememberMeKey).isEqualTo(rememberMeKey) + assertThat(komgaSettingsProvider.thumbnailSize).isEqualTo(ThumbnailSize.LARGE) + assertThat(komgaSettingsProvider.taskPoolSize).isEqualTo(4) + assertThat(komgaSettingsProvider.serverPort).isNull() + assertThat(komgaSettingsProvider.serverContextPath).isNull() + } + + @ParameterizedTest + @WithMockCustomUser(roles = ["ADMIN"]) + @ValueSource( + strings = [ + //language=JSON + """{"rememberMeDurationDays": 0}""", + //language=JSON + """{"thumbnailSize": "HUGE"}""", + """{"taskPoolSize": 0}""", + """{"serverPort": 0}""", + """{"serverPort": -5}""", + """{"serverPort": 65536}""", + """{"serverContextPath": "noSlashBegin"}""", + """{"serverContextPath": "/slashEnd/"}""", + """{"serverContextPath": "/invalid=character"}""", + """{"serverContextPath": "/invalid/end-"}""", + """{"serverContextPath": "/invalid/end_"}""", + """{"serverContextPath": "/日本語"}""", + ], + ) + fun `given admin user when updating with invalid settings then returns bad request`(jsonString: String) { + mockMvc + .patch("/api/v1/settings") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt new file mode 100644 index 000000000..d44bcb758 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.interfaces.api.rest + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@ActiveProfiles("demo", "test") +class UserControllerDemoTest( + @Autowired private val mockMvc: MockMvc, +) { + @Test + @WithMockCustomUser + fun `given demo profile is active when a user tries to update its password via api then returns forbidden`() { + // language=JSON + val jsonString = """{"password":"new"}""" + + mockMvc + .patch("/api/v2/users/me/password") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isForbidden() } + } + } + + @Test + @WithMockCustomUser + fun `given demo profile is active when a user tries to retrieve own authentication activity then returns forbidden`() { + mockMvc + .get("/api/v2/users/me/authentication-activity") + .andExpect { + status { isForbidden() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt new file mode 100644 index 000000000..55d74e1bf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt @@ -0,0 +1,657 @@ +package org.gotson.komga.interfaces.api.rest + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.hasItem +import org.hamcrest.text.MatchesPattern +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +@ActiveProfiles("test") +class UserControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val userRepository: KomgaUserRepository, +) { + @Autowired + private lateinit var userLifecycle: KomgaUserLifecycle + + private val admin = KomgaUser("admin@example.org", "", id = "admin") + + @BeforeAll + fun setup() { + libraryRepository.insert(makeLibrary(id = "1")) + libraryRepository.insert(makeLibrary(id = "2")) + userRepository.insert(admin) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun deleteUsers() { + userRepository + .findAll() + .filterNot { it.email == admin.email } + .forEach { userLifecycle.deleteUser(it) } + } + + @Nested + inner class Create { + @ParameterizedTest + @ValueSource(strings = ["user", "user@domain"]) + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with invalid email then returns bad request`(email: String) { + // language=JSON + val jsonString = """{"email":"$email","password":"password"}""" + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with limited fields then returns created user`() { + // language=JSON + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password" + } + """.trimIndent() + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isCreated() } + jsonPath("$.roles") { value(hasItem("USER")) } + jsonPath("$.sharedAllLibraries") { value(true) } + jsonPath("$.sharedLibrariesId") { doesNotExist() } + jsonPath("$.labelsAllow") { isEmpty() } + jsonPath("$.labelsExclude") { isEmpty() } + jsonPath("$.ageRestriction") { doesNotExist() } + } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with all fields then returns created user`() { + // language=JSON + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password", + "roles": ["${UserRoles.FILE_DOWNLOAD.name}"], + "ageRestriction": { + "age": 5, + "restriction": "${AllowExcludeDto.NONE}" + }, + "labelsAllow": ["allowTag"], + "labelsExclude": ["excludeTag"], + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "157"] + } + } + """.trimIndent() + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isCreated() } + jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) } + jsonPath("$.labelsAllow") { value(hasItem("allowtag")) } + jsonPath("$.labelsExclude") { value(hasItem("excludetag")) } + jsonPath("$.ageRestriction") { doesNotExist() } + jsonPath("$.sharedAllLibraries") { value(false) } + jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with some fields then returns created user`() { + // language=JSON + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password", + "roles": ["${UserRoles.FILE_DOWNLOAD.name}"], + "ageRestriction": { + "age": 5, + "restriction": "${AllowExcludeDto.ALLOW_ONLY}" + }, + "labelsAllow": ["allowTag"], + "labelsExclude": ["excludeTag"], + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "157"] + } + } + """.trimIndent() + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isCreated() } + jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) } + jsonPath("$.labelsAllow") { value(hasItem("allowtag")) } + jsonPath("$.labelsExclude") { value(hasItem("excludetag")) } + jsonPath("$.ageRestriction.age") { value(5) } + jsonPath("$.ageRestriction.restriction") { value(AllowExclude.ALLOW_ONLY.name) } + jsonPath("$.sharedAllLibraries") { value(false) } + jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) } + } + } + + @Nested + inner class Update { + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user without roles when updating roles then roles are updated`() { + val user = KomgaUser("user@example.org", "", id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "roles": ["${UserRoles.FILE_DOWNLOAD.name}","${UserRoles.PAGE_STREAMING.name}","${UserRoles.KOBO_SYNC.name}"] + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.roles).containsExactlyInAnyOrder(UserRoles.KOBO_SYNC, UserRoles.PAGE_STREAMING, UserRoles.FILE_DOWNLOAD) + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with roles when updating roles then roles are updated`() { + val user = KomgaUser("user@example.org", "", id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "roles": [] + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.roles).isEmpty() + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with library restrictions when updating available libraries then they are updated`() { + val user = KomgaUser("user@example.org", "", sharedLibrariesIds = setOf("1"), sharedAllLibraries = false, id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "2"] + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isFalse + assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2") + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user without library restrictions when restricting libraries then they restrictions are updated`() { + val user = KomgaUser("user@example.org", "", sharedAllLibraries = true, id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "sharedLibraries": { + "all": "false", + "libraryIds" : ["2"] + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isFalse + assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2") + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with library restrictions when removing restrictions then the restrictions are updated`() { + val user = KomgaUser("user@example.org", "", sharedLibrariesIds = setOf("2"), sharedAllLibraries = false, id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "sharedLibraries": { + "all": "true", + "libraryIds": [] + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isTrue + assertThat(this.sharedLibrariesIds).isEmpty() + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user without labels restrictions when adding restrictions then restrictions are updated`() { + val user = KomgaUser("user@example.org", "", id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "labelsAllow": ["cute", "kids"], + "labelsExclude": ["adult"] + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.labelsAllow).containsExactlyInAnyOrder("cute", "kids") + assertThat(this.restrictions.labelsExclude).containsOnly("adult") + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with labels restrictions when removing restrictions then restrictions are updated`() { + val user = + KomgaUser( + "user@example.org", + "", + restrictions = + ContentRestrictions( + labelsAllow = setOf("kids", "cute"), + labelsExclude = setOf("adult"), + ), + id = "user", + ) + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "labelsAllow": [], + "labelsExclude": null + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.labelsAllow).isEmpty() + assertThat(this.restrictions.labelsExclude).isEmpty() + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user without age restriction when adding restrictions then restrictions are updated`() { + val user = KomgaUser("user@example.org", "", id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "ageRestriction": { + "age": 12, + "restriction": "ALLOW_ONLY" + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNotNull + assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(12) + assertThat(this.restrictions.ageRestriction!!.restriction).isEqualTo(AllowExclude.ALLOW_ONLY) + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user without age restriction when adding incorrect restrictions then bad request`() { + val user = KomgaUser("user@example.org", "", id = "user") + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "ageRestriction": { + "age": -12, + "restriction": "ALLOW_ONLY" + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with age restriction when removing restriction then restrictions are updated`() { + val user = + KomgaUser( + "user@example.org", + "", + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(12, AllowExclude.ALLOW_ONLY), + ), + id = "user", + ) + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "ageRestriction": null + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNull() + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = ["ADMIN"]) + fun `given user with age restriction when changing restriction then restrictions are updated`() { + val user = + KomgaUser( + "user@example.org", + "", + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(12, AllowExclude.ALLOW_ONLY), + ), + id = "user", + ) + userLifecycle.createUser(user) + + // language=JSON + val jsonString = + """ + { + "ageRestriction": { + "age": 16, + "restriction": "EXCLUDE" + } + } + """.trimIndent() + + mockMvc + .patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNotNull + assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(16) + assertThat(this.restrictions.ageRestriction!!.restriction).isEqualTo(AllowExclude.EXCLUDE) + } + } + } + + @Nested + inner class ApiKey { + @AfterEach + fun cleanup() { + userRepository.deleteApiKeyByUserId(admin.id) + } + + @Test + @WithMockCustomUser(id = "admin") + fun `given user when creating API key then it is returned in plain text`() { + // language=JSON + val jsonString = + """ + { + "comment": "test api key" + } + """.trimIndent() + + mockMvc + .post("/api/v2/users/me/api-keys") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk() } + jsonPath("$.userId") { value(admin.id) } + jsonPath("$.key") { value(MatchesPattern(Regex("""[^*]+""").toPattern())) } + jsonPath("$.comment") { value("test api key") } + } + + with(userRepository.findApiKeyByUserId(admin.id)) { + assertThat(this).hasSize(1) + with(this.first()) { + assertThat(this.userId).isEqualTo(admin.id) + assertThat(this.comment).isEqualTo("test api key") + } + } + + mockMvc + .get("/api/v2/users/me/api-keys") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].userId") { value(admin.id) } + jsonPath("$[0].key") { value(MatchesPattern(Regex("""[*]+""").toPattern())) } + jsonPath("$[0].comment") { value("test api key") } + } + } + + @Test + @WithMockCustomUser(id = "admin") + fun `given user when creating API key without comment then returns bad request`() { + // language=JSON + val jsonString = + """ + { + "comment": "" + } + """.trimIndent() + + mockMvc + .post("/api/v2/users/me/api-keys") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(id = "admin") + fun `given user with api key when deleting API key then it is deleted`() { + val apiKey = userLifecycle.createApiKey(admin, "test")!! + + mockMvc + .delete("/api/v2/users/me/api-keys/${apiKey.id}") + .andExpect { + status { isNoContent() } + } + + assertThat(userRepository.findApiKeyByUserId(admin.id)).isEmpty() + } + + @Test + @WithMockCustomUser(id = "admin") + fun `given user with api key when deleting different API key ID then returns bad request`() { + userLifecycle.createApiKey(admin, "test")!! + + mockMvc + .delete("/api/v2/users/me/api-keys/abc123") + .andExpect { + status { isNotFound() } + } + + assertThat(userRepository.findApiKeyByUserId(admin.id)).isNotEmpty() + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundControllerTest.kt b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundControllerTest.kt new file mode 100644 index 000000000..6b86238c7 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/kotlin/org/gotson/komga/interfaces/mvc/ResourceNotFoundControllerTest.kt @@ -0,0 +1,55 @@ +package org.gotson.komga.interfaces.mvc + +import org.gotson.komga.interfaces.api.rest.WithMockCustomUser +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class ResourceNotFoundControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @Test + @WithMockCustomUser + fun `when getting an unknown API endpoint then 404 is returned`() { + mockMvc + .get("/api/v1/doesnotexist") + .andExpect { + status { isNotFound() } + } + } + + @Test + @WithMockCustomUser + fun `when getting an unknown OPDS endpoint then 404 is returned`() { + mockMvc + .get("/opds/v2/doesnotexist") + .andExpect { + status { isNotFound() } + } + } + + @Test + @WithMockCustomUser + fun `when getting an unknown SSE endpoint then 404 is returned`() { + mockMvc + .get("/sse/v1/doesnotexist") + .andExpect { + status { isNotFound() } + } + } + + @Test + @WithMockCustomUser + fun `when getting an unknown endpoint then it is forwarded to index`() { + mockMvc + .get("/book/0DBTWY6S0KNX9") + .andExpect { + status { isOk() } + } + } +} diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-memorydb.yml b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-memorydb.yml new file mode 100644 index 000000000..5da041aed --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-memorydb.yml @@ -0,0 +1,5 @@ +komga: + database: + file: "file:database?mode=memory" + tasks-db: + file: "file:tasks?mode=memory" diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-test.yml b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-test.yml new file mode 100644 index 000000000..171136603 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +application.version: TESTING + +komga: + database: + file: "\${java.io.tmpdir}/database\${random.uuid}.sqlite" + journal-mode: WAL + tasks-db: + file: "\${java.io.tmpdir}/tasks\${random.uuid}.sqlite" + journal-mode: WAL + +spring: + flyway: + enabled: true + +logging: + level: + org.gotson.komga: DEBUG +# org.jooq: DEBUG +# org.jooq.tools.LoggerListener: DEBUG diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip-encrypted.7z b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip-encrypted.7z new file mode 100644 index 000000000..cf24982a8 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip-encrypted.7z differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip.7z b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip.7z new file mode 100644 index 000000000..92223e2bb Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/7zip.7z differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/epub3.epub b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/epub3.epub new file mode 100644 index 000000000..b2264d1fa Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/epub3.epub differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/zip-as-epub.epub b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/zip-as-epub.epub new file mode 100644 index 000000000..bc224d487 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/archives/zip-as-epub.epub differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/komga.png b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/komga.png new file mode 100644 index 000000000..af95fb6ca Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/komga.png differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/page_384.jpg b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/page_384.jpg new file mode 100644 index 000000000..3b3c04c25 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/barcode/page_384.jpg differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/1979.opf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/1979.opf new file mode 100644 index 000000000..ffd65bc72 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/1979.opf @@ -0,0 +1,78 @@ + + + + de + + + calibre (2.10.0) [http://calibre-ebook.com] + <div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">Auf dem Weg nach Teheran sah ich aus dem Autofenster, mir wurde etwas übel und ich hielt mich an Christophers Knie fest. Sein Hosenbein war von den aufgeplatzten Blasen ganz naß. Wir fuhren an endlosen Reihen von Birken vorbei. Ich schlief.</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">Später hielten wir an, um uns zu erfrischen. Ich trank ein Glas Tee, Christopher eine Limonade. Es wurde sehr rasch Nacht.</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">Es gab einige Militärkontrollen, denn seit September herrschte Kriegsrecht, was ja eigentlich nichts zu bedeuten hatte in diesen Ländern, sagte Christopher. Wir wurden weitergewunken, einmal sah ich einen Arm, eine weiße Bandage darum und eine Taschenlampe, die uns ins Gesicht schien, dann ging es weiter.</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">Die Luft war staubig, ab und zu roch es im Wagen nach Mais. Wir hatten nur zwei Kassetten dabei; wir hörten erst Blondie, dann Devo, dann wieder Blondie. Es waren Christophers Kassetten.</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">CHRISTIAN KRACHT, 1966 geboren, ist Schweizer. Das Foto zeigt den Autor auf der Landstraße nach Tashigang, Tibet, Volksrepublik China.</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;"><br></span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">GESTALTUNG: PAUL BARNES UND PETER SAVILLE</span></font></div><div><font face="MS Shell Dlg 2, sans-serif"><span style="font-size: 14px;">FOTO: ECKHART NICKEL</span></font></div><div style="font-family: 'MS Shell Dlg 2', sans-serif; font-size: 14px;"><br></div></div> + Kracht, Christian + The Editor + + 84b3a7d8-eb3f-4fd8-ad65-4e3f75d019f9 + 84b3a7d8-eb3f-4fd8-ad65-4e3f75d019f9 + 0101-01-01T00:00:00+00:00 + 1979 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Die Drei 3.opf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Die Drei 3.opf new file mode 100644 index 000000000..c2a2d26da --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Die Drei 3.opf @@ -0,0 +1,62 @@ + + + + Die Drei Fragezeichen-Kids, Bd.3, Invasion Der Fliegen + Ulf Blanck + Stefanie Wegner + de + calibre (5.21.0) [https://calibre-ebook.com] + Franckh-Kosmos Verlag + AMAZON:3440077934 + GOOGLE:pPamZwEACAAJ + ISBN:9783440077931 + GOODREADS:71348 + uuid:38969b48-0187-4654-9e62-f2b21d4c5e40 + calibre:38969b48-0187-4654-9e62-f2b21d4c5e40 + Juvenile Fiction + Mysteries & Detective Stories + Kinder- und Jugendbücher + 1999-07-31T16:00:00+00:00 + <div> +<p>Hunderte von Fliegen tummeln sich auf dem Schrottplatz vor Tante Mathildas Haus. Das ist ein Alptraum! Findet diese "Invasion der Fliegen" tatsächlich statt oder leidet Tante Mathilda etwa an Halluzinationen? Justus, Peter und Bob nehmen sich der Sache an und kommen schnell dahinter, daß es sich hier nicht um eine Einbildung von Tante Mathilda handelt. Sie verfolgen die Spur der Fliegen bis in einen düsteren Kanalschacht...</p></div> + main + Drei Fragezeichen-Kids, Bd.3, Invasion Der Fliegen, Die + + aut + Blanck, Ulf & Wegner, Stefanie + aut + Blanck, Ulf & Wegner, Stefanie + bkp + 2020-08-09T08:40:50Z + 8.00 + Die drei ??? Kids + series + 3 + 2021-06-22T13:37:39Z + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies - namespace.opf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies - namespace.opf new file mode 100644 index 000000000..64b755b59 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies - namespace.opf @@ -0,0 +1,128 @@ + + + Panik im Paradies + Ulf Blanck + The Editor + goodreads:222735 + isbn:9783440077894 + calibre:255 + uuid:499def46-39dc-4e79-b474-d0ec12ea5dc5 + uuid:499def46-39dc-4e79-b474-d0ec12ea5dc5 + de + 1999-07-31T16:00:00+00:00 + <div> + <p>Bereits im ersten Band "Panik im Paradies" machen die drei berühmten Detektive ihrem Namen alle Ehre. Eigentlich haben sie ja gerade Ferien. Doch dann treffen sie auf diesen schrulligen Kapitän Larsson, der sich einen kleinen Privatzoo mit exotischen Tieren hält. Als plötzlich alle Tiere an rätselhaften Infektionen erkranken und die Besucher ausbleiben, werden Justus, Peter und Bob neugierig. Schon bald merken sie, daß da jemand ein düsteres Geheimnis hütet...</p></div> + Kosmos + Kinder- und Jugendbücher + main + Panik im Paradies + + 2020-08-09T08:40:58Z + 2021-06-19T08:20:33Z + aut + Blanck, Ulf + edt + Editor, The + 6 + Die drei ??? Kids + series + 1.5 + {"Ulf Blanck": ""} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies.opf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies.opf new file mode 100644 index 000000000..dc48a0def --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/Panik im Paradies.opf @@ -0,0 +1,128 @@ + + + Panik im Paradies + Ulf Blanck + The Editor + goodreads:222735 + isbn:9783440077894 + calibre:255 + uuid:499def46-39dc-4e79-b474-d0ec12ea5dc5 + uuid:499def46-39dc-4e79-b474-d0ec12ea5dc5 + de + 1999-07-31T16:00:00+00:00 + <div> + <p>Bereits im ersten Band "Panik im Paradies" machen die drei berühmten Detektive ihrem Namen alle Ehre. Eigentlich haben sie ja gerade Ferien. Doch dann treffen sie auf diesen schrulligen Kapitän Larsson, der sich einen kleinen Privatzoo mit exotischen Tieren hält. Als plötzlich alle Tiere an rätselhaften Infektionen erkranken und die Besucher ausbleiben, werden Justus, Peter und Bob neugierig. Schon bald merken sie, daß da jemand ein düsteres Geheimnis hütet...</p></div> + Kosmos + Kinder- und Jugendbücher + main + Panik im Paradies + + 2020-08-09T08:40:58Z + 2021-06-19T08:20:33Z + aut + Blanck, Ulf + edt + Editor, The + 6 + Die drei ??? Kids + series + 1.5 + {"Ulf Blanck": ""} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/The Incomplete Theft - Ralph Burke.epub b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/The Incomplete Theft - Ralph Burke.epub new file mode 100644 index 000000000..2bcc42d28 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/The Incomplete Theft - Ralph Burke.epub differ diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/clash.opf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/clash.opf new file mode 100644 index 000000000..afc02a76e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/clash.opf @@ -0,0 +1,207 @@ + + + + 978-0-553-89784-5 + A Clash of Kings + Copyright © 1996 by George R.R. Martin + George R. R. Martin + <p> + Random House Publishing Group + 2011-03-22 + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/nav.xhtml b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/nav.xhtml new file mode 100644 index 000000000..6beafc1cf --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/nav.xhtml @@ -0,0 +1,49 @@ + + + + + Best Bear Ever + + + + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/toc.ncx b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/toc.ncx new file mode 100644 index 000000000..92073930a --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/epub/toc.ncx @@ -0,0 +1,164 @@ + + + + + + + + + + A Clash of Kings + + + George R. R. Martin + + + + + COVER + + + + + + BRAN + + + + + + APPENDIX + + + + + THE KINGS AND THEIR COURTS + + + + + + THE KING ON THE IRON THRONE + + + + + + THE KING IN THE NARROW SEA + + + + + + THE KING IN HIGHGARDEN + + + + + + THE KING IN THE NORTH + + + + + + THE QUEEN ACROSS THE WATER + + + + + Another level + + + + + + Yet another level + + + + + + + + ACKNOWLEDGMENTS + + + + + + Omake 1: Ichika’s Q&A + + + + + + + + Cover Page + + + + + + iii + + + + + + v + + + + + + vii + + + + + + viii + + + + + + ix + + + + + + x + + + + + + xi + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/io/mockk/settings.properties b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/io/mockk/settings.properties new file mode 100644 index 000000000..66cdfac7e --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/io/mockk/settings.properties @@ -0,0 +1 @@ +relaxed=false \ No newline at end of file diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/junit-platform.properties b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/junit-platform.properties new file mode 100644 index 000000000..90f056db2 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default=per_class \ No newline at end of file diff --git a/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/pdf/komga.pdf b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/pdf/komga.pdf new file mode 100644 index 000000000..c61ae3295 Binary files /dev/null and b/jdk_17_gradle/cs/rest/komga/komga/src/test/resources/pdf/komga.pdf differ diff --git a/jdk_17_gradle/cs/rest/komga/settings.gradle b/jdk_17_gradle/cs/rest/komga/settings.gradle new file mode 100644 index 000000000..8a0a7eb45 --- /dev/null +++ b/jdk_17_gradle/cs/rest/komga/settings.gradle @@ -0,0 +1,6 @@ +plugins { + id("io.github.ben-manes.versions.settings") version "0.59.0" +} + +include 'komga' +include 'komga-tray'