diff --git a/.idea/runConfigurations/komga__bootRun__dev_noflyway.xml b/.idea/runConfigurations/komga__bootRun__dev_noflyway.xml deleted file mode 100644 index 33027f426..000000000 --- a/.idea/runConfigurations/komga__bootRun__dev_noflyway.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - true - - - \ No newline at end of file diff --git a/.idea/runConfigurations/komga__bootRun__generatesql.xml b/.idea/runConfigurations/komga__bootRun__generatesql.xml deleted file mode 100644 index 3dabceeba..000000000 --- a/.idea/runConfigurations/komga__bootRun__generatesql.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - true - - - \ No newline at end of file diff --git a/komga-webui/src/types/komga-users.ts b/komga-webui/src/types/komga-users.ts index 8ad1615bf..3cb109593 100644 --- a/komga-webui/src/types/komga-users.ts +++ b/komga-webui/src/types/komga-users.ts @@ -13,8 +13,7 @@ interface UserWithSharedLibrariesDto { } interface SharedLibraryDto { - id: number, - name: string + id: number } interface UserCreationDto { diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index 2787b1eae..8be683f56 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -1,3 +1,8 @@ +import com.rohanprabhu.gradle.plugins.kdjooq.database +import com.rohanprabhu.gradle.plugins.kdjooq.generator +import com.rohanprabhu.gradle.plugins.kdjooq.jdbc +import com.rohanprabhu.gradle.plugins.kdjooq.jooqCodegenConfiguration +import com.rohanprabhu.gradle.plugins.kdjooq.target import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -11,9 +16,10 @@ plugins { kotlin("kapt") version kotlinVersion } id("org.springframework.boot") version "2.2.6.RELEASE" - id("io.spring.dependency-management") version "1.0.9.RELEASE" id("com.github.ben-manes.versions") version "0.28.0" id("com.gorylenko.gradle-git-properties") version "2.2.2" + id("com.rohanprabhu.kotlin-dsl-jooq") version "0.4.5" + id("org.flywaydb.flyway") version "6.4.0" jacoco } @@ -25,37 +31,40 @@ configurations.runtimeClasspath.get().extendsFrom(developmentOnly) repositories { jcenter() mavenCentral() - maven("https://jitpack.io") } dependencies { implementation(kotlin("stdlib-jdk8")) implementation(kotlin("reflect")) + constraints { + implementation("org.flywaydb:flyway-core:6.4.0") { + because("support for H2 1.4.200 requires 6.1.0+") + } + } + + implementation(platform("org.springframework.boot:spring-boot-dependencies:2.2.6.RELEASE")) + implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-artemis") + implementation("org.springframework.boot:spring-boot-starter-jooq") - kapt("org.springframework.boot:spring-boot-configuration-processor") + kapt("org.springframework.boot:spring-boot-configuration-processor:2.2.6.RELEASE") implementation("org.apache.activemq:artemis-jms-server") implementation("org.flywaydb:flyway-core") - implementation("org.hibernate:hibernate-jcache") - - implementation("com.github.ben-manes.caffeine:caffeine") - implementation("com.github.ben-manes.caffeine:jcache") implementation("io.github.microutils:kotlin-logging:1.7.9") implementation("io.micrometer:micrometer-registry-influx") implementation("io.hawt:hawtio-springboot:2.10.0") run { - val springdocVersion = "1.3.3" + val springdocVersion = "1.3.4" implementation("org.springdoc:springdoc-openapi-ui:$springdocVersion") implementation("org.springdoc:springdoc-openapi-security:$springdocVersion") } @@ -63,12 +72,10 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") - implementation("com.github.klinq:klinq-jpaspec:0.9") - implementation("commons-io:commons-io:2.6") implementation("org.apache.commons:commons-lang3:3.10") - implementation("org.apache.tika:tika-core:1.24") + implementation("org.apache.tika:tika-core:1.24.1") implementation("org.apache.commons:commons-compress:1.20") implementation("com.github.junrar:junrar:4.0.0") implementation("org.apache.pdfbox:pdfbox:2.0.19") @@ -85,14 +92,15 @@ dependencies { implementation("com.jakewharton.byteunits:byteunits:0.9.1") - runtimeOnly("com.h2database:h2") + runtimeOnly("com.h2database:h2:1.4.200") + jooqGeneratorRuntime("com.h2database:h2:1.4.200") testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(module = "mockito-core") } testImplementation("org.springframework.security:spring-security-test") testImplementation("com.ninja-squad:springmockk:2.0.1") - testImplementation("io.mockk:mockk:1.9.3") + testImplementation("io.mockk:mockk:1.10.0") testImplementation("com.google.jimfs:jimfs:1.1") testImplementation("com.tngtech.archunit:archunit-junit5:0.13.1") @@ -100,6 +108,7 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-devtools") } +val webui = "$rootDir/komga-webui" tasks { withType { kotlinOptions { @@ -119,20 +128,18 @@ tasks { } } - register("unpack") { + //unpack Spring Boot's fat jar for better Docker image layering + register("unpack") { dependsOn(bootJar) from(zipTree(getByName("bootJar").outputs.files.singleFile)) into("$buildDir/dependency") } - register("deletePublic") { - group = "web" - delete("$projectDir/src/main/resources/public/") - } - register("npmInstall") { group = "web" - workingDir("$rootDir/komga-webui") + workingDir(webui) + inputs.file("$webui/package.json") + outputs.dir("$webui/node_modules") commandLine( if (Os.isFamily(Os.FAMILY_WINDOWS)) { "npm.cmd" @@ -146,7 +153,9 @@ tasks { register("npmBuild") { group = "web" dependsOn("npmInstall") - workingDir("$rootDir/komga-webui") + workingDir(webui) + inputs.dir(webui) + outputs.dir("$webui/dist") commandLine( if (Os.isFamily(Os.FAMILY_WINDOWS)) { "npm.cmd" @@ -158,16 +167,22 @@ tasks { ) } - register("copyWebDist") { + //copy the webui build into public + register("copyWebDist") { group = "web" - dependsOn("deletePublic", "npmBuild") - from("$rootDir/komga-webui/dist/") + dependsOn("npmBuild") + from("$webui/dist/") into("$projectDir/src/main/resources/public/") } } springBoot { - buildInfo() + buildInfo { + properties { + // prevent task bootBuildInfo to rerun every time + time = null + } + } } allOpen { @@ -175,3 +190,70 @@ allOpen { annotation("javax.persistence.MappedSuperclass") annotation("javax.persistence.Embeddable") } + +sourceSets { + //add a flyway sourceSet + val flyway by creating { + compileClasspath += sourceSets.main.get().compileClasspath + runtimeClasspath += sourceSets.main.get().runtimeClasspath + } + //main sourceSet depends on the output of flyway sourceSet + main { + output.dir(flyway.output) + } +} + +val jooqDb = mapOf( + "url" to "jdbc:h2:${project.buildDir}/generated/flyway/h2", + "schema" to "PUBLIC", + "user" to "sa", + "password" to "" +) +val migrationDirs = listOf( + "$projectDir/src/flyway/resources/db/migration", + "$projectDir/src/flyway/kotlin/db/migration" +) +flyway { + url = jooqDb["url"] + user = jooqDb["user"] + password = jooqDb["password"] + schemas = arrayOf(jooqDb["schema"]) + locations = arrayOf("classpath:db/migration") +} +//in order to include the Java migrations, flywayClasses must be run before flywayMigrate +tasks.flywayMigrate { + dependsOn("flywayClasses") + migrationDirs.forEach { inputs.dir(it) } + outputs.dir("${project.buildDir}/generated/flyway") + doFirst { delete(outputs.files) } +} + +jooqGenerator { + jooqVersion = "3.13.1" + configuration("primary", project.sourceSets.getByName("main")) { + databaseSources = migrationDirs + + configuration = jooqCodegenConfiguration { + jdbc { + username = jooqDb["user"] + password = jooqDb["password"] + driver = "org.h2.Driver" + url = jooqDb["url"] + } + + generator { + target { + packageName = "org.gotson.komga.jooq" + directory = "${project.buildDir}/generated/jooq/primary" + } + + database { + name = "org.jooq.meta.h2.H2Database" + inputSchema = jooqDb["schema"] + } + } + } + } +} +val `jooq-codegen-primary` by project.tasks +`jooq-codegen-primary`.dependsOn("flywayMigrate") diff --git a/komga/src/main/kotlin/db/migration/V20190926114415__create_library_from_series.kt b/komga/src/flyway/kotlin/db/migration/V20190926114415__create_library_from_series.kt similarity index 100% rename from komga/src/main/kotlin/db/migration/V20190926114415__create_library_from_series.kt rename to komga/src/flyway/kotlin/db/migration/V20190926114415__create_library_from_series.kt diff --git a/komga/src/main/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt b/komga/src/flyway/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt similarity index 100% rename from komga/src/main/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt rename to komga/src/flyway/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt diff --git a/komga/src/main/kotlin/db/migration/V20200306175848__create_book_metadata_from_book.kt b/komga/src/flyway/kotlin/db/migration/V20200306175848__create_book_metadata_from_book.kt similarity index 100% rename from komga/src/main/kotlin/db/migration/V20200306175848__create_book_metadata_from_book.kt rename to komga/src/flyway/kotlin/db/migration/V20200306175848__create_book_metadata_from_book.kt diff --git a/komga/src/main/resources/db/migration/V20190819161603__First_Version.sql b/komga/src/flyway/resources/db/migration/V20190819161603__First_Version.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190819161603__First_Version.sql rename to komga/src/flyway/resources/db/migration/V20190819161603__First_Version.sql diff --git a/komga/src/main/resources/db/migration/V20190906100609__regenerate_webp_covers.sql b/komga/src/flyway/resources/db/migration/V20190906100609__regenerate_webp_covers.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190906100609__regenerate_webp_covers.sql rename to komga/src/flyway/resources/db/migration/V20190906100609__regenerate_webp_covers.sql diff --git a/komga/src/main/resources/db/migration/V20190906152334__reparse_pdf_files.sql b/komga/src/flyway/resources/db/migration/V20190906152334__reparse_pdf_files.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190906152334__reparse_pdf_files.sql rename to komga/src/flyway/resources/db/migration/V20190906152334__reparse_pdf_files.sql diff --git a/komga/src/main/resources/db/migration/V20190907104615__reparse_pdf_files.sql b/komga/src/flyway/resources/db/migration/V20190907104615__reparse_pdf_files.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190907104615__reparse_pdf_files.sql rename to komga/src/flyway/resources/db/migration/V20190907104615__reparse_pdf_files.sql diff --git a/komga/src/main/resources/db/migration/V20190907143337__reparse_pdf_files.sql b/komga/src/flyway/resources/db/migration/V20190907143337__reparse_pdf_files.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190907143337__reparse_pdf_files.sql rename to komga/src/flyway/resources/db/migration/V20190907143337__reparse_pdf_files.sql diff --git a/komga/src/main/resources/db/migration/V20190910082852__rescan_series.sql b/komga/src/flyway/resources/db/migration/V20190910082852__rescan_series.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190910082852__rescan_series.sql rename to komga/src/flyway/resources/db/migration/V20190910082852__rescan_series.sql diff --git a/komga/src/main/resources/db/migration/V20190926112211__library_entity.sql b/komga/src/flyway/resources/db/migration/V20190926112211__library_entity.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190926112211__library_entity.sql rename to komga/src/flyway/resources/db/migration/V20190926112211__library_entity.sql diff --git a/komga/src/main/resources/db/migration/V20190927132225__serie_library_id_not_null.sql b/komga/src/flyway/resources/db/migration/V20190927132225__serie_library_id_not_null.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20190927132225__serie_library_id_not_null.sql rename to komga/src/flyway/resources/db/migration/V20190927132225__serie_library_id_not_null.sql diff --git a/komga/src/main/resources/db/migration/V20191008135338__rename_serie_to_series.sql b/komga/src/flyway/resources/db/migration/V20191008135338__rename_serie_to_series.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191008135338__rename_serie_to_series.sql rename to komga/src/flyway/resources/db/migration/V20191008135338__rename_serie_to_series.sql diff --git a/komga/src/main/resources/db/migration/V20191010153833__delete_thumbnails_to_force_regeneration_in_jpeg.sql b/komga/src/flyway/resources/db/migration/V20191010153833__delete_thumbnails_to_force_regeneration_in_jpeg.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191010153833__delete_thumbnails_to_force_regeneration_in_jpeg.sql rename to komga/src/flyway/resources/db/migration/V20191010153833__delete_thumbnails_to_force_regeneration_in_jpeg.sql diff --git a/komga/src/main/resources/db/migration/V20191014143703__user_entity.sql b/komga/src/flyway/resources/db/migration/V20191014143703__user_entity.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191014143703__user_entity.sql rename to komga/src/flyway/resources/db/migration/V20191014143703__user_entity.sql diff --git a/komga/src/main/resources/db/migration/V20191021101906__user_library_sharing.sql b/komga/src/flyway/resources/db/migration/V20191021101906__user_library_sharing.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191021101906__user_library_sharing.sql rename to komga/src/flyway/resources/db/migration/V20191021101906__user_library_sharing.sql diff --git a/komga/src/main/resources/db/migration/V20191029153618__book_filesize.sql b/komga/src/flyway/resources/db/migration/V20191029153618__book_filesize.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191029153618__book_filesize.sql rename to komga/src/flyway/resources/db/migration/V20191029153618__book_filesize.sql diff --git a/komga/src/main/resources/db/migration/V20191122164159__book_number.sql b/komga/src/flyway/resources/db/migration/V20191122164159__book_number.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191122164159__book_number.sql rename to komga/src/flyway/resources/db/migration/V20191122164159__book_number.sql diff --git a/komga/src/main/resources/db/migration/V20191230131239__rename_metadata_to_media.sql b/komga/src/flyway/resources/db/migration/V20191230131239__rename_metadata_to_media.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20191230131239__rename_metadata_to_media.sql rename to komga/src/flyway/resources/db/migration/V20191230131239__rename_metadata_to_media.sql diff --git a/komga/src/main/resources/db/migration/V20200103170613__media_as_auditable_entity.sql b/komga/src/flyway/resources/db/migration/V20200103170613__media_as_auditable_entity.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200103170613__media_as_auditable_entity.sql rename to komga/src/flyway/resources/db/migration/V20200103170613__media_as_auditable_entity.sql diff --git a/komga/src/main/resources/db/migration/V20200108103325__media_comment.sql b/komga/src/flyway/resources/db/migration/V20200108103325__media_comment.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200108103325__media_comment.sql rename to komga/src/flyway/resources/db/migration/V20200108103325__media_comment.sql diff --git a/komga/src/main/resources/db/migration/V20200121153351__series_metadata.sql b/komga/src/flyway/resources/db/migration/V20200121153351__series_metadata.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200121153351__series_metadata.sql rename to komga/src/flyway/resources/db/migration/V20200121153351__series_metadata.sql diff --git a/komga/src/main/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql b/komga/src/flyway/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql rename to komga/src/flyway/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql diff --git a/komga/src/main/resources/db/migration/V20200131101624__series_metadata_title.sql b/komga/src/flyway/resources/db/migration/V20200131101624__series_metadata_title.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200131101624__series_metadata_title.sql rename to komga/src/flyway/resources/db/migration/V20200131101624__series_metadata_title.sql diff --git a/komga/src/main/resources/db/migration/V20200306174948__book_number_to_int.sql b/komga/src/flyway/resources/db/migration/V20200306174948__book_number_to_int.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200306174948__book_number_to_int.sql rename to komga/src/flyway/resources/db/migration/V20200306174948__book_number_to_int.sql diff --git a/komga/src/main/resources/db/migration/V20200306175821__book_metadata.sql b/komga/src/flyway/resources/db/migration/V20200306175821__book_metadata.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200306175821__book_metadata.sql rename to komga/src/flyway/resources/db/migration/V20200306175821__book_metadata.sql diff --git a/komga/src/main/resources/db/migration/V20200402103754__media_files.sql b/komga/src/flyway/resources/db/migration/V20200402103754__media_files.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200402103754__media_files.sql rename to komga/src/flyway/resources/db/migration/V20200402103754__media_files.sql diff --git a/komga/src/main/resources/db/migration/V20200410115036__rescan_series_for_epub.sql b/komga/src/flyway/resources/db/migration/V20200410115036__rescan_series_for_epub.sql similarity index 100% rename from komga/src/main/resources/db/migration/V20200410115036__rescan_series_for_epub.sql rename to komga/src/flyway/resources/db/migration/V20200410115036__rescan_series_for_epub.sql diff --git a/komga/src/flyway/resources/db/migration/V20200429230748__jooq_migration.sql b/komga/src/flyway/resources/db/migration/V20200429230748__jooq_migration.sql new file mode 100644 index 000000000..df20e7dd9 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/V20200429230748__jooq_migration.sql @@ -0,0 +1,172 @@ +-- set default values for created_date / last_modified_date +alter table user + alter column CREATED_DATE set default now(); +alter table user + alter column LAST_MODIFIED_DATE set default now(); + +alter table library + alter column CREATED_DATE set default now(); +alter table library + alter column LAST_MODIFIED_DATE set default now(); + +alter table book + alter column CREATED_DATE set default now(); +alter table book + alter column LAST_MODIFIED_DATE set default now(); + +alter table book_metadata + alter column CREATED_DATE set default now(); +alter table book_metadata + alter column LAST_MODIFIED_DATE set default now(); + +alter table media + alter column CREATED_DATE set default now(); +alter table media + alter column LAST_MODIFIED_DATE set default now(); + +alter table series + alter column CREATED_DATE set default now(); +alter table series + alter column LAST_MODIFIED_DATE set default now(); + +alter table series_metadata + alter column CREATED_DATE set default now(); +alter table series_metadata + alter column LAST_MODIFIED_DATE set default now(); + +-- replace USER_ROLE table by boolean value per role in USER table +alter table user + add role_admin boolean default false; +update user u +set role_admin = exists(select roles from user_role ur where ur.roles like 'ADMIN' and ur.user_id = u.id); +drop table user_role; + +-- add LIBRARY_ID field to table BOOK +alter table book + add library_id bigint; +alter table book + add constraint fk_book_library_library_id foreign key (library_id) references library (id); +update book b +set library_id = (select s.library_id from series s where s.ID = b.series_id); +alter table book + alter column library_id set not null; + +-- inverse relationship between series and series_metadata +alter table SERIES_METADATA + add column series_id bigint; +update SERIES_METADATA m +set m.series_id = (select s.id from series s where s.metadata_id = m.id); +alter table SERIES + drop constraint FK_SERIES_SERIES_METADATA_METADATA_ID; +alter table SERIES_METADATA + drop primary key; +alter table SERIES_METADATA + drop column id; +alter table SERIES_METADATA + alter column series_id set not null; +alter table SERIES_METADATA + add primary key (series_id); +alter table series + drop column METADATA_ID; + +alter table SERIES_METADATA + add constraint FK_SERIES_METADATA_SERIES_SERIES_ID foreign key (series_id) references series (id); + +-- inverse relationship between book and book_metadata +alter table BOOK_METADATA + add column book_id bigint; +update BOOK_METADATA m +set m.book_id = (select b.id from book b where b.metadata_id = m.id); + +alter table BOOK_METADATA_AUTHOR + add column book_id bigint; +update BOOK_METADATA_AUTHOR a +set a.book_id = (select m.book_id from BOOK_METADATA m where m.id = a.BOOK_METADATA_ID); + +alter table BOOK + drop constraint FK_BOOK_BOOK__METADATA_METADATA_ID; +alter table BOOK_METADATA_AUTHOR + drop constraint FK_BOOK_METADATA_AUTHOR_BOOK_METADATA_ID; + +alter table BOOK_METADATA + drop primary key; +alter table BOOK_METADATA + drop column id; +alter table BOOK_METADATA + alter column book_id set not null; +alter table BOOK_METADATA + add primary key (book_id); + +alter table BOOK_METADATA_AUTHOR + drop column BOOK_METADATA_ID; +alter table BOOK_METADATA_AUTHOR + alter column book_id set not null; + +alter table BOOK + drop column METADATA_ID; + +alter table BOOK_METADATA + add constraint FK_BOOK_METADATA_BOOK_BOOK_ID foreign key (book_id) references book (id); +alter table BOOK_METADATA_AUTHOR + add constraint FK_BOOK_METADATA_AUTHOR_BOOK_BOOK_ID foreign key (book_id) references book (id); + +-- inverse relationship between book and media +alter table MEDIA + add column book_id bigint; +update MEDIA m +set m.book_id = (select b.id from book b where b.MEDIA_ID = m.id); + +alter table MEDIA_PAGE + add column book_id bigint; +update MEDIA_PAGE p +set p.book_id = (select m.book_id from MEDIA m where m.id = p.MEDIA_ID); + +alter table MEDIA_FILE + add column book_id bigint; +update MEDIA_FILE f +set f.book_id = (select m.book_id from MEDIA m where m.id = f.MEDIA_ID); + +alter table BOOK + drop constraint FK_BOOK_MEDIA_MEDIA_ID; +alter table MEDIA_PAGE + drop constraint FK_MEDIA_PAGE_MEDIA_MEDIA_ID; +alter table MEDIA_FILE + drop constraint FK_MEDIA_FILE_MEDIA_MEDIA_ID; + +alter table MEDIA + drop primary key; +alter table MEDIA + drop column id; +alter table MEDIA + alter column book_id set not null; +alter table MEDIA + add primary key (book_id); + +alter table MEDIA_PAGE + drop column MEDIA_ID; +alter table MEDIA_PAGE + alter column book_id set not null; + +alter table MEDIA_FILE + drop column MEDIA_ID; +alter table MEDIA_FILE + alter column book_id set not null; +alter table MEDIA_FILE + alter column FILES rename to FILE_NAME; + +alter table BOOK + drop column MEDIA_ID; + +alter table MEDIA + add constraint FK_MEDIA_BOOK_BOOK_ID foreign key (book_id) references book (id); +alter table MEDIA_PAGE + add constraint FK_MEDIA_PAGE_BOOK_BOOK_ID foreign key (book_id) references book (id); +alter table MEDIA_FILE + add constraint FK_MEDIA_FILE_BOOK_BOOK_ID foreign key (book_id) references book (id); + +-- store media page count in DB +alter table media + add column page_count bigint default 0; + +update media m +set page_count = (select count(p.BOOK_ID) from media_page p where p.BOOK_ID = m.BOOK_ID); diff --git a/komga/src/main/kotlin/org/gotson/komga/Application.kt b/komga/src/main/kotlin/org/gotson/komga/Application.kt index 71881348f..36156d16f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/Application.kt +++ b/komga/src/main/kotlin/org/gotson/komga/Application.kt @@ -2,12 +2,10 @@ package org.gotson.komga import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication @EnableScheduling -@EnableJpaAuditing class Application fun main(args: Array) { diff --git a/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt deleted file mode 100644 index 83029da32..000000000 --- a/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.gotson.komga.application.service - -import mu.KotlinLogging -import org.gotson.komga.domain.model.Book -import org.gotson.komga.domain.persistence.BookRepository -import org.gotson.komga.domain.persistence.SeriesRepository -import org.gotson.komga.domain.service.MetadataApplier -import org.gotson.komga.infrastructure.metadata.BookMetadataProvider -import org.springframework.stereotype.Service - -private val logger = KotlinLogging.logger {} - -@Service -class MetadataLifecycle( - private val bookMetadataProviders: List, - private val metadataApplier: MetadataApplier, - private val bookRepository: BookRepository, - private val seriesRepository: SeriesRepository -) { - - fun refreshMetadata(book: Book) { - logger.info { "Refresh metadata for book: $book" } - bookMetadataProviders.forEach { - it.getBookMetadataFromBook(book)?.let { bPatch -> - metadataApplier.apply(bPatch, book) - bookRepository.save(book) - - bPatch.series?.let { sPatch -> - metadataApplier.apply(sPatch, book.series) - seriesRepository.save(book.series) - } - } - } - } - -} diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt index a1ef871bd..0d5ff3140 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt @@ -1,17 +1,15 @@ package org.gotson.komga.application.tasks import mu.KotlinLogging -import org.gotson.komga.application.service.BookLifecycle -import org.gotson.komga.application.service.MetadataLifecycle import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.LibraryScanner +import org.gotson.komga.domain.service.MetadataLifecycle import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR -import org.springframework.data.repository.findByIdOrNull import org.springframework.jms.annotation.JmsListener import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import kotlin.time.measureTime private val logger = KotlinLogging.logger {} @@ -27,12 +25,12 @@ class TaskHandler( ) { @JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR) - @Transactional fun handleTask(task: Task) { logger.info { "Executing task: $task" } try { measureTime { when (task) { + is Task.ScanLibrary -> libraryRepository.findByIdOrNull(task.libraryId)?.let { libraryScanner.scanRootFolder(it) @@ -54,6 +52,7 @@ class TaskHandler( bookRepository.findByIdOrNull(task.bookId)?.let { metadataLifecycle.refreshMetadata(it) } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + } }.also { logger.info { "Task $task executed in $it" } diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt index c108cce0d..8f593ea1d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt @@ -2,6 +2,7 @@ package org.gotson.komga.application.tasks import mu.KotlinLogging import org.gotson.komga.domain.model.Book +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.persistence.BookRepository @@ -23,25 +24,36 @@ class TaskReceiver( ) { fun scanLibraries() { - libraryRepository.findAll().forEach { scanLibrary(it) } + libraryRepository.findAll().forEach { scanLibrary(it.id) } } - fun scanLibrary(library: Library) { - submitTask(Task.ScanLibrary(library.id)) + fun scanLibrary(libraryId: Long) { + submitTask(Task.ScanLibrary(libraryId)) } fun analyzeUnknownBooks(library: Library) { - bookRepository.findAllByMediaStatusAndSeriesLibrary(Media.Status.UNKNOWN, library).forEach { - submitTask(Task.AnalyzeBook(it.id)) + bookRepository.findAllId(BookSearch( + libraryIds = listOf(library.id), + mediaStatus = listOf(Media.Status.UNKNOWN) + )).forEach { + submitTask(Task.AnalyzeBook(it)) } } + fun analyzeBook(bookId: Long) { + submitTask(Task.AnalyzeBook(bookId)) + } + fun analyzeBook(book: Book) { submitTask(Task.AnalyzeBook(book.id)) } - fun generateBookThumbnail(book: Book) { - submitTask(Task.GenerateBookThumbnail(book.id)) + fun generateBookThumbnail(bookId: Long) { + submitTask(Task.GenerateBookThumbnail(bookId)) + } + + fun refreshBookMetadata(bookId: Long) { + submitTask(Task.RefreshBookMetadata(bookId)) } fun refreshBookMetadata(book: Book) { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Auditable.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Auditable.kt new file mode 100644 index 000000000..8ebeea0b7 --- /dev/null +++ b/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 + +abstract class Auditable { + abstract val createdDate: LocalDateTime + abstract val lastModifiedDate: LocalDateTime +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuditableEntity.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/AuditableEntity.kt deleted file mode 100644 index 129bb534f..000000000 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuditableEntity.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.gotson.komga.domain.model - -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedDate -import org.springframework.data.jpa.domain.support.AuditingEntityListener -import java.time.LocalDateTime -import javax.persistence.Column -import javax.persistence.EntityListeners -import javax.persistence.MappedSuperclass - -@MappedSuperclass -@EntityListeners(AuditingEntityListener::class) -abstract class AuditableEntity { - @CreatedDate - @Column(name = "created_date", updatable = false, nullable = false) - var createdDate: LocalDateTime? = null - - @LastModifiedDate - @Column(name = "last_modified_date", nullable = false) - var lastModifiedDate: LocalDateTime? = null -} \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt index 02baf7d2b..a95fa3f3d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt @@ -1,31 +1,11 @@ package org.gotson.komga.domain.model -import javax.persistence.Column -import javax.persistence.Embeddable -import javax.validation.constraints.NotBlank - -@Embeddable -class Author { - constructor(name: String, role: String) { - this.name = name - this.role = role - } - - @NotBlank - @Column(name = "name", nullable = false) - var name: String - set(value) { - require(value.isNotBlank()) { "name must not be blank" } - field = value.trim() - } - - @NotBlank - @Column(name = "role", nullable = false) - var role: String - set(value) { - require(value.isNotBlank()) { "role must not be blank" } - field = value.trim().toLowerCase() - } +class Author( + name: String, + role: String +) { + val name = name.trim() + val role = role.trim().toLowerCase() override fun toString(): String = "Author($name, $role)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt index 9d9b682a4..3ace55703 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt @@ -2,74 +2,25 @@ package org.gotson.komga.domain.model import com.jakewharton.byteunits.BinaryByteUnit import org.apache.commons.io.FilenameUtils -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy import java.net.URL import java.nio.file.Path import java.nio.file.Paths import java.time.LocalDateTime -import javax.persistence.Cacheable -import javax.persistence.CascadeType -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.FetchType -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.ManyToOne -import javax.persistence.OneToOne -import javax.persistence.Table -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotNull -@Entity -@Table(name = "book") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book") -class Book( - @NotBlank - @Column(name = "name", nullable = false) - var name: String, +data class Book( + val name: String, + val url: URL, + val fileLastModified: LocalDateTime, + val fileSize: Long = 0, + val number: Int = 0, - @Column(name = "url", nullable = false) - var url: URL, + val id: Long = 0, + val seriesId: Long = 0, + val libraryId: Long = 0, - @Column(name = "file_last_modified", nullable = false) - var fileLastModified: LocalDateTime, - - @Column(name = "file_size", nullable = false) - var fileSize: Long = 0 -) : AuditableEntity() { - @Id - @GeneratedValue - @Column(name = "id", nullable = false) - var id: Long = 0 - - @NotNull - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "series_id", nullable = false) - lateinit var series: Series - - @OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - @JoinColumn(name = "media_id", nullable = false) - var media: Media = Media() - - @Column(name = "number", nullable = false) - var number: Int = 0 - set(value) { - field = value - if (!metadata.numberLock) metadata.number = value.toString() - if (!metadata.numberSortLock) metadata.numberSort = value.toFloat() - } - - @OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - @JoinColumn(name = "metadata_id", nullable = false) - var metadata: BookMetadata = - BookMetadata( - title = name, - number = number.toString(), - numberSort = number.toFloat() - ) + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { fun fileName(): String = FilenameUtils.getName(url.toString()) @@ -78,6 +29,4 @@ class Book( fun path(): Path = Paths.get(this.url.toURI()) fun fileSizeHumanReadable(): String = BinaryByteUnit.format(fileSize) - - override fun toString(): String = "Book($id, ${url.toURI().path})" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt index 8f561d5c5..eca0ced7f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt @@ -1,128 +1,86 @@ package org.gotson.komga.domain.model -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy import java.time.LocalDate -import javax.persistence.Cacheable -import javax.persistence.CollectionTable -import javax.persistence.Column -import javax.persistence.ElementCollection -import javax.persistence.Entity -import javax.persistence.EnumType -import javax.persistence.Enumerated -import javax.persistence.FetchType -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.Table -import javax.validation.constraints.NotBlank -import javax.validation.constraints.PositiveOrZero +import java.time.LocalDateTime -@Entity -@Table(name = "book_metadata") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book_metadata") -class BookMetadata : AuditableEntity { - constructor( - title: String, - summary: String = "", - number: String, - numberSort: Float, - readingDirection: ReadingDirection? = null, - publisher: String = "", - ageRating: Int? = null, - releaseDate: LocalDate? = null, - authors: MutableList = mutableListOf() - ) : super() { - this.title = title - this.summary = summary - this.number = number - this.numberSort = numberSort - this.readingDirection = readingDirection - this.publisher = publisher - this.ageRating = ageRating - this.releaseDate = releaseDate - this.authors = authors - } +class BookMetadata( + title: String, + summary: String = "", + number: String, + val numberSort: Float, + val readingDirection: ReadingDirection? = null, + publisher: String = "", + val ageRating: Int? = null, + val releaseDate: LocalDate? = null, + val authors: List = emptyList(), - @Id - @GeneratedValue - @Column(name = "id", nullable = false, unique = true) - val id: Long = 0 + val titleLock: Boolean = false, + val summaryLock: Boolean = false, + val numberLock: Boolean = false, + val numberSortLock: Boolean = false, + val readingDirectionLock: Boolean = false, + val publisherLock: Boolean = false, + val ageRatingLock: Boolean = false, + val releaseDateLock: Boolean = false, + val authorsLock: Boolean = false, - @NotBlank - @Column(name = "title", nullable = false) - var title: String - set(value) { - require(value.isNotBlank()) { "title must not be blank" } - field = value.trim() - } + val bookId: Long = 0, - @Column(name = "summary", nullable = false) - var summary: String - set(value) { - field = value.trim() - } + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { - @NotBlank - @Column(name = "number", nullable = false) - var number: String - set(value) { - require(value.isNotBlank()) { "number must not be blank" } - field = value.trim() - } + val title = title.trim() + val summary = summary.trim() + val number = number.trim() + val publisher = publisher.trim() - @Column(name = "number_sort", nullable = false, columnDefinition = "REAL") - var numberSort: Float - - @Enumerated(EnumType.STRING) - @Column(name = "reading_direction", nullable = true) - var readingDirection: ReadingDirection? - - @Column(name = "publisher", nullable = false) - var publisher: String - set(value) { - field = value.trim() - } - - @PositiveOrZero - @Column(name = "age_rating", nullable = true) - var ageRating: Int? - - @Column(name = "release_date", nullable = true) - var releaseDate: LocalDate? - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "book_metadata_author", joinColumns = [JoinColumn(name = "book_metadata_id")]) - var authors: MutableList - - - @Column(name = "title_lock", nullable = false) - var titleLock: Boolean = false - - @Column(name = "summary_lock", nullable = false) - var summaryLock: Boolean = false - - @Column(name = "number_lock", nullable = false) - var numberLock: Boolean = false - - @Column(name = "number_sort_lock", nullable = false) - var numberSortLock: Boolean = false - - @Column(name = "reading_direction_lock", nullable = false) - var readingDirectionLock: Boolean = false - - @Column(name = "publisher_lock", nullable = false) - var publisherLock: Boolean = false - - @Column(name = "age_rating_lock", nullable = false) - var ageRatingLock: Boolean = false - - @Column(name = "release_date_lock", nullable = false) - var releaseDateLock: Boolean = false - - @Column(name = "authors_lock", nullable = false) - var authorsLock: Boolean = false + fun copy( + title: String = this.title, + summary: String = this.summary, + number: String = this.number, + numberSort: Float = this.numberSort, + readingDirection: ReadingDirection? = this.readingDirection, + publisher: String = this.publisher, + ageRating: Int? = this.ageRating, + releaseDate: LocalDate? = this.releaseDate, + authors: List = this.authors.toList(), + titleLock: Boolean = this.titleLock, + summaryLock: Boolean = this.summaryLock, + numberLock: Boolean = this.numberLock, + numberSortLock: Boolean = this.numberSortLock, + readingDirectionLock: Boolean = this.readingDirectionLock, + publisherLock: Boolean = this.publisherLock, + ageRatingLock: Boolean = this.ageRatingLock, + releaseDateLock: Boolean = this.releaseDateLock, + authorsLock: Boolean = this.authorsLock, + bookId: Long = this.bookId, + createdDate: LocalDateTime = this.createdDate, + lastModifiedDate: LocalDateTime = this.lastModifiedDate + ) = + BookMetadata( + title = title, + summary = summary, + number = number, + numberSort = numberSort, + readingDirection = readingDirection, + publisher = publisher, + ageRating = ageRating, + releaseDate = releaseDate, + authors = authors, + titleLock = titleLock, + summaryLock = summaryLock, + numberLock = numberLock, + numberSortLock = numberSortLock, + readingDirectionLock = readingDirectionLock, + publisherLock = publisherLock, + ageRatingLock = ageRatingLock, + releaseDateLock = releaseDateLock, + authorsLock = authorsLock, + bookId = bookId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) enum class ReadingDirection { LEFT_TO_RIGHT, @@ -130,4 +88,7 @@ class BookMetadata : AuditableEntity { VERTICAL, WEBTOON } + + override fun toString(): String = + "BookMetadata(numberSort=$numberSort, readingDirection=$readingDirection, ageRating=$ageRating, releaseDate=$releaseDate, authors=$authors, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, readingDirectionLock=$readingDirectionLock, publisherLock=$publisherLock, ageRatingLock=$ageRatingLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, bookId=$bookId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', publisher='$publisher')" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt index 98299c35f..b29340927 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -2,7 +2,7 @@ package org.gotson.komga.domain.model import java.time.LocalDate -class BookMetadataPatch( +data class BookMetadataPatch( val title: String?, val summary: String?, val number: String?, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt index 8d714debe..015ce450f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookPage.kt @@ -1,13 +1,6 @@ package org.gotson.komga.domain.model -import javax.persistence.Column -import javax.persistence.Embeddable - -@Embeddable -class BookPage( - @Column(name = "file_name", nullable = false) +data class BookPage( val fileName: String, - - @Column(name = "media_type", nullable = false) val mediaType: String ) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt new file mode 100644 index 000000000..5b8ee4f57 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +data class BookSearch( + val libraryIds: Collection = emptyList(), + val seriesIds: Collection = emptyList(), + val searchTerm: String? = null, + val mediaStatus: Collection = emptyList() +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt index 964f10b2b..5441f80ed 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Exceptions.kt @@ -6,3 +6,4 @@ class ImageConversionException(message: String) : Exception(message) class DirectoryNotFoundException(message: String) : Exception(message) class DuplicateNameException(message: String) : Exception(message) class PathContainedInPath(message: String) : Exception(message) +class UserEmailAlreadyExistsException(message: String) : Exception(message) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt index 37cfad162..87ed762b5 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt @@ -1,77 +1,55 @@ package org.gotson.komga.domain.model -import javax.persistence.CollectionTable -import javax.persistence.Column -import javax.persistence.ElementCollection -import javax.persistence.Entity -import javax.persistence.EnumType -import javax.persistence.Enumerated -import javax.persistence.FetchType -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.JoinTable -import javax.persistence.ManyToMany -import javax.persistence.Table +import java.time.LocalDateTime import javax.validation.constraints.Email import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotNull -@Entity -@Table(name = "user") -class KomgaUser( +data class KomgaUser( @Email @NotBlank - @Column(name = "email", nullable = false, unique = true) - var email: String, - + val email: String, @NotBlank - @Column(name = "password", nullable = false) - var password: String, + val password: String, + val roleAdmin: Boolean, + val sharedLibrariesIds: Set = emptySet(), + val sharedAllLibraries: Boolean = true, + val id: Long = 0, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { - @Enumerated(EnumType.STRING) - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "user_role", joinColumns = [JoinColumn(name = "user_id")]) - var roles: MutableSet = mutableSetOf() + fun roles(): Set { + val roles = mutableSetOf("USER") + if (roleAdmin) roles.add("ADMIN") + return roles + } -) : AuditableEntity() { + fun getAuthorizedLibraryIds(libraryIds: Collection?) = + when { + // limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries + !sharedAllLibraries && !libraryIds.isNullOrEmpty() -> libraryIds.intersect(sharedLibrariesIds) - @Id - @GeneratedValue - @Column(name = "id", nullable = false) - var id: Long = 0 + // limited user: filter on user's authorized libraries + !sharedAllLibraries -> sharedLibrariesIds - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable( - name = "user_library_sharing", - joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")], - inverseJoinColumns = [JoinColumn(name = "library_id", referencedColumnName = "id")] - ) - var sharedLibraries: MutableSet = mutableSetOf() + // non-limited user: filter on provided libraries + !libraryIds.isNullOrEmpty() -> libraryIds - @NotNull - @Column(name = "shared_all_libraries", nullable = false) - var sharedAllLibraries: Boolean = true - get() = if (roles.contains(UserRoles.ADMIN)) true else field - set(value) { - field = if (roles.contains(UserRoles.ADMIN)) true else value + else -> emptyList() } - fun isAdmin() = roles.contains(UserRoles.ADMIN) - fun canAccessBook(book: Book): Boolean { - return sharedAllLibraries || sharedLibraries.any { it.id == book.series.library.id } + return sharedAllLibraries || sharedLibrariesIds.any { it == book.libraryId } } fun canAccessSeries(series: Series): Boolean { - return sharedAllLibraries || sharedLibraries.any { it.id == series.library.id } + return sharedAllLibraries || sharedLibrariesIds.any { it == series.libraryId } } + fun canAccessLibrary(libraryId: Long): Boolean = + sharedAllLibraries || sharedLibrariesIds.any { it == libraryId } + fun canAccessLibrary(library: Library): Boolean { - return sharedAllLibraries || sharedLibraries.any { it.id == library.id } + return sharedAllLibraries || sharedLibrariesIds.any { it == library.id } } } - -enum class UserRoles { - ADMIN -} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt index 46f10820b..623b5841f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -1,39 +1,19 @@ package org.gotson.komga.domain.model -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy import java.net.URL import java.nio.file.Path import java.nio.file.Paths -import javax.persistence.Cacheable -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.Table -import javax.validation.constraints.NotBlank +import java.time.LocalDateTime -@Entity -@Table(name = "library") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.library") -class Library( - @NotBlank - @Column(name = "name", nullable = false, unique = true) +data class Library( val name: String, - - @NotBlank - @Column(name = "root", nullable = false) - val root: URL -) : AuditableEntity() { - @Id - @GeneratedValue - @Column(name = "id", nullable = false, unique = true) - var id: Long = 0 + val root: URL, + val id: Long = 0, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL()) fun path(): Path = Paths.get(this.root.toURI()) - - override fun toString() = "Library($id, $name, ${root.toURI().path})" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt index 0f9334a95..1dd0f527f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt @@ -1,91 +1,48 @@ package org.gotson.komga.domain.model -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy -import javax.persistence.Cacheable -import javax.persistence.CollectionTable -import javax.persistence.Column -import javax.persistence.ElementCollection -import javax.persistence.Entity -import javax.persistence.EnumType -import javax.persistence.Enumerated -import javax.persistence.FetchType -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.Lob -import javax.persistence.OrderColumn -import javax.persistence.Table +import java.time.LocalDateTime -@Entity -@Table(name = "media") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media") class Media( - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: Status = Status.UNKNOWN, + val status: Status = Status.UNKNOWN, + val mediaType: String? = null, + val thumbnail: ByteArray? = null, + val pages: List = emptyList(), + val files: List = emptyList(), + val comment: String? = null, + val bookId: Long = 0, + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { - @Column(name = "media_type") - var mediaType: String? = null, + fun reset() = Media(bookId = this.bookId) - @Column(name = "thumbnail") - @Lob - var thumbnail: ByteArray? = null, - - pages: Iterable = emptyList(), - - files: Iterable = emptyList(), - - @Column(name = "comment") - var comment: String? = null -) : AuditableEntity() { - @Id - @GeneratedValue - @Column(name = "id", nullable = false, unique = true) - val id: Long = 0 - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "media_page", joinColumns = [JoinColumn(name = "media_id")]) - @OrderColumn(name = "number") - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media.collection.pages") - private var _pages: MutableList = mutableListOf() - - var pages: List - get() = _pages.toList() - set(value) { - _pages.clear() - _pages.addAll(value) - } - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "media_file", joinColumns = [JoinColumn(name = "media_id")]) - @Column(name = "files") - private var _files: MutableList = mutableListOf() - - var files: List - get() = _files.toList() - set(value) { - _files.clear() - _files.addAll(value) - } - - fun reset() { - status = Status.UNKNOWN - mediaType = null - thumbnail = null - comment = null - _pages.clear() - _files.clear() - } - - init { - this.pages = pages.toList() - this.files = files.toList() - } + fun copy( + status: Status = this.status, + mediaType: String? = this.mediaType, + thumbnail: ByteArray? = this.thumbnail, + pages: List = this.pages.toList(), + files: List = this.files.toList(), + comment: String? = this.comment, + bookId: Long = this.bookId, + createdDate: LocalDateTime = this.createdDate, + lastModifiedDate: LocalDateTime = this.lastModifiedDate + ) = + Media( + status = status, + mediaType = mediaType, + thumbnail = thumbnail, + pages = pages, + files = files, + comment = comment, + bookId = bookId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) enum class Status { UNKNOWN, ERROR, READY, UNSUPPORTED } + override fun toString(): String = + "Media(status=$status, mediaType=$mediaType, pages=$pages, files=$files, comment=$comment, bookId=$bookId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt index 96801222e..496621632 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaContainerEntry.kt @@ -1,6 +1,6 @@ package org.gotson.komga.domain.model -class MediaContainerEntry( +data class MediaContainerEntry( val name: String, val mediaType: String? = null, val comment: String? = null diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt index 093c5ac0d..5626bfb96 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt @@ -1,76 +1,16 @@ package org.gotson.komga.domain.model -import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy import java.net.URL import java.time.LocalDateTime -import java.util.* -import javax.persistence.Cacheable -import javax.persistence.CascadeType -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.FetchType -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.ManyToOne -import javax.persistence.OneToMany -import javax.persistence.OneToOne -import javax.persistence.Table -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotNull -private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - -@Entity -@Table(name = "series") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series") -class Series( - @NotBlank - @Column(name = "name", nullable = false) - var name: String, - - @Column(name = "url", nullable = false) - var url: URL, - - @Column(name = "file_last_modified", nullable = false) +data class Series( + val name: String, + val url: URL, var fileLastModified: LocalDateTime, - books: Iterable + val id: Long = 0, + val libraryId: Long = 0, -) : AuditableEntity() { - @Id - @GeneratedValue - @Column(name = "id", nullable = false, unique = true) - var id: Long = 0 - - @NotNull - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "library_id", nullable = false) - lateinit var library: Library - - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "series") - @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series.collection.books") - private var _books: MutableList = mutableListOf() - - var books: List - get() = _books.toList() - set(value) { - _books.clear() - value.forEach { it.series = this } - _books.addAll(value.sortedWith(compareBy(natSortComparator) { it.name })) - _books.forEachIndexed { index, book -> book.number = index + 1 } - } - - @OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - @JoinColumn(name = "metadata_id", nullable = false) - var metadata: SeriesMetadata = SeriesMetadata(title = name) - - init { - this.books = books.toList() - } - - override fun toString(): String = "Series($id, ${url.toURI().path})" -} + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt index 05c5987a3..96e700631 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt @@ -1,70 +1,51 @@ package org.gotson.komga.domain.model -import org.hibernate.annotations.Cache -import org.hibernate.annotations.CacheConcurrencyStrategy -import javax.persistence.Cacheable -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.EnumType -import javax.persistence.Enumerated -import javax.persistence.GeneratedValue -import javax.persistence.Id -import javax.persistence.Table -import javax.validation.constraints.NotBlank +import java.time.LocalDateTime -@Entity -@Table(name = "series_metadata") -@Cacheable -@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series_metadata") -class SeriesMetadata : AuditableEntity { - constructor( - status: Status = Status.ONGOING, - title: String, - titleSort: String = title - ) : super() { - this.status = status - this.title = title - this.titleSort = titleSort - } +class SeriesMetadata( + val status: Status = Status.ONGOING, + title: String, + titleSort: String = title, - @Id - @GeneratedValue - @Column(name = "id", nullable = false, unique = true) - val id: Long = 0 + val statusLock: Boolean = false, + val titleLock: Boolean = false, + val titleSortLock: Boolean = false, + val seriesId: Long = 0, - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: Status - - @NotBlank - @Column(name = "title", nullable = false) - var title: String - set(value) { - require(value.isNotBlank()) { "title must not be blank" } - field = value.trim() - } - - @NotBlank - @Column(name = "title_sort", nullable = false) - var titleSort: String - set(value) { - require(value.isNotBlank()) { "titleSort must not be blank" } - field = value.trim() - } - - - @Column(name = "status_lock", nullable = false) - var statusLock: Boolean = false - - @Column(name = "title_lock", nullable = false) - var titleLock: Boolean = false - - @Column(name = "title_sort_lock", nullable = false) - var titleSortLock: Boolean = false + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = LocalDateTime.now() +) : Auditable() { + val title = title.trim() + val titleSort = titleSort.trim() + fun copy( + status: Status = this.status, + title: String = this.title, + titleSort: String = this.titleSort, + statusLock: Boolean = this.statusLock, + titleLock: Boolean = this.titleLock, + titleSortLock: Boolean = this.titleSortLock, + seriesId: Long = this.seriesId, + createdDate: LocalDateTime = this.createdDate, + lastModifiedDate: LocalDateTime = this.lastModifiedDate + ) = + SeriesMetadata( + status = status, + title = title, + titleSort = titleSort, + statusLock = statusLock, + titleLock = titleLock, + titleSortLock = titleSortLock, + seriesId = seriesId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) enum class Status { ENDED, ONGOING, ABANDONED, HIATUS } + + override fun toString(): String = + "SeriesMetadata(status=$status, statusLock=$statusLock, titleLock=$titleLock, titleSortLock=$titleSortLock, seriesId=$seriesId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', titleSort='$titleSort')" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt index ee13cf50b..6dbf08c3a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt @@ -1,6 +1,6 @@ package org.gotson.komga.domain.model -class SeriesMetadataPatch( +data class SeriesMetadataPatch( val title: String?, val titleSort: String?, val status: SeriesMetadata.Status? diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt new file mode 100644 index 000000000..89e91589a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +data class SeriesSearch( + val libraryIds: Collection = emptyList(), + val searchTerm: String? = null, + val metadataStatus: Collection = emptyList() +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt index 6d0f0c2b8..2819e5d1f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookMetadataRepository.kt @@ -1,16 +1,15 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.BookMetadata -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import org.springframework.stereotype.Repository -@Repository -interface BookMetadataRepository : JpaRepository { - @Query( - value = "select distinct a.name from BOOK_METADATA_AUTHOR a where a.name ilike CONCAT('%', :search, '%') order by a.name", - nativeQuery = true - ) - fun findAuthorsByName(@Param("search") search: String): List +interface BookMetadataRepository { + fun findById(bookId: Long): BookMetadata + fun findByIdOrNull(bookId: Long): BookMetadata? + + fun findAuthorsByName(search: String): List + + fun insert(metadata: BookMetadata): BookMetadata + fun update(metadata: BookMetadata) + + fun delete(bookId: Long) } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt index fcf5c7671..9ecdf0d9c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -1,36 +1,26 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.Book -import org.gotson.komga.domain.model.Library -import org.gotson.komga.domain.model.Media -import org.hibernate.annotations.QueryHints.CACHEABLE -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.JpaSpecificationExecutor -import org.springframework.data.jpa.repository.QueryHints -import org.springframework.stereotype.Repository -import java.net.URL -import javax.persistence.QueryHint +import org.gotson.komga.domain.model.BookSearch -@Repository -interface BookRepository : JpaRepository, JpaSpecificationExecutor { - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - override fun findAll(pageable: Pageable): Page +interface BookRepository { + fun findByIdOrNull(bookId: Long): Book? + fun findBySeriesId(seriesId: Long): Collection + fun findAll(): Collection + fun findAll(bookSearch: BookSearch): Collection - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findAllBySeriesId(seriesId: Long, pageable: Pageable): Page + fun getLibraryId(bookId: Long): Long? + fun findFirstIdInSeries(seriesId: Long): Long? + fun findAllIdBySeriesId(seriesId: Long): Collection + fun findAllIdByLibraryId(libraryId: Long): Collection + fun findAllId(bookSearch: BookSearch): Collection - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findAllByMediaStatusInAndSeriesId(status: Collection, seriesId: Long, pageable: Pageable): Page + fun insert(book: Book): Book + fun update(book: Book) - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findBySeriesLibraryIn(seriesLibrary: Collection, pageable: Pageable): Page + fun delete(bookId: Long) + fun deleteAll(bookIds: List) + fun deleteAll() - fun findBySeriesLibraryIn(seriesLibrary: Collection): List - fun findBySeriesLibrary(seriesLibrary: Library): List - - fun findByUrl(url: URL): Book? - fun findAllByMediaStatusAndSeriesLibrary(status: Media.Status, library: Library): List - fun findAllByMediaThumbnailIsNull(): List + fun count(): Long } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt index 28b9768cc..99827a226 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt @@ -1,13 +1,19 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.KomgaUser -import org.gotson.komga.domain.model.Library -import org.springframework.data.repository.CrudRepository -import org.springframework.stereotype.Repository -@Repository -interface KomgaUserRepository : CrudRepository { +interface KomgaUserRepository { + fun count(): Long + + fun findAll(): Collection + fun findByIdOrNull(id: Long): KomgaUser? + + fun save(user: KomgaUser): KomgaUser + fun saveAll(users: Iterable): Collection + + fun delete(user: KomgaUser) + fun deleteAll() + fun existsByEmailIgnoreCase(email: String): Boolean fun findByEmailIgnoreCase(email: String): KomgaUser? - fun findBySharedLibrariesContaining(library: Library): List } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt index d0d4d8366..0772187fd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/LibraryRepository.kt @@ -1,20 +1,18 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.Library -import org.hibernate.annotations.QueryHints.CACHEABLE -import org.springframework.data.domain.Sort -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.QueryHints -import org.springframework.stereotype.Repository -import javax.persistence.QueryHint -@Repository -interface LibraryRepository : JpaRepository { - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - override fun findAll(sort: Sort): List - - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - override fun findAllById(ids: Iterable): List +interface LibraryRepository { + fun findByIdOrNull(libraryId: Long): Library? + fun findAll(): Collection + fun findAllById(libraryIds: Collection): Collection fun existsByName(name: String): Boolean + + fun delete(libraryId: Long) + fun deleteAll() + + fun insert(library: Library): Library + + fun count(): Long } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt index a559ba9d3..e5a25a152 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt @@ -1,8 +1,14 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.Media -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository -@Repository -interface MediaRepository : JpaRepository +interface MediaRepository { + fun findById(bookId: Long): Media + + fun getThumbnail(bookId: Long): ByteArray? + + fun insert(media: Media): Media + fun update(media: Media) + + fun delete(bookId: Long) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt new file mode 100644 index 000000000..ed00970af --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesMetadataRepository.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.SeriesMetadata + +interface SeriesMetadataRepository { + fun findById(seriesId: Long): SeriesMetadata + fun findByIdOrNull(seriesId: Long): SeriesMetadata? + + fun insert(metadata: SeriesMetadata): SeriesMetadata + fun update(metadata: SeriesMetadata) + + fun delete(seriesId: Long) + + fun count(): Long +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt index 6bb013fd9..4d4292e3d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt @@ -1,43 +1,25 @@ package org.gotson.komga.domain.persistence -import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Series -import org.hibernate.annotations.QueryHints.CACHEABLE -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.Sort -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.JpaSpecificationExecutor -import org.springframework.data.jpa.repository.Query -import org.springframework.data.jpa.repository.QueryHints -import org.springframework.stereotype.Repository +import org.gotson.komga.domain.model.SeriesSearch import java.net.URL -import javax.persistence.QueryHint -@Repository -interface SeriesRepository : JpaRepository, JpaSpecificationExecutor { - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - override fun findAll(pageable: Pageable): Page - - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findByLibraryIn(libraries: Collection, sort: Sort): List - - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findByLibraryIn(libraries: Collection, page: Pageable): Page - - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findByLibraryId(libraryId: Long, sort: Sort): List - - @Query("select s from Series s where s.createdDate <> s.lastModifiedDate") - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findRecentlyUpdated(pageable: Pageable): Page - - @Query("select s from Series s where s.createdDate <> s.lastModifiedDate and s.library in ?1") - @QueryHints(QueryHint(name = CACHEABLE, value = "true")) - fun findRecentlyUpdatedByLibraryIn(libraries: Collection, pageable: Pageable): Page - - fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection): List +interface SeriesRepository { + fun findAll(): Collection + fun findByIdOrNull(seriesId: Long): Series? + fun findByLibraryId(libraryId: Long): Collection + fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection): Collection fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series? - fun deleteByLibraryId(libraryId: Long) + fun findAll(search: SeriesSearch): Collection + fun getLibraryId(seriesId: Long): Long? + + fun insert(series: Series): Series + fun update(series: Series) + + fun delete(seriesId: Long) + fun deleteAll() + fun deleteAll(seriesIds: Collection) + + fun count(): Long } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt index 8bc8ad648..89c1d45e0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookPage import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor @@ -17,7 +18,8 @@ private val logger = KotlinLogging.logger {} class BookAnalyzer( private val contentDetector: ContentDetector, extractors: List, - private val imageConverter: ImageConverter + private val imageConverter: ImageConverter, + private val mediaRepository: MediaRepository ) { val supportedMediaTypes = extractors @@ -78,17 +80,19 @@ class BookAnalyzer( fun regenerateThumbnail(book: Book): Media { logger.info { "Regenerate thumbnail for book: $book" } - if (book.media.status != Media.Status.READY) { + val media = mediaRepository.findById(book.id) + + if (media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" } throw MediaNotReadyException() } - val thumbnail = generateThumbnail(book, book.media.mediaType!!, book.media.pages.first().fileName) + val thumbnail = generateThumbnail(book, media.mediaType!!, media.pages.first().fileName) return Media( - mediaType = book.media.mediaType, + mediaType = media.mediaType, status = Media.Status.READY, - pages = book.media.pages, + pages = media.pages, thumbnail = thumbnail ) } @@ -110,17 +114,19 @@ class BookAnalyzer( fun getPageContent(book: Book, number: Int): ByteArray { logger.info { "Get page #$number for book: $book" } - if (book.media.status != Media.Status.READY) { + val media = mediaRepository.findById(book.id) + + if (media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot get pages" } throw MediaNotReadyException() } - if (number > book.media.pages.size || number <= 0) { - logger.error { "Page number #$number is out of bounds. Book has ${book.media.pages.size} pages" } + if (number > media.pages.size || number <= 0) { + logger.error { "Page number #$number is out of bounds. Book has ${media.pages.size} pages" } throw IndexOutOfBoundsException("Page $number does not exist") } - return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), book.media.pages[number - 1].fileName) + return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), media.pages[number - 1].fileName) } @Throws( @@ -129,11 +135,13 @@ class BookAnalyzer( fun getFileContent(book: Book, fileName: String): ByteArray { logger.info { "Get file $fileName for book: $book" } - if (book.media.status != Media.Status.READY) { + val media = mediaRepository.findById(book.id) + + if (media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot get files" } throw MediaNotReadyException() } - return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), fileName) + return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), fileName) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/application/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt similarity index 75% rename from komga/src/main/kotlin/org/gotson/komga/application/service/BookLifecycle.kt rename to komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index 9657006ec..f656c4459 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -1,4 +1,4 @@ -package org.gotson.komga.application.service +package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Book @@ -6,8 +6,9 @@ import org.gotson.komga.domain.model.BookPageContent 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.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository -import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType import org.springframework.stereotype.Service @@ -17,30 +18,32 @@ private val logger = KotlinLogging.logger {} @Service class BookLifecycle( private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, private val bookAnalyzer: BookAnalyzer, private val imageConverter: ImageConverter ) { fun analyzeAndPersist(book: Book) { logger.info { "Analyze and persist book: $book" } - try { - book.media = bookAnalyzer.analyze(book) + val media = try { + bookAnalyzer.analyze(book) } catch (ex: Exception) { logger.error(ex) { "Error while analyzing book: $book" } - book.media = Media(status = Media.Status.ERROR, comment = ex.message) - } - bookRepository.save(book) + Media(status = Media.Status.ERROR, comment = ex.message) + }.copy(bookId = book.id) + mediaRepository.update(media) } fun regenerateThumbnailAndPersist(book: Book) { logger.info { "Regenerate thumbnail and persist book: $book" } - try { - book.media = bookAnalyzer.regenerateThumbnail(book) + val media = try { + bookAnalyzer.regenerateThumbnail(book) } catch (ex: Exception) { logger.error(ex) { "Error while recreating thumbnail" } - book.media = Media(status = Media.Status.ERROR) - } - bookRepository.save(book) + Media(status = Media.Status.ERROR) + }.copy(bookId = book.id) + mediaRepository.update(media) } @Throws( @@ -49,8 +52,9 @@ class BookLifecycle( IndexOutOfBoundsException::class ) fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent { + val media = mediaRepository.findById(book.id) val pageContent = bookAnalyzer.getPageContent(book, number) - val pageMediaType = book.media.pages[number - 1].mediaType + val pageMediaType = media.pages[number - 1].mediaType if (resizeTo != null) { val targetFormat = ImageType.JPEG @@ -88,4 +92,13 @@ class BookLifecycle( return BookPageContent(number, pageContent, pageMediaType) } } + + fun delete(bookId: Long) { + logger.info { "Delete book id: $bookId" } + + mediaRepository.delete(bookId) + bookMetadataRepository.delete(bookId) + + bookRepository.delete(bookId) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt index 4bbd3d7cc..0a42fb6f4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt @@ -25,14 +25,14 @@ class FileSystemScanner( val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub") - fun scanRootFolder(root: Path): List { + fun scanRootFolder(root: Path): Map> { logger.info { "Scanning folder: $root" } logger.info { "Supported extensions: $supportedExtensions" } logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" } if (komgaProperties.filesystemScannerForceDirectoryModifiedTime) logger.info { "Force directory modified time: active" } - lateinit var scannedSeries: List + lateinit var scannedSeries: Map> measureTime { scannedSeries = Files.walk(root, FileVisitOption.FOLLOW_LINKS).use { dirsStream -> @@ -72,13 +72,12 @@ class FileSystemScanner( fileLastModified = if (komgaProperties.filesystemScannerForceDirectoryModifiedTime) maxOf(dir.getUpdatedTime(), books.map { it.fileLastModified }.max()!!) - else dir.getUpdatedTime(), - books = books.toMutableList() - ) - }.toList() + else dir.getUpdatedTime() + ) to books + }.toMap() } }.also { - val countOfBooks = scannedSeries.sumBy { it.books.size } + val countOfBooks = scannedSeries.values.sumBy { it.size } logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt similarity index 56% rename from komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsLifecycle.kt rename to komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt index ec0e19ce4..9730b25e4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaUserDetailsLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt @@ -1,22 +1,21 @@ -package org.gotson.komga.infrastructure.security +package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.KomgaUser -import org.gotson.komga.domain.model.UserRoles +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.persistence.KomgaUserRepository -import org.springframework.security.core.GrantedAuthority +import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional private val logger = KotlinLogging.logger {} @Service -class KomgaUserDetailsLifecycle( +class KomgaUserLifecycle( private val userRepository: KomgaUserRepository, private val passwordEncoder: PasswordEncoder, private val sessionRegistry: SessionRegistry @@ -28,39 +27,31 @@ class KomgaUserDetailsLifecycle( KomgaPrincipal(it) } ?: throw UsernameNotFoundException(username) - @Transactional fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails { userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser -> logger.info { "Changing password for user ${user.username}" } - komgaUser.password = passwordEncoder.encode(newPassword) - userRepository.save(komgaUser) + val updatedUser = komgaUser.copy(password = passwordEncoder.encode(newPassword)) + userRepository.save(updatedUser) - if (expireSessions) expireSessions(komgaUser) + if (expireSessions) expireSessions(updatedUser) - return KomgaPrincipal(komgaUser) + return KomgaPrincipal(updatedUser) } ?: throw UsernameNotFoundException(user.username) } fun countUsers() = userRepository.count() - @Transactional @Throws(UserEmailAlreadyExistsException::class) - fun createUser(user: UserDetails): UserDetails { - if (userRepository.existsByEmailIgnoreCase(user.username)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${user.username}") + fun createUser(komgaUser: KomgaUser): KomgaUser { + if (userRepository.existsByEmailIgnoreCase(komgaUser.email)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${komgaUser.email}") - val komgaUser = KomgaUser( - email = user.username, - password = passwordEncoder.encode(user.password), - roles = user.authorities.toUserRoles() - ) - - userRepository.save(komgaUser) - logger.info { "Created user: ${komgaUser.email}, roles: ${komgaUser.roles}" } - return KomgaPrincipal(komgaUser) + val createdUser = userRepository.save(komgaUser.copy(password = passwordEncoder.encode(komgaUser.password))) + logger.info { "User created: $createdUser" } + return createdUser } fun deleteUser(user: KomgaUser) { - logger.info { "Deleting user: ${user.email}" } + logger.info { "Deleting user: $user" } userRepository.delete(user) expireSessions(user) } @@ -79,16 +70,3 @@ class KomgaUserDetailsLifecycle( } -private fun Iterable.toUserRoles() = - this.filter { it.authority.startsWith("ROLE_") } - .map { it.authority.removePrefix("ROLE_") } - .mapNotNull { - try { - UserRoles.valueOf(it) - } catch (e: Exception) { - null - } - } - .toMutableSet() - -class UserEmailAlreadyExistsException(message: String) : Exception(message) diff --git a/komga/src/main/kotlin/org/gotson/komga/application/service/LibraryLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt similarity index 69% rename from komga/src/main/kotlin/org/gotson/komga/application/service/LibraryLifecycle.kt rename to komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt index 19f1f7c4b..164b2b35c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/service/LibraryLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryLifecycle.kt @@ -1,4 +1,4 @@ -package org.gotson.komga.application.service +package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.application.tasks.TaskReceiver @@ -6,11 +6,9 @@ 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.KomgaUserRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.io.FileNotFoundException import java.nio.file.Files @@ -19,8 +17,8 @@ private val logger = KotlinLogging.logger {} @Service class LibraryLifecycle( private val libraryRepository: LibraryRepository, + private val seriesLifecycle: SeriesLifecycle, private val seriesRepository: SeriesRepository, - private val userRepository: KomgaUserRepository, private val taskReceiver: TaskReceiver ) { @@ -49,25 +47,19 @@ class LibraryLifecycle( throw PathContainedInPath("Library path ${library.path()} is a parent of existing library ${it.name}: ${it.path()}") } - libraryRepository.save(library) - taskReceiver.scanLibrary(library) - - return library + return libraryRepository.insert(library).let { + taskReceiver.scanLibrary(it.id) + it + } } - @Transactional fun deleteLibrary(library: Library) { - logger.info { "Deleting library: ${library.name} with root folder: ${library.root}" } + logger.info { "Deleting library: $library" } - logger.info { "Delete all series for this library" } - seriesRepository.deleteByLibraryId(library.id) - - logger.info { "Remove shared library access for all users" } - userRepository.findBySharedLibrariesContaining(library).let { users -> - users.forEach { user -> user.sharedLibraries.removeIf { it.id == library.id } } - userRepository.saveAll(users) + seriesRepository.findByLibraryId(library.id).forEach { + seriesLifecycle.deleteSeries(it.id) } - libraryRepository.delete(library) + libraryRepository.delete(library.id) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt index 9c0903af0..de9f6534b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt @@ -3,9 +3,9 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.nio.file.Paths import java.time.temporal.ChronoUnit @@ -15,54 +15,83 @@ private val logger = KotlinLogging.logger {} class LibraryScanner( private val fileSystemScanner: FileSystemScanner, private val seriesRepository: SeriesRepository, - private val bookRepository: BookRepository + private val bookRepository: BookRepository, + private val bookLifecycle: BookLifecycle, + private val mediaRepository: MediaRepository, + private val seriesLifecycle: SeriesLifecycle ) { - @Transactional fun scanRootFolder(library: Library) { logger.info { "Updating library: $library" } - val scannedSeries = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI())) + val scannedSeries = + fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI())) + .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, deleting all existing series" } - seriesRepository.deleteByLibraryId(library.id) + seriesRepository.findByLibraryId(library.id).forEach { + seriesLifecycle.deleteSeries(it.id) + } } else { - scannedSeries.map { it.url }.let { urls -> + scannedSeries.keys.map { it.url }.let { urls -> seriesRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach { logger.info { "Deleting series not on disk anymore: $it" } - seriesRepository.delete(it) + seriesLifecycle.deleteSeries(it.id) } } } - scannedSeries.forEach { newSeries -> + scannedSeries.forEach { (newSeries, newBooks) -> val existingSeries = seriesRepository.findByLibraryIdAndUrl(library.id, newSeries.url) // if series does not exist, save it if (existingSeries == null) { logger.info { "Adding new series: $newSeries" } - seriesRepository.save(newSeries.also { it.library = library }) + val createdSeries = seriesLifecycle.createSeries(newSeries) + seriesLifecycle.addBooks(createdSeries, newBooks) + seriesLifecycle.sortBooks(createdSeries) } else { // if series already exists, update it if (newSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) { logger.info { "Series changed on disk, updating: $existingSeries" } existingSeries.fileLastModified = newSeries.fileLastModified + seriesRepository.update(existingSeries) + // update list of books with existing entities if they exist - existingSeries.books = newSeries.books.map { newBook -> - val existingBook = bookRepository.findByUrl(newBook.url) ?: newBook + val existingBooks = bookRepository.findBySeriesId(existingSeries.id) - if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) { - logger.info { "Book changed on disk, update and reset media status: $existingBook" } - existingBook.fileLastModified = newBook.fileLastModified - existingBook.fileSize = newBook.fileSize - existingBook.media.reset() + // update existing books + newBooks.forEach { newBook -> + existingBooks.find { it.url == newBook.url }?.let { existingBook -> + if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) { + logger.info { "Book changed on disk, update and reset media status: $existingBook" } + val updatedBook = existingBook.copy( + fileLastModified = newBook.fileLastModified, + fileSize = newBook.fileSize + ) + mediaRepository.findById(existingBook.id).let { + mediaRepository.update(it.reset()) + } + bookRepository.update(updatedBook) + } } - existingBook - }.toMutableList() + } - seriesRepository.save(existingSeries) + // remove books not present anymore + existingBooks + .filterNot { existingBook -> newBooks.map { it.url }.contains(existingBook.url) } + .forEach { bookLifecycle.delete(it.id) } + + // add new books + val booksToAdd = newBooks.filterNot { newBook -> existingBooks.map { it.url }.contains(newBook.url) } + seriesLifecycle.addBooks(existingSeries, booksToAdd) + + // sort all books + seriesLifecycle.sortBooks(existingSeries) } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt index 6f5d99177..3302aef98 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt @@ -1,9 +1,9 @@ package org.gotson.komga.domain.service import mu.KotlinLogging -import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadataPatch -import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadataPatch import org.springframework.stereotype.Service @@ -12,113 +12,32 @@ private val logger = KotlinLogging.logger {} @Service class MetadataApplier { - fun apply(patch: BookMetadataPatch, book: Book) { - logger.debug { "Apply metadata for book: $book" } + private fun getIfNotLocked(original: T, patched: T?, lock: Boolean): T = + if (patched != null && !lock) patched + else original - with(book.metadata) { - patch.title?.let { - if (!titleLock) { - logger.debug { "Update title: $it" } - title = it - } else - logger.debug { "title is locked, skipping" } - } - - - patch.summary?.let { - if (!summaryLock) { - logger.debug { "Update summary: $it" } - summary = it - } else - logger.debug { "summary is locked, skipping" } - } - - patch.number?.let { - if (!numberLock) { - logger.debug { "Update number: $it" } - number = it - } else - logger.debug { "number is locked, skipping" } - } - - patch.numberSort?.let { - if (!numberSortLock) { - logger.debug { "Update numberSort: $it" } - numberSort = it - } else - logger.debug { "numberSort is locked, skipping" } - } - - patch.readingDirection?.let { - if (!readingDirectionLock) { - logger.debug { "Update readingDirection: $it" } - readingDirection = it - } else - logger.debug { "readingDirection is locked, skipping" } - } - - patch.releaseDate?.let { - if (!releaseDateLock) { - logger.debug { "Update releaseDate: $it" } - releaseDate = it - } else - logger.debug { "releaseDate is locked, skipping" } - } - - patch.ageRating?.let { - if (!ageRatingLock) { - logger.debug { "Update ageRating: $it" } - ageRating = it - } else - logger.debug { "ageRating is locked, skipping" } - } - - patch.publisher?.let { - if (!publisherLock) { - logger.debug { "Update publisher: $it" } - publisher = it - } else - logger.debug { "publisher is locked, skipping" } - } - - patch.authors?.let { - if (!authorsLock) { - logger.debug { "Update authors: $it" } - authors = it.toMutableList() - } else - logger.debug { "authors is locked, skipping" } - } + 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), + readingDirection = getIfNotLocked(readingDirection, patch.readingDirection, readingDirectionLock), + releaseDate = getIfNotLocked(releaseDate, patch.releaseDate, releaseDateLock), + ageRating = getIfNotLocked(ageRating, patch.ageRating, ageRatingLock), + publisher = getIfNotLocked(publisher, patch.publisher, publisherLock), + authors = getIfNotLocked(authors, patch.authors, authorsLock) + ) } - } - fun apply(patch: SeriesMetadataPatch, series: Series) { - logger.debug { "Apply metadata for series: $series" } - - with(series.metadata) { - patch.title?.let { - if (!titleLock) { - logger.debug { "Update title: $it" } - title = it - } else - logger.debug { "title is locked, skipping" } - } - - patch.titleSort?.let { - if (!titleSortLock) { - logger.debug { "Update titleSort: $it" } - titleSort = it - } else - logger.debug { "titleSort is locked, skipping" } - } - - patch.status?.let { - if (!statusLock) { - logger.debug { "status number: $it" } - status = it - } else - logger.debug { "status is locked, skipping" } - } + 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) + ) } - } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt new file mode 100644 index 000000000..68afaf3bd --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt @@ -0,0 +1,52 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class MetadataLifecycle( + private val bookMetadataProviders: List, + private val metadataApplier: MetadataApplier, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesMetadataRepository: SeriesMetadataRepository +) { + + fun refreshMetadata(book: Book) { + logger.info { "Refresh metadata for book: $book" } + val media = mediaRepository.findById(book.id) + + bookMetadataProviders.forEach { provider -> + provider.getBookMetadataFromBook(book, media)?.let { bPatch -> + + bookMetadataRepository.findById(book.id).let { + logger.debug { "Original metadata: $it" } + val patched = metadataApplier.apply(bPatch, it) + logger.debug { "Patched metadata: $patched" } + + bookMetadataRepository.update(patched) + } + + bPatch.series?.let { sPatch -> + seriesMetadataRepository.findById(book.seriesId).let { + logger.debug { "Apply metadata for series: ${book.seriesId}" } + + logger.debug { "Original metadata: $it" } + val patched = metadataApplier.apply(sPatch, it) + logger.debug { "Patched metadata: $patched" } + + seriesMetadataRepository.update(patched) + } + } + } + } + } + +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt new file mode 100644 index 000000000..f8b332c0c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt @@ -0,0 +1,93 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadata +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.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.springframework.stereotype.Service +import java.util.* + +private val logger = KotlinLogging.logger {} +private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + +@Service +class SeriesLifecycle( + private val bookRepository: BookRepository, + private val bookLifecycle: BookLifecycle, + private val mediaRepository: MediaRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesRepository: SeriesRepository, + private val seriesMetadataRepository: SeriesMetadataRepository +) { + + fun sortBooks(series: Series) { + val books = bookRepository.findBySeriesId(series.id) + + val sorted = books.sortedWith(compareBy(natSortComparator) { it.name }) + sorted.forEachIndexed { index, book -> + val number = index + 1 + bookRepository.update(book.copy(number = number)) + + bookMetadataRepository.findById(book.id).let { metadata -> + val renumbered = metadata.copy( + number = if (!metadata.numberLock) number.toString() else metadata.number, + numberSort = if (!metadata.numberSortLock) number.toFloat() else metadata.numberSort + ) + if (!metadata.numberLock || !metadata.numberSortLock) + bookMetadataRepository.update(renumbered) + } + } + } + + 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" } + } + + booksToAdd.forEach { book -> + val createdBook = bookRepository.insert(book.copy(seriesId = series.id)) + + // create associated media + mediaRepository.insert(Media(bookId = createdBook.id)) + + // create associated metadata + bookMetadataRepository.insert(BookMetadata( + title = createdBook.name, + number = createdBook.number.toString(), + numberSort = createdBook.number.toFloat(), + bookId = createdBook.id + )) + } + } + + fun createSeries(series: Series): Series { + val createdSeries = seriesRepository.insert(series) + + seriesMetadataRepository.insert( + SeriesMetadata( + title = createdSeries.name, + seriesId = createdSeries.id + ) + ) + + return createdSeries + } + + fun deleteSeries(seriesId: Long) { + logger.info { "Delete series id: $seriesId" } + + bookRepository.findBySeriesId(seriesId).forEach { + bookLifecycle.delete(it.id) + } + + seriesRepository.delete(seriesId) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt new file mode 100644 index 000000000..d988a5f38 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt @@ -0,0 +1,177 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.jooq.Sequences +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.BookRecord +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.stereotype.Component +import java.net.URL +import java.time.LocalDateTime + +@Component +class BookDao( + private val dsl: DSLContext +) : BookRepository { + + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val a = Tables.BOOK_METADATA_AUTHOR + + override fun findByIdOrNull(bookId: Long): Book? = + dsl.selectFrom(b) + .where(b.ID.eq(bookId)) + .fetchOneInto(b) + ?.toDomain() + + override fun findBySeriesId(seriesId: Long): Collection = + dsl.selectFrom(b) + .where(b.SERIES_ID.eq(seriesId)) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAll(): Collection = + dsl.select(*b.fields()) + .from(b) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAll(bookSearch: BookSearch): Collection = + dsl.select(*b.fields()) + .from(b) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .where(bookSearch.toCondition()) + .fetchInto(b) + .map { it.toDomain() } + + + override fun getLibraryId(bookId: Long): Long? = + dsl.select(b.LIBRARY_ID) + .from(b) + .where(b.ID.eq(bookId)) + .fetchOne(0, Long::class.java) + + override fun findFirstIdInSeries(seriesId: Long): Long? = + dsl.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(0, Long::class.java) + + override fun findAllIdBySeriesId(seriesId: Long): Collection = + dsl.select(b.ID) + .from(b) + .where(b.SERIES_ID.eq(seriesId)) + .fetch(0, Long::class.java) + + override fun findAllIdByLibraryId(libraryId: Long): Collection = + dsl.select(b.ID) + .from(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .fetch(0, Long::class.java) + + override fun findAllId(bookSearch: BookSearch): Collection { + val conditions = bookSearch.toCondition() + + return dsl.select(b.ID) + .from(b) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .where(conditions) + .fetch(0, Long::class.java) + } + + + override fun insert(book: Book): Book { + val id = dsl.nextval(Sequences.HIBERNATE_SEQUENCE) + dsl.insertInto(b) + .set(b.ID, id) + .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.LIBRARY_ID, book.libraryId) + .set(b.SERIES_ID, book.seriesId) + .execute() + + return findByIdOrNull(id)!! + } + + override fun update(book: Book) { + dsl.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.LIBRARY_ID, book.libraryId) + .set(b.SERIES_ID, book.seriesId) + .set(b.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(b.ID.eq(book.id)) + .execute() + } + + override fun delete(bookId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(b).where(b.ID.eq(bookId)).execute() + } + } + } + + override fun deleteAll(bookIds: List) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(b).where(b.ID.`in`(bookIds)).execute() + } + } + } + + override fun deleteAll() { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(b).execute() + } + } + } + + override fun count(): Long = dsl.fetchCount(b).toLong() + + + private fun BookSearch.toCondition(): Condition { + var c: Condition = DSL.trueCondition() + + if (libraryIds.isNotEmpty()) c = c.and(b.LIBRARY_ID.`in`(libraryIds)) + if (seriesIds.isNotEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds)) + searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } + if (mediaStatus.isNotEmpty()) c = c.and(m.STATUS.`in`(mediaStatus)) + + return c + } + + private fun BookRecord.toDomain() = + Book( + name = name, + url = URL(url), + fileLastModified = fileLastModified, + fileSize = fileSize, + id = id, + libraryId = libraryId, + seriesId = seriesId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + number = number + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt new file mode 100644 index 000000000..78420e276 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -0,0 +1,177 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.interfaces.rest.dto.AuthorDto +import org.gotson.komga.interfaces.rest.dto.BookDto +import org.gotson.komga.interfaces.rest.dto.BookMetadataDto +import org.gotson.komga.interfaces.rest.dto.MediaDto +import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord +import org.gotson.komga.jooq.tables.records.BookMetadataRecord +import org.gotson.komga.jooq.tables.records.BookRecord +import org.gotson.komga.jooq.tables.records.MediaRecord +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.jooq.impl.DSL +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.stereotype.Component +import java.net.URL + +@Component +class BookDtoDao( + private val dsl: DSLContext +) : BookDtoRepository { + + 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 mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray() + + private val sorts = mapOf( + "metadata.numberSort" to d.NUMBER_SORT, + "createdDate" to b.CREATED_DATE, + "lastModifiedDate" to b.LAST_MODIFIED_DATE, + "fileSize" to b.FILE_SIZE + ) + + override fun findAll(search: BookSearch, pageable: Pageable): Page { + val conditions = search.toCondition() + + val count = dsl.selectCount() + .from(b) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .where(conditions) + .fetchOne(0, Long::class.java) + + val orderBy = pageable.sort.toOrderBy(sorts) + + val dtos = selectBase() + .where(conditions) + .orderBy(orderBy) + .limit(pageable.pageSize) + .offset(pageable.offset) + .fetchAndMap() + + return PageImpl( + dtos, + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageable.sort), + count.toLong() + ) + } + + override fun findByIdOrNull(bookId: Long): BookDto? = + selectBase() + .where(b.ID.eq(bookId)) + .fetchAndMap() + .firstOrNull() + + override fun findPreviousInSeries(bookId: Long): BookDto? = findSibling(bookId, next = false) + + override fun findNextInSeries(bookId: Long): BookDto? = findSibling(bookId, next = true) + + + private fun findSibling(bookId: Long, next: Boolean): BookDto? { + val record = dsl.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, Long::class.java) + val numberSort = record.get(1, Float::class.java) + + return selectBase() + .where(b.SERIES_ID.eq(seriesId)) + .orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() }) + .seek(numberSort) + .limit(1) + .fetchAndMap() + .firstOrNull() + } + + private fun selectBase() = + dsl.select( + *b.fields(), + *mediaFields, + *d.fields(), + *a.fields() + ).from(b) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID)) + + private fun ResultQuery.fetchAndMap() = + fetchGroups( + { it.into(*b.fields(), *mediaFields, *d.fields()) }, { it.into(a) } + ).map { (r, ar) -> + val br = r.into(b) + val mr = r.into(m) + val dr = r.into(d) + br.toDto(mr.toDto(), dr.toDto(ar)) + } + + private fun BookSearch.toCondition(): Condition { + var c: Condition = DSL.trueCondition() + + if (libraryIds.isNotEmpty()) c = c.and(b.LIBRARY_ID.`in`(libraryIds)) + if (seriesIds.isNotEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds)) + searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } + if (mediaStatus.isNotEmpty()) c = c.and(m.STATUS.`in`(mediaStatus)) + + return c + } + + private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto) = + BookDto( + id = id, + seriesId = seriesId, + libraryId = libraryId, + name = name, + url = URL(url).toURI().path, + number = number, + created = createdDate.toUTC(), + lastModified = lastModifiedDate.toUTC(), + fileLastModified = fileLastModified.toUTC(), + sizeBytes = fileSize, + media = media, + metadata = metadata + ) + + private fun MediaRecord.toDto() = + MediaDto( + status = status, + mediaType = mediaType ?: "", + pagesCount = pageCount.toInt(), + comment = comment ?: "" + ) + + private fun BookMetadataRecord.toDto(ar: Collection) = + BookMetadataDto( + title = title, + titleLock = titleLock, + summary = summary, + summaryLock = summaryLock, + number = number, + numberLock = numberLock, + numberSort = numberSort, + numberSortLock = numberSortLock, + readingDirection = readingDirection ?: "", + readingDirectionLock = readingDirectionLock, + publisher = publisher, + publisherLock = publisherLock, + ageRating = ageRating, + ageRatingLock = ageRatingLock, + releaseDate = releaseDate, + releaseDateLock = releaseDateLock, + authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) }, + authorsLock = authorsLock + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt new file mode 100644 index 000000000..f0b1b86c6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt @@ -0,0 +1,171 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord +import org.gotson.komga.jooq.tables.records.BookMetadataRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class BookMetadataDao( + private val dsl: DSLContext +) : BookMetadataRepository { + + private val d = Tables.BOOK_METADATA + private val a = Tables.BOOK_METADATA_AUTHOR + + private val groupFields = arrayOf(*d.fields(), *a.fields()) + + override fun findById(bookId: Long): BookMetadata = + findOne(bookId).first() + + override fun findByIdOrNull(bookId: Long): BookMetadata? = + findOne(bookId).firstOrNull() + + private fun findOne(bookId: Long) = + dsl.select(*groupFields) + .from(d) + .leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID)) + .where(d.BOOK_ID.eq(bookId)) + .groupBy(*groupFields) + .fetchGroups( + { it.into(d) }, { it.into(a) } + ).map { (dr, ar) -> + dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }) + } + + override fun findAuthorsByName(search: String): List { + return dsl.selectDistinct(a.NAME) + .from(a) + .where(a.NAME.containsIgnoreCase(search)) + .orderBy(a.NAME) + .fetch(a.NAME) + } + + override fun insert(metadata: BookMetadata): BookMetadata { + dsl.transaction { config -> + with(config.dsl()) + { + insertInto(d) + .set(d.BOOK_ID, metadata.bookId) + .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.READING_DIRECTION, metadata.readingDirection?.toString()) + .set(d.READING_DIRECTION_LOCK, metadata.readingDirectionLock) + .set(d.PUBLISHER, metadata.publisher) + .set(d.PUBLISHER_LOCK, metadata.publisherLock) + .set(d.AGE_RATING, metadata.ageRating) + .set(d.AGE_RATING_LOCK, metadata.ageRatingLock) + .set(d.RELEASE_DATE, metadata.releaseDate) + .set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock) + .set(d.AUTHORS_LOCK, metadata.authorsLock) + .execute() + + insertAuthors(this, metadata) + } + } + + return findById(metadata.bookId) + } + + override fun update(metadata: BookMetadata) { + dsl.transaction { config -> + with(config.dsl()) + { + 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.READING_DIRECTION, metadata.readingDirection?.toString()) + .set(d.READING_DIRECTION_LOCK, metadata.readingDirectionLock) + .set(d.PUBLISHER, metadata.publisher) + .set(d.PUBLISHER_LOCK, metadata.publisherLock) + .set(d.AGE_RATING, metadata.ageRating) + .set(d.AGE_RATING_LOCK, metadata.ageRatingLock) + .set(d.RELEASE_DATE, metadata.releaseDate) + .set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock) + .set(d.AUTHORS_LOCK, metadata.authorsLock) + .set(d.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(d.BOOK_ID.eq(metadata.bookId)) + .execute() + + deleteFrom(a) + .where(a.BOOK_ID.eq(metadata.bookId)) + .execute() + + insertAuthors(this, metadata) + } + } + } + + private fun insertAuthors(dsl: DSLContext, metadata: BookMetadata) { + metadata.authors.forEach { + dsl.insertInto(a) + .set(a.BOOK_ID, metadata.bookId) + .set(a.NAME, it.name) + .set(a.ROLE, it.role) + .execute() + } + } + + override fun delete(bookId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(a).where(a.BOOK_ID.eq(bookId)).execute() + deleteFrom(d).where(d.BOOK_ID.eq(bookId)).execute() + } + } + } + + private fun BookMetadataRecord.toDomain(authors: Collection) = + BookMetadata( + title = title, + summary = summary, + number = number, + numberSort = numberSort, + readingDirection = readingDirection?.let { + BookMetadata.ReadingDirection.valueOf(readingDirection) + }, + publisher = publisher, + ageRating = ageRating, + releaseDate = releaseDate, + authors = authors.toMutableList(), + + bookId = bookId, + + createdDate = createdDate, + lastModifiedDate = lastModifiedDate, + + titleLock = titleLock, + summaryLock = summaryLock, + numberLock = numberLock, + numberSortLock = numberSortLock, + readingDirectionLock = readingDirectionLock, + publisherLock = publisherLock, + ageRatingLock = ageRatingLock, + releaseDateLock = releaseDateLock, + authorsLock = authorsLock + ) + + private fun BookMetadataAuthorRecord.toDomain() = + Author( + name = name, + role = role + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt new file mode 100644 index 000000000..4e6fc4ee0 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt @@ -0,0 +1,124 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.jooq.Sequences.HIBERNATE_SEQUENCE +import org.gotson.komga.jooq.Tables +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class KomgaUserDao( + private val dsl: DSLContext +) : KomgaUserRepository { + + private val u = Tables.USER + private val ul = Tables.USER_LIBRARY_SHARING + + override fun count(): Long = dsl.fetchCount(u).toLong() + + override fun findAll(): Collection = + selectBase() + .fetchAndMap() + + override fun findByIdOrNull(id: Long): KomgaUser? = + selectBase() + .where(u.ID.equal(id)) + .fetchAndMap() + .firstOrNull() + + private fun selectBase() = + dsl + .select(*u.fields()) + .select(ul.LIBRARY_ID) + .from(u) + .leftJoin(ul).onKey() + + private fun ResultQuery.fetchAndMap() = + this.fetchGroups({ it.into(u) }, { it.into(ul) }) + .map { (ur, ulr) -> + KomgaUser( + email = ur.email, + password = ur.password, + roleAdmin = ur.roleAdmin, + sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(), + sharedAllLibraries = ur.sharedAllLibraries, + id = ur.id, + createdDate = ur.createdDate, + lastModifiedDate = ur.lastModifiedDate + ) + } + + override fun save(user: KomgaUser): KomgaUser { + val id = if (user.id == 0L) dsl.nextval(HIBERNATE_SEQUENCE) else user.id + + dsl.transaction { config -> + with(config.dsl()) + { + mergeInto(u) + .using(dsl.selectOne()) + .on(u.ID.eq(id)) + .whenMatchedThenUpdate() + .set(u.EMAIL, user.email) + .set(u.PASSWORD, user.password) + .set(u.ROLE_ADMIN, user.roleAdmin) + .set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries) + .set(u.LAST_MODIFIED_DATE, LocalDateTime.now()) + .whenNotMatchedThenInsert(u.ID, u.EMAIL, u.PASSWORD, u.ROLE_ADMIN, u.SHARED_ALL_LIBRARIES) + .values(id, user.email, user.password, user.roleAdmin, user.sharedAllLibraries) + .execute() + + deleteFrom(ul) + .where(ul.USER_ID.eq(id)) + .execute() + + user.sharedLibrariesIds.forEach { + insertInto(ul) + .columns(ul.USER_ID, ul.LIBRARY_ID) + .values(id, it) + .execute() + } + } + } + + return findByIdOrNull(id)!! + } + + override fun saveAll(users: Iterable): Collection = + users.map { save(it) } + + override fun delete(user: KomgaUser) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(ul).where(ul.USER_ID.equal(user.id)).execute() + deleteFrom(u).where(u.ID.equal(user.id)).execute() + } + } + } + + override fun deleteAll() { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(ul).execute() + deleteFrom(u).execute() + } + } + } + + override fun existsByEmailIgnoreCase(email: String): Boolean = + dsl.fetchExists( + dsl.selectFrom(u) + .where(u.EMAIL.equalIgnoreCase(email)) + ) + + override fun findByEmailIgnoreCase(email: String): KomgaUser? = + selectBase() + .where(u.EMAIL.equalIgnoreCase(email)) + .fetchAndMap() + .firstOrNull() +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt new file mode 100644 index 000000000..a1bf38815 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt @@ -0,0 +1,86 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.jooq.Sequences.HIBERNATE_SEQUENCE +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.LibraryRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import java.net.URL + +@Component +class LibraryDao( + private val dsl: DSLContext +) : LibraryRepository { + + private val l = Tables.LIBRARY + private val ul = Tables.USER_LIBRARY_SHARING + + override fun findByIdOrNull(libraryId: Long): Library? = + dsl.selectFrom(l) + .where(l.ID.eq(libraryId)) + .fetchOneInto(l) + ?.toDomain() + + override fun findAll(): Collection = + dsl.selectFrom(l) + .fetchInto(l) + .map { it.toDomain() } + + override fun findAllById(libraryIds: Collection): Collection = + dsl.selectFrom(l) + .where(l.ID.`in`(libraryIds)) + .fetchInto(l) + .map { it.toDomain() } + + override fun existsByName(name: String): Boolean = + dsl.fetchExists( + dsl.selectFrom(l) + .where(l.NAME.equalIgnoreCase(name)) + ) + + override fun delete(libraryId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(ul).where(ul.LIBRARY_ID.eq(libraryId)).execute() + deleteFrom(l).where(l.ID.eq(libraryId)).execute() + } + } + } + + override fun deleteAll() { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(ul).execute() + deleteFrom(l).execute() + } + } + } + + override fun insert(library: Library): Library { + val id = dsl.nextval(HIBERNATE_SEQUENCE) + + dsl.insertInto(l) + .set(l.ID, id) + .set(l.NAME, library.name) + .set(l.ROOT, library.root.toString()) + .execute() + + return findByIdOrNull(id)!! + } + + override fun count(): Long = dsl.fetchCount(l).toLong() + + + private fun LibraryRecord.toDomain() = + Library( + name = name, + root = URL(root), + id = id, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/MediaDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/MediaDao.kt new file mode 100644 index 000000000..3628dc065 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/MediaDao.kt @@ -0,0 +1,146 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.MediaPageRecord +import org.gotson.komga.jooq.tables.records.MediaRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class MediaDao( + private val dsl: DSLContext +) : MediaRepository { + + private val m = Tables.MEDIA + private val p = Tables.MEDIA_PAGE + private val f = Tables.MEDIA_FILE + + private val groupFields = arrayOf(*m.fields(), *p.fields()) + + override fun findById(bookId: Long): Media = + dsl.select(*groupFields) + .from(m) + .leftJoin(p).on(m.BOOK_ID.eq(p.BOOK_ID)) + .where(m.BOOK_ID.eq(bookId)) + .groupBy(*groupFields) + .fetchGroups( + { it.into(m) }, { it.into(p) } + ).map { (mr, pr) -> + val files = dsl.selectFrom(f) + .where(f.BOOK_ID.eq(bookId)) + .fetchInto(f) + .map { it.fileName } + + mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files) + }.first() + + + override fun getThumbnail(bookId: Long): ByteArray? = + dsl.select(m.THUMBNAIL) + .from(m) + .where(m.BOOK_ID.eq(bookId)) + .fetchOne(0, ByteArray::class.java) + + + override fun insert(media: Media): Media { + dsl.transaction { config -> + with(config.dsl()) + { + insertInto(m) + .set(m.BOOK_ID, media.bookId) + .set(m.STATUS, media.status.toString()) + .set(m.MEDIA_TYPE, media.mediaType) + .set(m.THUMBNAIL, media.thumbnail) + .set(m.COMMENT, media.comment) + .set(m.PAGE_COUNT, media.pages.size.toLong()) + .execute() + + insertPages(this, media) + insertFiles(this, media) + } + } + + return findById(media.bookId) + } + + private fun insertPages(dsl: DSLContext, media: Media) { + media.pages.forEach { + dsl.insertInto(p) + .set(p.BOOK_ID, media.bookId) + .set(p.FILE_NAME, it.fileName) + .set(p.MEDIA_TYPE, it.mediaType) + .execute() + } + } + + private fun insertFiles(dsl: DSLContext, media: Media) { + media.files.forEach { + dsl.insertInto(f) + .set(f.BOOK_ID, media.bookId) + .set(f.FILE_NAME, it) + .execute() + } + } + + override fun update(media: Media) { + dsl.transaction { config -> + with(config.dsl()) + { + update(m) + .set(m.STATUS, media.status.toString()) + .set(m.MEDIA_TYPE, media.mediaType) + .set(m.THUMBNAIL, media.thumbnail) + .set(m.COMMENT, media.comment) + .set(m.PAGE_COUNT, media.pages.size.toLong()) + .set(m.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(m.BOOK_ID.eq(media.bookId)) + .execute() + + deleteFrom(p) + .where(p.BOOK_ID.eq(media.bookId)) + .execute() + + deleteFrom(f) + .where(f.BOOK_ID.eq(media.bookId)) + .execute() + + insertPages(this, media) + insertFiles(this, media) + } + } + } + + override fun delete(bookId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(p).where(p.BOOK_ID.eq(bookId)).execute() + deleteFrom(f).where(f.BOOK_ID.eq(bookId)).execute() + deleteFrom(m).where(m.BOOK_ID.eq(bookId)).execute() + } + } + } + + private fun MediaRecord.toDomain(pages: List, files: List) = + Media( + status = Media.Status.valueOf(status), + mediaType = mediaType, + thumbnail = thumbnail, + pages = pages, + files = files, + comment = comment, + bookId = bookId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) + + private fun MediaPageRecord.toDomain() = + BookPage( + fileName = fileName, + mediaType = mediaType + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt new file mode 100644 index 000000000..049f510cf --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt @@ -0,0 +1,151 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.jooq.Sequences +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.SeriesRecord +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.springframework.stereotype.Component +import java.net.URL +import java.time.LocalDateTime + +@Component +class SeriesDao( + private val dsl: DSLContext +) : SeriesRepository { + + private val s = Tables.SERIES + private val d = Tables.SERIES_METADATA + private val b = Tables.BOOK + + + override fun findAll(): Collection = + dsl.selectFrom(s) + .fetchInto(s) + .map { it.toDomain() } + + override fun findByIdOrNull(seriesId: Long): Series? = + dsl.selectFrom(s) + .where(s.ID.eq(seriesId)) + .fetchOneInto(s) + ?.toDomain() + + override fun findByLibraryId(libraryId: Long): List = + dsl.selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId)) + .fetchInto(s) + .map { it.toDomain() } + + override fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection): List = + dsl.selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId).and(s.URL.notIn(urls.map { it.toString() }))) + .fetchInto(s) + .map { it.toDomain() } + + override fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series? = + dsl.selectFrom(s) + .where(s.LIBRARY_ID.eq(libraryId).and(s.URL.eq(url.toString()))) + .fetchOneInto(s) + ?.toDomain() + + + override fun getLibraryId(seriesId: Long): Long? = + dsl.select(s.LIBRARY_ID) + .from(s) + .where(s.ID.eq(seriesId)) + .fetchOne(0, Long::class.java) + + + override fun findAll(search: SeriesSearch): Collection { + val conditions = search.toCondition() + + return dsl.selectFrom(s) + .where(conditions) + .fetchInto(s) + .map { it.toDomain() } + } + + + override fun insert(series: Series): Series { + val id = dsl.nextval(Sequences.HIBERNATE_SEQUENCE) + + dsl.insertInto(s) + .set(s.ID, 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) + .execute() + + return findByIdOrNull(id)!! + } + + override fun update(series: Series) { + dsl.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.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(s.ID.eq(series.id)) + .execute() + } + + override fun delete(seriesId: Long) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(d).where(d.SERIES_ID.eq(seriesId)).execute() + deleteFrom(s).where(s.ID.eq(seriesId)).execute() + } + } + } + + override fun deleteAll() { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(d).execute() + deleteFrom(s).execute() + } + } + } + + override fun deleteAll(seriesIds: Collection) { + dsl.transaction { config -> + with(config.dsl()) + { + deleteFrom(d).where(d.SERIES_ID.`in`(seriesIds)).execute() + deleteFrom(s).where(s.ID.`in`(seriesIds)).execute() + } + } + } + + override fun count(): Long = dsl.fetchCount(s).toLong() + + + private fun SeriesSearch.toCondition(): Condition { + var c: Condition = DSL.trueCondition() + + if (libraryIds.isNotEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds)) + searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(searchTerm)) } + if (metadataStatus.isNotEmpty()) c = c.and(d.STATUS.`in`(metadataStatus)) + + return c + } + + private fun SeriesRecord.toDomain() = + Series( + name = name, + url = URL(url), + fileLastModified = fileLastModified, + id = id, + libraryId = libraryId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt new file mode 100644 index 000000000..e2f2106cc --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt @@ -0,0 +1,140 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.interfaces.rest.dto.SeriesDto +import org.gotson.komga.interfaces.rest.dto.SeriesMetadataDto +import org.gotson.komga.interfaces.rest.dto.toUTC +import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.SeriesMetadataRecord +import org.gotson.komga.jooq.tables.records.SeriesRecord +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.ResultQuery +import org.jooq.impl.DSL +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.stereotype.Component +import java.net.URL + +@Component +class SeriesDtoDao( + private val dsl: DSLContext +) : SeriesDtoRepository { + + private val s = Tables.SERIES + private val b = Tables.BOOK + private val d = Tables.SERIES_METADATA + + private val groupFields = arrayOf( + *s.fields(), + *d.fields() + ) + + private val sorts = mapOf( + "metadata.titleSort" to DSL.lower(d.TITLE_SORT), + "createdDate" to s.CREATED_DATE, + "lastModifiedDate" to s.LAST_MODIFIED_DATE + ) + + override fun findAll(search: SeriesSearch, pageable: Pageable): Page { + val conditions = search.toCondition() + + return findAll(conditions, pageable) + } + + override fun findRecentlyUpdated(search: SeriesSearch, pageable: Pageable): Page { + val conditions = search.toCondition() + .and(s.CREATED_DATE.ne(s.LAST_MODIFIED_DATE)) + + return findAll(conditions, pageable) + } + + override fun findByIdOrNull(seriesId: Long): SeriesDto? = + selectBase() + .where(s.ID.eq(seriesId)) + .groupBy(*groupFields) + .fetchAndMap() + .firstOrNull() + + + private fun findAll(conditions: Condition, pageable: Pageable): Page { + val count = dsl.selectCount() + .from(s) + .leftJoin(d).on(s.ID.eq(d.SERIES_ID)) + .where(conditions) + .fetchOne(0, Int::class.java) + + val orderBy = pageable.sort.toOrderBy(sorts) + + val dtos = selectBase() + .where(conditions) + .groupBy(*groupFields) + .orderBy(orderBy) + .limit(pageable.pageSize) + .offset(pageable.offset) + .fetchAndMap() + + return PageImpl( + dtos, + PageRequest.of(pageable.pageNumber, pageable.pageSize, pageable.sort), + count.toLong() + ) + } + + private fun selectBase() = + dsl.select(*groupFields) + .select(DSL.count(b.ID).`as`("bookCount")) + .from(s) + .leftJoin(b).on(s.ID.eq(b.SERIES_ID)) + .leftJoin(d).on(s.ID.eq(d.SERIES_ID)) + + private fun ResultQuery.fetchAndMap() = + fetch() + .map { r -> + val sr = r.into(s) + val dr = r.into(d) + val bookCount = r["bookCount"] as Int + sr.toDto(bookCount, dr.toDto()) + } + + private fun SeriesSearch.toCondition(): Condition { + var c: Condition = DSL.trueCondition() + + if (libraryIds.isNotEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds)) + searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(searchTerm)) } + if (metadataStatus.isNotEmpty()) c = c.and(d.STATUS.`in`(metadataStatus)) + + return c + } + + private fun SeriesRecord.toDto(bookCount: Int, metadata: SeriesMetadataDto) = + SeriesDto( + id = id, + libraryId = libraryId, + name = name, + url = URL(url).toURI().path, + created = createdDate.toUTC(), + lastModified = lastModifiedDate.toUTC(), + fileLastModified = fileLastModified.toUTC(), + booksCount = bookCount, + metadata = metadata + ) + + private fun SeriesMetadataRecord.toDto() = + SeriesMetadataDto( + status = status, + statusLock = statusLock, + created = createdDate.toUTC(), + lastModified = lastModifiedDate.toUTC(), + title = title, + titleLock = titleLock, + titleSort = titleSort, + titleSortLock = titleSortLock + ) +} + + diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDao.kt new file mode 100644 index 000000000..163f14362 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDao.kt @@ -0,0 +1,77 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.SeriesMetadataRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class SeriesMetadataDao( + private val dsl: DSLContext +) : SeriesMetadataRepository { + + private val d = Tables.SERIES_METADATA + + override fun findById(seriesId: Long): SeriesMetadata = + findOne(seriesId).toDomain() + + override fun findByIdOrNull(seriesId: Long): SeriesMetadata? = + findOne(seriesId)?.toDomain() + + private fun findOne(seriesId: Long) = + dsl.selectFrom(d) + .where(d.SERIES_ID.eq(seriesId)) + .fetchOneInto(d) + + override fun insert(metadata: SeriesMetadata): SeriesMetadata { + dsl.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.STATUS_LOCK, metadata.statusLock) + .set(d.TITLE_LOCK, metadata.titleLock) + .set(d.TITLE_SORT_LOCK, metadata.titleSortLock) + .execute() + + return findById(metadata.seriesId) + } + + override fun update(metadata: SeriesMetadata) { + dsl.update(d) + .set(d.STATUS, metadata.status.toString()) + .set(d.TITLE, metadata.title) + .set(d.TITLE_SORT, metadata.titleSort) + .set(d.STATUS_LOCK, metadata.statusLock) + .set(d.TITLE_LOCK, metadata.titleLock) + .set(d.TITLE_SORT_LOCK, metadata.titleSortLock) + .set(d.LAST_MODIFIED_DATE, LocalDateTime.now()) + .where(d.SERIES_ID.eq(metadata.seriesId)) + .execute() + } + + override fun delete(seriesId: Long) { + dsl.deleteFrom(d) + .where(d.SERIES_ID.eq(seriesId)) + .execute() + } + + override fun count(): Long = dsl.fetchCount(d).toLong() + + + private fun SeriesMetadataRecord.toDomain() = + SeriesMetadata( + status = SeriesMetadata.Status.valueOf(status), + title = title, + titleSort = titleSort, + seriesId = seriesId, + statusLock = statusLock, + titleLock = titleLock, + titleSortLock = titleSortLock, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt new file mode 100644 index 000000000..e27f58824 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.infrastructure.jooq + +import org.jooq.Field +import org.jooq.SortField +import org.springframework.data.domain.Sort +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset + +fun LocalDateTime.toUTC(): LocalDateTime = + atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() + +fun Sort.toOrderBy(sorts: Map>): List> = + this.mapNotNull { + val f = sorts[it.property] + if (it.isAscending) f?.asc() else f?.desc() + } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt index 0434fca70..c7ab4cac7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt @@ -2,7 +2,8 @@ package org.gotson.komga.infrastructure.metadata import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.Media interface BookMetadataProvider { - fun getBookMetadataFromBook(book: Book): BookMetadataPatch? + fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt index ae0bc4677..3bf005ef3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt @@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadata 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.service.BookAnalyzer import org.gotson.komga.infrastructure.metadata.BookMetadataProvider @@ -25,8 +26,8 @@ class ComicInfoProvider( private val bookAnalyzer: BookAnalyzer ) : BookMetadataProvider { - override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? { - getComicInfo(book)?.let { comicInfo -> + override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { + getComicInfo(book, media)?.let { comicInfo -> val releaseDate = comicInfo.year?.let { LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1) } @@ -66,9 +67,9 @@ class ComicInfoProvider( return null } - private fun getComicInfo(book: Book): ComicInfo? { + private fun getComicInfo(book: Book, media: Media): ComicInfo? { try { - if (book.media.files.none { it == COMIC_INFO }) { + if (media.files.none { it == COMIC_INFO }) { logger.debug { "Book does not contain any $COMIC_INFO file: $book" } return null } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt index 8dbee34c4..e1cbe2307 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt @@ -4,6 +4,7 @@ import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadata 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.infrastructure.mediacontainer.EpubExtractor import org.gotson.komga.infrastructure.metadata.BookMetadataProvider @@ -26,8 +27,8 @@ class EpubMetadataProvider( "ill" to "penciller" ) - override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? { - if (book.media.mediaType != "application/epub+zip") return null + override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { + if (media.mediaType != "application/epub+zip") return null epubExtractor.getPackageFile(book.path())?.let { packageFile -> val opf = Jsoup.parse(packageFile.toString()) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt index 9d85bd6d9..b71ad87d6 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt @@ -9,13 +9,10 @@ class KomgaPrincipal( val user: KomgaUser ) : UserDetails { - override fun getAuthorities(): MutableCollection { - return user.roles.map { it.name } - .toMutableSet() - .apply { add("USER") } + override fun getAuthorities(): MutableCollection = + user.roles() .map { SimpleGrantedAuthority("ROLE_$it") } .toMutableSet() - } override fun isEnabled() = true diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt index 12f9e6faa..31d047271 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt @@ -1,15 +1,19 @@ package org.gotson.komga.interfaces.opds -import com.github.klinq.jpaspec.`in` -import com.github.klinq.jpaspec.likeLower -import com.github.klinq.jpaspec.toJoin import mu.KotlinLogging import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadata +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.Series import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch +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.infrastructure.security.KomgaPrincipal import org.gotson.komga.interfaces.opds.dto.OpdsAuthor @@ -26,9 +30,6 @@ import org.gotson.komga.interfaces.opds.dto.OpdsLinkPageStreaming import org.gotson.komga.interfaces.opds.dto.OpdsLinkRel import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription -import org.springframework.data.domain.Sort -import org.springframework.data.jpa.domain.Specification -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -63,8 +64,12 @@ private const val ID_LIBRARIES_ALL = "allLibraries" @RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE]) class OpdsController( servletContext: ServletContext, + private val libraryRepository: LibraryRepository, private val seriesRepository: SeriesRepository, - private val libraryRepository: LibraryRepository + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookRepository: BookRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val mediaRepository: MediaRepository ) { private val routeBase = "${servletContext.contextPath}$ROUTE_BASE" @@ -129,23 +134,16 @@ class OpdsController( @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam("search") searchTerm: String? ): OpdsFeed { - val sort = Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase()) - val series = - mutableListOf>().let { specs -> - if (!principal.user.sharedAllLibraries) { - specs.add(Series::library.`in`(principal.user.sharedLibraries)) - } + val seriesSearch = SeriesSearch( + libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet(), + searchTerm = searchTerm + ) - if (!searchTerm.isNullOrEmpty()) { - specs.add(Series::metadata.toJoin().where(SeriesMetadata::title).likeLower("%$searchTerm%")) - } + val entries = seriesRepository.findAll(seriesSearch) + .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } + .sortedBy { it.metadata.titleSort.toLowerCase() } + .map { it.toOpdsEntry() } - if (specs.isNotEmpty()) { - seriesRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, sort) - } else { - seriesRepository.findAll(sort) - } - } return OpdsFeedNavigation( id = ID_SERIES_ALL, @@ -156,7 +154,7 @@ class OpdsController( OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL"), linkStart ), - entries = series.map { it.toOpdsEntry() } + entries = entries ) } @@ -164,13 +162,14 @@ class OpdsController( fun getLatestSeries( @AuthenticationPrincipal principal: KomgaPrincipal ): OpdsFeed { - val sort = Sort.by(Sort.Direction.DESC, "lastModifiedDate") - val series = - if (principal.user.sharedAllLibraries) { - seriesRepository.findAll(sort) - } else { - seriesRepository.findByLibraryIn(principal.user.sharedLibraries, sort) - } + val seriesSearch = SeriesSearch( + libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet() + ) + + val entries = seriesRepository.findAll(seriesSearch) + .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } + .sortedBy { it.series.lastModifiedDate } + .map { it.toOpdsEntry() } return OpdsFeedNavigation( id = ID_SERIES_LATEST, @@ -181,7 +180,7 @@ class OpdsController( OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST"), linkStart ), - entries = series.map { it.toOpdsEntry() } + entries = entries ) } @@ -193,7 +192,7 @@ class OpdsController( if (principal.user.sharedAllLibraries) { libraryRepository.findAll() } else { - principal.user.sharedLibraries + libraryRepository.findAllById(principal.user.sharedLibrariesIds) } return OpdsFeedNavigation( id = ID_LIBRARIES_ALL, @@ -217,19 +216,27 @@ class OpdsController( seriesRepository.findByIdOrNull(id)?.let { series -> if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + val books = bookRepository.findAll(BookSearch( + seriesIds = listOf(id), + mediaStatus = setOf(Media.Status.READY) + )) + val metadata = seriesMetadataRepository.findById(series.id) + + val entries = books + .map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) } + .sortedBy { it.metadata.numberSort } + .map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) } + OpdsFeedAcquisition( id = series.id.toString(), - title = series.metadata.title, - updated = series.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + title = metadata.title, + updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), author = komgaAuthor, links = listOf( OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id"), linkStart ), - entries = series.books - .filter { it.media.status == Media.Status.READY } - .sortedBy { it.metadata.numberSort } - .map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) } + entries = entries ) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -241,53 +248,60 @@ class OpdsController( libraryRepository.findByIdOrNull(id)?.let { library -> if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + val seriesSearch = SeriesSearch(libraryIds = setOf(library.id)) + + val entries = seriesRepository.findAll(seriesSearch) + .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } + .sortedBy { it.metadata.titleSort.toLowerCase() } + .map { it.toOpdsEntry() } + OpdsFeedNavigation( id = library.id.toString(), title = library.name, - updated = library.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), author = komgaAuthor, links = listOf( OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id"), linkStart ), - entries = seriesRepository.findByLibraryId(library.id, Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())).map { it.toOpdsEntry() } + entries = entries ) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - private fun Series.toOpdsEntry() = + private fun SeriesWithInfo.toOpdsEntry(): OpdsEntryNavigation = OpdsEntryNavigation( title = metadata.title, - updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = id.toString(), + updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = series.id.toString(), content = "", - link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/$id") + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}") ) - private fun Book.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition { + private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition { val mediaTypes = media.pages.map { it.mediaType }.distinct() val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) { - OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/$id/pages/{pageNumber}?zero_based=true", media.pages.size) + OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/${book.id}/pages/{pageNumber}?zero_based=true", media.pages.size) } else { - OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/$id/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size) + OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size) } return OpdsEntryAcquisition( title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}", - updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - id = id.toString(), + updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = book.id.toString(), content = run { - var content = "${fileExtension().toUpperCase()} - ${fileSizeHumanReadable()}" + var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}" if (metadata.summary.isNotBlank()) content += "\n\n${metadata.summary}" content }, authors = metadata.authors.map { OpdsAuthor(it.name) }, links = listOf( - OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/$id/thumbnail"), - OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/$id/pages/1"), - OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/$id/file/${fileName()}"), + OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/${book.id}/thumbnail"), + OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/${book.id}/pages/1"), + OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/${book.id}/file/${book.fileName()}"), opdsLinkPageStreaming ) ) @@ -296,7 +310,7 @@ class OpdsController( private fun Library.toOpdsEntry(): OpdsEntryNavigation { return OpdsEntryNavigation( title = name, - updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), id = id.toString(), content = "", link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id") @@ -305,4 +319,15 @@ class OpdsController( private fun shouldPrependBookNumbers(userAgent: String) = userAgent.contains("chunky", ignoreCase = true) + + private data class BookWithInfo( + val book: Book, + val media: Media, + val metadata: BookMetadata + ) + + private data class SeriesWithInfo( + val series: Series, + val metadata: SeriesMetadata + ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/AdminController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/AdminController.kt deleted file mode 100644 index a18ae43d1..000000000 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/AdminController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.gotson.komga.interfaces.rest - -import mu.KotlinLogging -import org.gotson.komga.application.tasks.TaskReceiver -import org.gotson.komga.domain.persistence.BookRepository -import org.springframework.http.HttpStatus -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController - -private val logger = KotlinLogging.logger {} - -@RestController -@RequestMapping("api/v1/admin") -@PreAuthorize("hasRole('ADMIN')") -class AdminController( - private val bookRepository: BookRepository, - private val taskReceiver: TaskReceiver -) { - - @PostMapping("rpc/thumbnails/regenerate/all") - @ResponseStatus(HttpStatus.ACCEPTED) - fun regenerateAllThumbnails() { - logger.info { "Regenerate thumbnail for all books" } - bookRepository.findAll().forEach { taskReceiver.generateBookThumbnail(it) } - } - - @PostMapping("rpc/thumbnails/regenerate/missing") - @ResponseStatus(HttpStatus.ACCEPTED) - fun regenerateMissingThumbnails() { - logger.info { "Regenerate missing thumbnails" } - bookRepository.findAllByMediaThumbnailIsNull().forEach { taskReceiver.generateBookThumbnail(it) } - } -} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 3b023a411..375aae48f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -1,25 +1,21 @@ package org.gotson.komga.interfaces.rest -import com.github.klinq.jpaspec.`in` -import com.github.klinq.jpaspec.likeLower -import com.github.klinq.jpaspec.toJoin 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 mu.KotlinLogging -import org.gotson.komga.application.service.BookLifecycle import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.Author -import org.gotson.komga.domain.model.Book -import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.ImageConversionException -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.Series +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.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam @@ -27,14 +23,13 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.PageDto -import org.gotson.komga.interfaces.rest.dto.toDto +import org.gotson.komga.interfaces.rest.dto.restrictUrl +import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository 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.data.jpa.domain.Specification -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.CacheControl import org.springframework.http.ContentDisposition import org.springframework.http.HttpHeaders @@ -58,7 +53,6 @@ import java.io.FileNotFoundException import java.nio.file.NoSuchFileException import java.time.ZoneOffset import java.util.concurrent.TimeUnit -import javax.persistence.criteria.JoinType import javax.validation.Valid private val logger = KotlinLogging.logger {} @@ -66,9 +60,12 @@ private val logger = KotlinLogging.logger {} @RestController @RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) class BookController( - private val bookRepository: BookRepository, + private val taskReceiver: TaskReceiver, private val bookLifecycle: BookLifecycle, - private val taskReceiver: TaskReceiver + private val bookRepository: BookRepository, + private val bookMetadataRepository: BookMetadataRepository, + private val mediaRepository: MediaRepository, + private val bookDtoRepository: BookDtoRepository ) { @PageableAsQueryParam @@ -87,45 +84,21 @@ class BookController( else Sort.by(Sort.Order.asc("metadata.title").ignoreCase()) ) - return mutableListOf>().let { specs -> - when { - // limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries - !principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> { - val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id }) - if (authorizedLibraryIDs.isEmpty()) return@let Page.empty(pageRequest) - else specs.add(Book::series.toJoin().join(Series::library, JoinType.INNER).where(Library::id).`in`(authorizedLibraryIDs)) - } + val bookSearch = BookSearch( + libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds), + searchTerm = searchTerm, + mediaStatus = mediaStatus ?: emptyList() + ) - // limited user: filter on user's authorized libraries - !principal.user.sharedAllLibraries -> specs.add(Book::series.toJoin().where(Series::library).`in`(principal.user.sharedLibraries)) - - // non-limited user: filter on provided libraries - !libraryIds.isNullOrEmpty() -> { - specs.add(Book::series.toJoin().join(Series::library, JoinType.INNER).where(Library::id).`in`(libraryIds)) - } - } - - if (!searchTerm.isNullOrEmpty()) { - specs.add(Book::metadata.toJoin().where(BookMetadata::title).likeLower("%$searchTerm%")) - } - - if (!mediaStatus.isNullOrEmpty()) { - specs.add(Book::media.toJoin().where(Media::status).`in`(mediaStatus)) - } - - if (specs.isNotEmpty()) { - bookRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, pageRequest) - } else { - bookRepository.findAll(pageRequest) - } - }.map { it.toDto(includeFullUrl = principal.user.isAdmin()) } + return bookDtoRepository.findAll(bookSearch, pageRequest) + .map { it.restrictUrl(!principal.user.roleAdmin) } } @Operation(description = "Return newly added or updated books.") @PageableWithoutSortAsQueryParam @GetMapping("api/v1/books/latest") - fun getLatestSeries( + fun getLatestBooks( @AuthenticationPrincipal principal: KomgaPrincipal, @Parameter(hidden = true) page: Pageable ): Page { @@ -135,11 +108,14 @@ class BookController( Sort.by(Sort.Direction.DESC, "lastModifiedDate") ) - return if (principal.user.sharedAllLibraries) { - bookRepository.findAll(pageRequest) - } else { - bookRepository.findBySeriesLibraryIn(principal.user.sharedLibraries, pageRequest) - }.map { it.toDto(includeFullUrl = principal.user.isAdmin()) } + val libraryIds = if (principal.user.sharedAllLibraries) emptyList() else principal.user.sharedLibrariesIds + + return bookDtoRepository.findAll( + BookSearch( + libraryIds = libraryIds + ), + pageRequest + ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -148,42 +124,39 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long ): BookDto = - bookRepository.findByIdOrNull(bookId)?.let { - if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - it.toDto(includeFullUrl = principal.user.isAdmin()) + bookDtoRepository.findByIdOrNull(bookId)?.let { + if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @GetMapping("api/v1/books/{bookId}/previous") fun getBookSiblingPrevious( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long - ): BookDto = - bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - - val previousBook = book.series.books - .sortedByDescending { it.metadata.numberSort } - .find { it.metadata.numberSort < book.metadata.numberSort } - - previousBook?.toDto(includeFullUrl = principal.user.isAdmin()) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): BookDto { + bookRepository.getLibraryId(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return bookDtoRepository.findPreviousInSeries(bookId) + ?.restrictUrl(!principal.user.roleAdmin) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @GetMapping("api/v1/books/{bookId}/next") fun getBookSiblingNext( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long - ): BookDto = - bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - - val nextBook = book.series.books - .sortedBy { it.metadata.numberSort } - .find { it.metadata.numberSort > book.metadata.numberSort } - - nextBook?.toDto(includeFullUrl = principal.user.isAdmin()) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): BookDto { + bookRepository.getLibraryId(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return bookDtoRepository.findNextInSeries(bookId) + ?.restrictUrl(!principal.user.roleAdmin) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @GetMapping(value = [ @@ -193,16 +166,18 @@ class BookController( fun getBookThumbnail( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long - ): ResponseEntity = - bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - if (book.media.thumbnail != null) { - ResponseEntity.ok() - .setCachePrivate() - .body(book.media.thumbnail) - } else throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): ResponseEntity { + bookRepository.getLibraryId(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return mediaRepository.getThumbnail(bookId)?.let { + ResponseEntity.ok() + .setCachePrivate() + .body(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @Operation(description = "Download the book file.") @GetMapping(value = [ "api/v1/books/{bookId}/file", @@ -216,6 +191,7 @@ class BookController( bookRepository.findByIdOrNull(bookId)?.let { book -> if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) try { + val media = mediaRepository.findById(book.id) with(FileSystemResource(book.path())) { if (!exists()) throw FileNotFoundException(path) ResponseEntity.ok() @@ -224,7 +200,7 @@ class BookController( .filename(book.fileName()) .build() }) - .contentType(getMediaTypeOrDefault(book.media.mediaType)) + .contentType(getMediaTypeOrDefault(media.mediaType)) .body(this) } } catch (ex: FileNotFoundException) { @@ -239,12 +215,14 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: Long ): List = - bookRepository.findByIdOrNull((bookId))?.let { - if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - if (it.media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet") - if (it.media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + bookRepository.findByIdOrNull((bookId))?.let { book -> + if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - it.media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) } + val media = mediaRepository.findById(book.id) + if (media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet") + if (media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + + media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @ApiResponse(content = [Content( @@ -266,10 +244,11 @@ class BookController( @RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean ): ResponseEntity = bookRepository.findByIdOrNull((bookId))?.let { book -> - if (request.checkNotModified(getBookLastModified(book))) { + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { return@let ResponseEntity .status(HttpStatus.NOT_MODIFIED) - .setNotModified(book) + .setNotModified(media) .body(ByteArray(0)) } if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) @@ -287,7 +266,7 @@ class BookController( ResponseEntity.ok() .contentType(getMediaTypeOrDefault(pageContent.mediaType)) - .setNotModified(book) + .setNotModified(media) .body(pageContent.content) } catch (ex: IndexOutOfBoundsException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") @@ -313,10 +292,11 @@ class BookController( @PathVariable pageNumber: Int ): ResponseEntity = bookRepository.findByIdOrNull((bookId))?.let { book -> - if (request.checkNotModified(getBookLastModified(book))) { + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { return@let ResponseEntity .status(HttpStatus.NOT_MODIFIED) - .setNotModified(book) + .setNotModified(media) .body(ByteArray(0)) } if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) @@ -325,7 +305,7 @@ class BookController( ResponseEntity.ok() .contentType(getMediaTypeOrDefault(pageContent.mediaType)) - .setNotModified(book) + .setNotModified(media) .body(pageContent.content) } catch (ex: IndexOutOfBoundsException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") @@ -364,36 +344,33 @@ class BookController( @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 ): BookDto = - bookRepository.findByIdOrNull(bookId)?.let { book -> - with(newMetadata) { - title?.let { book.metadata.title = it } - titleLock?.let { book.metadata.titleLock = it } - summary?.let { book.metadata.summary = it } - summaryLock?.let { book.metadata.summaryLock = it } - number?.let { book.metadata.number = it } - numberLock?.let { book.metadata.numberLock = it } - numberSort?.let { book.metadata.numberSort = it } - numberSortLock?.let { book.metadata.numberSortLock = it } - if (isSet("readingDirection")) book.metadata.readingDirection = newMetadata.readingDirection - readingDirectionLock?.let { book.metadata.readingDirectionLock = it } - publisher?.let { book.metadata.publisher = it } - publisherLock?.let { book.metadata.publisherLock = it } - if (isSet("ageRating")) book.metadata.ageRating = newMetadata.ageRating - ageRatingLock?.let { book.metadata.ageRatingLock = it } - if (isSet("releaseDate")) { - book.metadata.releaseDate = newMetadata.releaseDate - } - releaseDateLock?.let { book.metadata.releaseDateLock = it } - if (isSet("authors")) { - if (authors != null) { - book.metadata.authors = authors!!.map { - Author(it.name ?: "", it.role ?: "") - }.toMutableList() - } else book.metadata.authors = mutableListOf() - } - authorsLock?.let { book.metadata.authorsLock = it } + bookMetadataRepository.findByIdOrNull(bookId)?.let { existing -> + val updated = with(newMetadata) { + existing.copy( + title = title ?: existing.title, + titleLock = titleLock ?: existing.titleLock, + summary = summary ?: existing.summary, + summaryLock = summaryLock ?: existing.summaryLock, + number = number ?: existing.number, + numberLock = numberLock ?: existing.numberLock, + numberSort = numberSort ?: existing.numberSort, + numberSortLock = numberSortLock ?: existing.numberSortLock, + 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, + releaseDate = if (isSet("releaseDate")) releaseDate else existing.releaseDate, + releaseDateLock = releaseDateLock ?: existing.releaseDateLock, + authors = if (isSet("authors")) { + if (authors != null) authors!!.map { Author(it.name ?: "", it.role ?: "") } else emptyList() + } else existing.authors, + authorsLock = authorsLock ?: existing.authorsLock + ) } - bookRepository.save(book).toDto(includeFullUrl = true) + bookMetadataRepository.update(updated) + bookDtoRepository.findByIdOrNull(bookId) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) private fun ResponseEntity.BodyBuilder.setCachePrivate() = @@ -402,11 +379,11 @@ class BookController( .mustRevalidate() ) - private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) = - this.setCachePrivate().lastModified(getBookLastModified(book)) + private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) = + this.setCachePrivate().lastModified(getBookLastModified(media)) - private fun getBookLastModified(book: Book) = - book.media.lastModifiedDate!!.toInstant(ZoneOffset.UTC).toEpochMilli() + private fun getBookLastModified(media: Media) = + media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ClaimController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ClaimController.kt index b52dc24b2..8879041ff 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ClaimController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ClaimController.kt @@ -1,13 +1,12 @@ package org.gotson.komga.interfaces.rest -import org.gotson.komga.domain.model.UserRoles -import org.gotson.komga.infrastructure.security.KomgaPrincipal -import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.interfaces.rest.dto.UserDto import org.gotson.komga.interfaces.rest.dto.toDto import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import org.springframework.security.core.userdetails.User import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestHeader @@ -22,7 +21,7 @@ import javax.validation.constraints.NotBlank @RequestMapping("api/v1/claim", produces = [MediaType.APPLICATION_JSON_VALUE]) @Validated class ClaimController( - private val userDetailsLifecycle: KomgaUserDetailsLifecycle + private val userDetailsLifecycle: KomgaUserLifecycle ) { @PostMapping fun claimAdmin( @@ -32,11 +31,12 @@ class ClaimController( if (userDetailsLifecycle.countUsers() > 0) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed") - return (userDetailsLifecycle.createUser( - User.withUsername(email) - .password(password) - .roles(UserRoles.ADMIN.name) - .build()) as KomgaPrincipal - ).toDto() + return userDetailsLifecycle.createUser( + KomgaUser( + email = email, + password = password, + roleAdmin = true + ) + ).toDto() } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index b3e376eef..069048642 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -1,7 +1,6 @@ package org.gotson.komga.interfaces.rest import mu.KotlinLogging -import org.gotson.komga.application.service.LibraryLifecycle import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.DirectoryNotFoundException import org.gotson.komga.domain.model.DuplicateNameException @@ -9,8 +8,8 @@ import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.infrastructure.security.KomgaPrincipal -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -33,10 +32,10 @@ private val logger = KotlinLogging.logger {} @RestController @RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE]) class LibraryController( + private val taskReceiver: TaskReceiver, private val libraryLifecycle: LibraryLifecycle, private val libraryRepository: LibraryRepository, - private val bookRepository: BookRepository, - private val taskReceiver: TaskReceiver + private val bookRepository: BookRepository ) { @GetMapping @@ -46,8 +45,8 @@ class LibraryController( if (principal.user.sharedAllLibraries) { libraryRepository.findAll() } else { - principal.user.sharedLibraries - }.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.isAdmin()) } + libraryRepository.findAllById(principal.user.sharedLibrariesIds) + }.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.roleAdmin) } @GetMapping("{id}") fun getOne( @@ -56,7 +55,7 @@ class LibraryController( ): LibraryDto = libraryRepository.findByIdOrNull(id)?.let { if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - it.toDto(includeRoot = principal.user.isAdmin()) + it.toDto(includeRoot = principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @PostMapping @@ -66,7 +65,7 @@ class LibraryController( @Valid @RequestBody library: LibraryCreationDto ): LibraryDto = try { - libraryLifecycle.addLibrary(Library(library.name, library.root)).toDto(includeRoot = principal.user.isAdmin()) + libraryLifecycle.addLibrary(Library(library.name, library.root)).toDto(includeRoot = principal.user.roleAdmin) } catch (e: Exception) { when (e) { is FileNotFoundException, @@ -92,7 +91,7 @@ class LibraryController( @ResponseStatus(HttpStatus.ACCEPTED) fun scan(@PathVariable libraryId: Long) { libraryRepository.findByIdOrNull(libraryId)?.let { library -> - taskReceiver.scanLibrary(library) + taskReceiver.scanLibrary(library.id) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -100,18 +99,18 @@ class LibraryController( @PreAuthorize("hasRole('ADMIN')") @ResponseStatus(HttpStatus.ACCEPTED) fun analyze(@PathVariable libraryId: Long) { - libraryRepository.findByIdOrNull(libraryId)?.let { library -> - bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.analyzeBook(it) } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + bookRepository.findAllIdByLibraryId(libraryId).forEach { + taskReceiver.analyzeBook(it) + } } @PostMapping("{libraryId}/metadata/refresh") @PreAuthorize("hasRole('ADMIN')") @ResponseStatus(HttpStatus.ACCEPTED) fun refreshMetadata(@PathVariable libraryId: Long) { - libraryRepository.findByIdOrNull(libraryId)?.let { library -> - bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.refreshBookMetadata(it) } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + bookRepository.findAllIdByLibraryId(libraryId).forEach { + taskReceiver.refreshBookMetadata(it) + } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index 97e7585a0..fbdebca84 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -1,8 +1,5 @@ package org.gotson.komga.interfaces.rest -import com.github.klinq.jpaspec.`in` -import com.github.klinq.jpaspec.likeLower -import com.github.klinq.jpaspec.toJoin import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.Content @@ -10,11 +7,12 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import mu.KotlinLogging import org.gotson.komga.application.tasks.TaskReceiver -import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.Media -import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.model.SeriesSearch 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.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam @@ -22,14 +20,13 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.SeriesDto import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto -import org.gotson.komga.interfaces.rest.dto.toDto -import org.springdoc.api.annotations.ParameterObject +import org.gotson.komga.interfaces.rest.dto.restrictUrl +import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository +import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository 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.data.jpa.domain.Specification -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -52,19 +49,23 @@ private val logger = KotlinLogging.logger {} @RestController @RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE]) class SeriesController( + private val taskReceiver: TaskReceiver, private val seriesRepository: SeriesRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val seriesDtoRepository: SeriesDtoRepository, private val bookRepository: BookRepository, - private val bookController: BookController, - private val taskReceiver: TaskReceiver + private val bookDtoRepository: BookDtoRepository, + private val bookController: BookController ) { + @PageableAsQueryParam @GetMapping fun getAllSeries( @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "search", required = false) searchTerm: String?, @RequestParam(name = "library_id", required = false) libraryIds: List?, @RequestParam(name = "status", required = false) metadataStatus: List?, - @ParameterObject page: Pageable + @Parameter(hidden = true) page: Pageable ): Page { val pageRequest = PageRequest.of( page.pageNumber, @@ -73,38 +74,14 @@ class SeriesController( else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase()) ) - return mutableListOf>().let { specs -> - when { - // limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries - !principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> { - val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id }) - if (authorizedLibraryIDs.isEmpty()) return@let Page.empty(pageRequest) - else specs.add(Series::library.toJoin().where(Library::id).`in`(authorizedLibraryIDs)) - } + val seriesSearch = SeriesSearch( + libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds), + searchTerm = searchTerm, + metadataStatus = metadataStatus ?: emptyList() + ) - // limited user: filter on user's authorized libraries - !principal.user.sharedAllLibraries -> specs.add(Series::library.`in`(principal.user.sharedLibraries)) - - // non-limited user: filter on provided libraries - !libraryIds.isNullOrEmpty() -> { - specs.add(Series::library.toJoin().where(Library::id).`in`(libraryIds)) - } - } - - if (!searchTerm.isNullOrEmpty()) { - specs.add(Series::metadata.toJoin().where(SeriesMetadata::title).likeLower("%$searchTerm%")) - } - - if (!metadataStatus.isNullOrEmpty()) { - specs.add(Series::metadata.toJoin().where(SeriesMetadata::status).`in`(metadataStatus)) - } - - if (specs.isNotEmpty()) { - seriesRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, pageRequest) - } else { - seriesRepository.findAll(pageRequest) - } - }.map { it.toDto(includeUrl = principal.user.isAdmin()) } + return seriesDtoRepository.findAll(seriesSearch, pageRequest) + .map { it.restrictUrl(!principal.user.roleAdmin) } } @Operation(description = "Return recently added or updated series.") @@ -120,11 +97,12 @@ class SeriesController( Sort.by(Sort.Direction.DESC, "lastModifiedDate") ) - return if (principal.user.sharedAllLibraries) { - seriesRepository.findAll(pageRequest) - } else { - seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest) - }.map { it.toDto(includeUrl = principal.user.isAdmin()) } + val libraryIds = if (principal.user.sharedAllLibraries) emptyList() else principal.user.sharedLibrariesIds + + return seriesDtoRepository.findAll( + SeriesSearch(libraryIds = libraryIds), + pageRequest + ).map { it.restrictUrl(!principal.user.roleAdmin) } } @Operation(description = "Return newly added series.") @@ -140,11 +118,12 @@ class SeriesController( Sort.by(Sort.Direction.DESC, "createdDate") ) - return if (principal.user.sharedAllLibraries) { - seriesRepository.findAll(pageRequest) - } else { - seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest) - }.map { it.toDto(includeUrl = principal.user.isAdmin()) } + val libraryIds = if (principal.user.sharedAllLibraries) emptyList() else principal.user.sharedLibrariesIds + + return seriesDtoRepository.findAll( + SeriesSearch(libraryIds = libraryIds), + pageRequest + ).map { it.restrictUrl(!principal.user.roleAdmin) } } @Operation(description = "Return recently updated series, but not newly added ones.") @@ -160,11 +139,12 @@ class SeriesController( Sort.by(Sort.Direction.DESC, "lastModifiedDate") ) - return if (principal.user.sharedAllLibraries) { - seriesRepository.findRecentlyUpdated(pageRequest) - } else { - seriesRepository.findRecentlyUpdatedByLibraryIn(principal.user.sharedLibraries, pageRequest) - }.map { it.toDto(includeUrl = principal.user.isAdmin()) } + val libraryIds = if (principal.user.sharedAllLibraries) emptyList() else principal.user.sharedLibrariesIds + + return seriesDtoRepository.findRecentlyUpdated( + SeriesSearch(libraryIds = libraryIds), + pageRequest + ).map { it.restrictUrl(!principal.user.roleAdmin) } } @GetMapping("{seriesId}") @@ -172,35 +152,36 @@ class SeriesController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "seriesId") id: Long ): SeriesDto = - seriesRepository.findByIdOrNull(id)?.let { - if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - it.toDto(includeUrl = principal.user.isAdmin()) + seriesDtoRepository.findByIdOrNull(id)?.let { + if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) fun getSeriesThumbnail( @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable(name = "seriesId") id: Long - ): ResponseEntity = - seriesRepository.findByIdOrNull(id)?.let { series -> - if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - - series.books.minBy { it.metadata.numberSort }?.let { firstBook -> - bookController.getBookThumbnail(principal, firstBook.id) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + @PathVariable(name = "seriesId") seriesId: Long + ): ResponseEntity { + seriesRepository.getLibraryId(seriesId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return bookRepository.findFirstIdInSeries(seriesId)?.let { + bookController.getBookThumbnail(principal, it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @PageableAsQueryParam @GetMapping("{seriesId}/books") fun getAllBooksBySeries( @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable(name = "seriesId") id: Long, + @PathVariable(name = "seriesId") seriesId: Long, @RequestParam(name = "media_status", required = false) mediaStatus: List?, @Parameter(hidden = true) page: Pageable ): Page { - seriesRepository.findByIdOrNull(id)?.let { - if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + seriesRepository.getLibraryId(seriesId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) val pageRequest = PageRequest.of( @@ -210,28 +191,31 @@ class SeriesController( else Sort.by(Sort.Order.asc("metadata.numberSort")) ) - return (if (!mediaStatus.isNullOrEmpty()) - bookRepository.findAllByMediaStatusInAndSeriesId(mediaStatus, id, pageRequest) - else - bookRepository.findAllBySeriesId(id, pageRequest)).map { it.toDto(includeFullUrl = principal.user.isAdmin()) } + return bookDtoRepository.findAll( + BookSearch( + seriesIds = listOf(seriesId), + mediaStatus = mediaStatus ?: emptyList() + ), + pageRequest + ).map { it.restrictUrl(!principal.user.roleAdmin) } } @PostMapping("{seriesId}/analyze") @PreAuthorize("hasRole('ADMIN')") @ResponseStatus(HttpStatus.ACCEPTED) fun analyze(@PathVariable seriesId: Long) { - seriesRepository.findByIdOrNull(seriesId)?.let { series -> - series.books.forEach { taskReceiver.analyzeBook(it) } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + bookRepository.findAllIdBySeriesId(seriesId).forEach { + taskReceiver.analyzeBook(it) + } } @PostMapping("{seriesId}/metadata/refresh") @PreAuthorize("hasRole('ADMIN')") @ResponseStatus(HttpStatus.ACCEPTED) fun refreshMetadata(@PathVariable seriesId: Long) { - seriesRepository.findByIdOrNull(seriesId)?.let { series -> - series.books.forEach { taskReceiver.refreshBookMetadata(it) } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + bookRepository.findAllIdBySeriesId(seriesId).forEach { + taskReceiver.refreshBookMetadata(it) + } } @PatchMapping("{seriesId}/metadata") @@ -241,16 +225,19 @@ class SeriesController( @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 ): SeriesDto = - seriesRepository.findByIdOrNull(seriesId)?.let { series -> - with(newMetadata) { - status?.let { series.metadata.status = it } - statusLock?.let { series.metadata.statusLock = it } - title?.let { series.metadata.title = it } - titleLock?.let { series.metadata.titleLock = it } - titleSort?.let { series.metadata.titleSort = it } - titleSortLock?.let { series.metadata.titleSortLock = it } + 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 + ) } - seriesRepository.save(series).toDto(includeUrl = true) + seriesMetadataRepository.update(updated) + seriesDtoRepository.findByIdOrNull(seriesId)!! } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/UserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/UserController.kt index 27fd7f3b3..fc6b3f709 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/UserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/UserController.kt @@ -1,21 +1,23 @@ package org.gotson.komga.interfaces.rest import mu.KotlinLogging -import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException 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.security.KomgaPrincipal -import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle -import org.gotson.komga.infrastructure.security.UserEmailAlreadyExistsException +import org.gotson.komga.interfaces.rest.dto.PasswordUpdateDto +import org.gotson.komga.interfaces.rest.dto.SharedLibrariesUpdateDto +import org.gotson.komga.interfaces.rest.dto.UserCreationDto +import org.gotson.komga.interfaces.rest.dto.UserDto +import org.gotson.komga.interfaces.rest.dto.UserWithSharedLibrariesDto import org.gotson.komga.interfaces.rest.dto.toDto +import org.gotson.komga.interfaces.rest.dto.toWithSharedLibrariesDto import org.springframework.core.env.Environment -import org.springframework.data.repository.findByIdOrNull 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.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetails import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -27,15 +29,13 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException import javax.validation.Valid -import javax.validation.constraints.Email -import javax.validation.constraints.NotBlank private val logger = KotlinLogging.logger {} @RestController @RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE]) class UserController( - private val userDetailsLifecycle: KomgaUserDetailsLifecycle, + private val userLifecycle: KomgaUserLifecycle, private val userRepository: KomgaUserRepository, private val libraryRepository: LibraryRepository, env: Environment @@ -54,7 +54,7 @@ class UserController( @Valid @RequestBody newPasswordDto: PasswordUpdateDto ) { if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) - userDetailsLifecycle.updatePassword(principal, newPasswordDto.password, false) + userLifecycle.updatePassword(principal, newPasswordDto.password, false) } @GetMapping @@ -67,7 +67,7 @@ class UserController( @PreAuthorize("hasRole('ADMIN')") fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto = try { - (userDetailsLifecycle.createUser(newUser.toUserDetails()) as KomgaPrincipal).toDto() + userLifecycle.createUser(newUser.toDomain()).toDto() } catch (e: UserEmailAlreadyExistsException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") } @@ -80,7 +80,7 @@ class UserController( @AuthenticationPrincipal principal: KomgaPrincipal ) { userRepository.findByIdOrNull(id)?.let { - userDetailsLifecycle.deleteUser(it) + userLifecycle.deleteUser(it) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -92,63 +92,15 @@ class UserController( @Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto ) { userRepository.findByIdOrNull(id)?.let { user -> - if (sharedLibrariesUpdateDto.all) { - user.sharedAllLibraries = true - user.sharedLibraries = mutableSetOf() - } else { - user.sharedAllLibraries = false - user.sharedLibraries = libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds).toMutableSet() - } - userRepository.save(user) + val updatedUser = user.copy( + sharedAllLibraries = sharedLibrariesUpdateDto.all, + sharedLibrariesIds = if (sharedLibrariesUpdateDto.all) emptySet() + else libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds) + .map { it.id } + .toSet() + ) + userRepository.save(updatedUser) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } } -data class UserDto( - val id: Long, - val email: String, - val roles: List -) - -data class UserWithSharedLibrariesDto( - val id: Long, - val email: String, - val roles: List, - val sharedAllLibraries: Boolean, - val sharedLibraries: List -) - -data class SharedLibraryDto( - val id: Long, - val name: String -) - -fun KomgaUser.toWithSharedLibrariesDto() = - UserWithSharedLibrariesDto( - id = id, - email = email, - roles = roles.map { it.name }, - sharedAllLibraries = sharedAllLibraries, - sharedLibraries = sharedLibraries.map { SharedLibraryDto(it.id, it.name) } - ) - -data class UserCreationDto( - @get:Email val email: String, - @get:NotBlank val password: String, - val roles: List = emptyList() -) { - fun toUserDetails(): UserDetails = - User.withUsername(email) - .password(password) - .roles(*roles.toTypedArray()) - .build() -} - -data class PasswordUpdateDto( - @get:NotBlank val password: String -) - -data class SharedLibrariesUpdateDto( - val all: Boolean, - val libraryIds: Set -) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt index c49b180f6..0626a0572 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt @@ -1,32 +1,33 @@ package org.gotson.komga.interfaces.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.Author -import org.gotson.komga.domain.model.Book -import org.gotson.komga.domain.model.BookMetadata -import org.gotson.komga.domain.model.Media import java.time.LocalDate import java.time.LocalDateTime data class BookDto( val id: Long, val seriesId: Long, + val libraryId: Long, val name: String, val url: String, val number: Int, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val created: LocalDateTime?, + val created: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val lastModified: LocalDateTime?, + val lastModified: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val fileLastModified: LocalDateTime, val sizeBytes: Long, - val size: String, + val size: String = BinaryByteUnit.format(sizeBytes), val media: MediaDto, val metadata: BookMetadataDto ) +fun BookDto.restrictUrl(restrict: Boolean) = + if (restrict) copy(url = FilenameUtils.getName(url)) else this + data class MediaDto( val status: String, val mediaType: String, @@ -61,48 +62,3 @@ data class AuthorDto( val role: String ) -fun Book.toDto(includeFullUrl: Boolean) = - BookDto( - id = id, - seriesId = series.id, - name = name, - url = if (includeFullUrl) url.toURI().path else FilenameUtils.getName(url.toURI().path), - number = number, - created = createdDate?.toUTC(), - lastModified = lastModifiedDate?.toUTC(), - fileLastModified = fileLastModified.toUTC(), - sizeBytes = fileSize, - size = fileSizeHumanReadable(), - media = media.toDto(), - metadata = metadata.toDto() - ) - -fun Media.toDto() = MediaDto( - status = status.toString(), - mediaType = mediaType ?: "", - pagesCount = pages.size, - comment = comment ?: "" -) - -fun BookMetadata.toDto() = BookMetadataDto( - title = title, - titleLock = titleLock, - summary = summary, - summaryLock = summaryLock, - number = number, - numberLock = numberLock, - numberSort = numberSort, - numberSortLock = numberSortLock, - readingDirection = readingDirection?.name ?: "", - readingDirectionLock = readingDirectionLock, - publisher = publisher, - publisherLock = publisherLock, - ageRating = ageRating, - ageRatingLock = ageRatingLock, - releaseDate = releaseDate, - releaseDateLock = releaseDateLock, - authors = authors.map { it.toDto() }, - authorsLock = authorsLock -) - -fun Author.toDto() = AuthorDto(name = name, role = role) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt index 58c5bbc79..4bb0c1140 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt @@ -1,8 +1,6 @@ package org.gotson.komga.interfaces.rest.dto import com.fasterxml.jackson.annotation.JsonFormat -import org.gotson.komga.domain.model.Series -import org.gotson.komga.domain.model.SeriesMetadata import java.time.LocalDateTime data class SeriesDto( @@ -11,47 +9,27 @@ data class SeriesDto( val name: String, val url: String, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val created: LocalDateTime?, + val created: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val lastModified: LocalDateTime?, + val lastModified: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val fileLastModified: LocalDateTime, val booksCount: Int, val metadata: SeriesMetadataDto ) +fun SeriesDto.restrictUrl(restrict: Boolean) = + if (restrict) copy(url = "") else this + data class SeriesMetadataDto( val status: String, val statusLock: Boolean, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val created: LocalDateTime?, + val created: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - val lastModified: LocalDateTime?, + val lastModified: LocalDateTime, val title: String, val titleLock: Boolean, val titleSort: String, val titleSortLock: Boolean ) - -fun Series.toDto(includeUrl: Boolean) = SeriesDto( - id = id, - libraryId = library.id, - name = name, - url = if (includeUrl) url.toURI().path else "", - created = createdDate?.toUTC(), - lastModified = lastModifiedDate?.toUTC(), - fileLastModified = fileLastModified.toUTC(), - booksCount = books.size, - metadata = metadata.toDto() -) - -fun SeriesMetadata.toDto() = SeriesMetadataDto( - status = status.name, - statusLock = statusLock, - created = createdDate?.toUTC(), - lastModified = lastModifiedDate?.toUTC(), - title = title, - titleLock = titleLock, - titleSort = titleSort, - titleSortLock = titleSortLock -) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/UserDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/UserDto.kt index 39a927c9d..ec874fcac 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/UserDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/UserDto.kt @@ -2,13 +2,59 @@ package org.gotson.komga.interfaces.rest.dto import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.infrastructure.security.KomgaPrincipal -import org.gotson.komga.interfaces.rest.UserDto +import javax.validation.constraints.Email +import javax.validation.constraints.NotBlank + +data class UserDto( + val id: Long, + val email: String, + val roles: List +) fun KomgaUser.toDto() = UserDto( id = id, email = email, - roles = roles.map { it.name } + roles = roles().toList() ) fun KomgaPrincipal.toDto() = user.toDto() + +data class UserWithSharedLibrariesDto( + val id: Long, + val email: String, + val roles: List, + val sharedAllLibraries: Boolean, + val sharedLibraries: List +) + +data class SharedLibraryDto( + val id: Long +) + +fun KomgaUser.toWithSharedLibrariesDto() = + UserWithSharedLibrariesDto( + id = id, + email = email, + roles = roles().toList(), + sharedAllLibraries = sharedAllLibraries, + sharedLibraries = sharedLibrariesIds.map { SharedLibraryDto(it) } + ) + +data class UserCreationDto( + @get:Email val email: String, + @get:NotBlank val password: String, + val roles: List = emptyList() +) { + fun toDomain(): KomgaUser = + KomgaUser(email, password, roleAdmin = roles.contains("ADMIN")) +} + +data class PasswordUpdateDto( + @get:NotBlank val password: String +) + +data class SharedLibrariesUpdateDto( + val all: Boolean, + val libraryIds: Set +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt new file mode 100644 index 000000000..83d184a6b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/BookDtoRepository.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.rest.persistence + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.interfaces.rest.dto.BookDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface BookDtoRepository { + fun findAll(search: BookSearch, pageable: Pageable): Page + fun findByIdOrNull(bookId: Long): BookDto? + fun findPreviousInSeries(bookId: Long): BookDto? + fun findNextInSeries(bookId: Long): BookDto? +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt new file mode 100644 index 000000000..a476e1d44 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.rest.persistence + +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.interfaces.rest.dto.SeriesDto +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface SeriesDtoRepository { + fun findAll(search: SeriesSearch, pageable: Pageable): Page + fun findRecentlyUpdated(search: SeriesSearch, pageable: Pageable): Page + fun findByIdOrNull(seriesId: Long): SeriesDto? +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt index 73f58c99b..9856e29b0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt @@ -3,14 +3,12 @@ package org.gotson.komga.interfaces.scheduler import mu.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.infrastructure.security.KomgaUserDetailsLifecycle +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.security.core.userdetails.User import org.springframework.stereotype.Controller private val logger = KotlinLogging.logger {} @@ -18,24 +16,19 @@ private val logger = KotlinLogging.logger {} @Profile("!(test | claim)") @Controller class InitialUserController( - private val userDetailsLifecycle: KomgaUserDetailsLifecycle, + private val userLifecycle: KomgaUserLifecycle, private val initialUsers: List ) { @EventListener(ApplicationReadyEvent::class) fun createInitialUserOnStartupIfNoneExist() { - if (userDetailsLifecycle.countUsers() == 0L) { + if (userLifecycle.countUsers() == 0L) { logger.info { "No users exist in database, creating initial users" } initialUsers - .map { - User.withUsername(it.email) - .password(it.password) - .roles(*it.roles.map { it.name }.toTypedArray()) - .build() - }.forEach { - userDetailsLifecycle.createUser(it) - logger.info { "Initial user created. Login: ${it.username}, Password: ${it.password}" } + .forEach { + userLifecycle.createUser(it) + logger.info { "Initial user created. Login: ${it.email}, Password: ${it.password}" } } } } @@ -45,9 +38,9 @@ class InitialUserController( @Profile("dev") class InitialUsersDevConfiguration { @Bean - fun initialUsers() = listOf( - KomgaUser("admin@example.org", "admin", mutableSetOf(UserRoles.ADMIN)), - KomgaUser("user@example.org", "user") + fun initialUsers(): List = listOf( + KomgaUser("admin@example.org", "admin", roleAdmin = true), + KomgaUser("user@example.org", "user", roleAdmin = false) ) } @@ -55,7 +48,7 @@ class InitialUsersDevConfiguration { @Profile("!dev") class InitialUsersProdConfiguration { @Bean - fun initialUsers() = listOf( - KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), mutableSetOf(UserRoles.ADMIN)) + fun initialUsers(): List = listOf( + KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), roleAdmin = true) ) } diff --git a/komga/src/main/resources/application-dev.yml b/komga/src/main/resources/application-dev.yml index 13cb2b003..b7cf93f33 100644 --- a/komga/src/main/resources/application-dev.yml +++ b/komga/src/main/resources/application-dev.yml @@ -9,12 +9,6 @@ komga: spring: datasource: url: jdbc:h2:mem:testdb - jpa: - properties: - hibernate: - generate_statistics: true - session.events.log: false - format_sql: true artemis: embedded: data-directory: ./artemis @@ -24,14 +18,11 @@ logging: name: komga-dev.log level: org.apache.activemq.audit.message: WARN +# org.jooq: DEBUG # web: DEBUG # org.gotson.komga: DEBUG # org.springframework.jms: DEBUG # org.springframework.security.web.FilterChainProxy: DEBUG -# org.hibernate.stat: DEBUG -# org.hibernate.SQL: DEBUG -# org.hibernate.cache: DEBUG -# org.hibernate.type.descriptor.sql.BasicBinder: TRACE management.metrics.export.influx: # enabled: true diff --git a/komga/src/main/resources/application-generatesql.yml b/komga/src/main/resources/application-generatesql.yml deleted file mode 100644 index 85ed8552c..000000000 --- a/komga/src/main/resources/application-generatesql.yml +++ /dev/null @@ -1,14 +0,0 @@ -spring: - flyway: - enabled: false - jpa: - hibernate: - ddl-auto: none - properties: - javax: - persistence: - schema-generation: - create-source: metadata - scripts: - action: create - create-target: build/generated/ddl_jpa_creation.sql \ No newline at end of file diff --git a/komga/src/main/resources/application-noflyway.yml b/komga/src/main/resources/application-noflyway.yml deleted file mode 100644 index 37187d603..000000000 --- a/komga/src/main/resources/application-noflyway.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - flyway: - enabled: false - jpa: - hibernate: - ddl-auto: create diff --git a/komga/src/main/resources/application.conf b/komga/src/main/resources/application.conf deleted file mode 100644 index abece628e..000000000 --- a/komga/src/main/resources/application.conf +++ /dev/null @@ -1,111 +0,0 @@ -caffeine.jcache { - cache.library { - monitoring { - statistics = true - } - policy { - maximum { - size = 50 - } - } - } - - cache.series { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.series.collection.books { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.book { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.media { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.media.collection.pages { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.series_metadata { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - cache.book_metadata { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - default-update-timestamps-region { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } - - default-query-results-region { - monitoring { - statistics = true - } - policy { - maximum { - size = 500 - } - } - } -} diff --git a/komga/src/main/resources/application.yml b/komga/src/main/resources/application.yml index 064488a6c..4e2c16c43 100644 --- a/komga/src/main/resources/application.yml +++ b/komga/src/main/resources/application.yml @@ -23,20 +23,6 @@ spring: enabled: true resources: add-mappings: false - jpa: - hibernate: - ddl-auto: validate - properties: - javax: - persistence.sharedCache.mode: ENABLE_SELECTIVE - cache.provider: com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider - hibernate: - generate_statistics: true - session.events.log: false - cache: - use_second_level_cache: true - use_query_cache: true - region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactory flyway: enabled: true thymeleaf: diff --git a/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskHandlerTest.kt b/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskHandlerTest.kt index eb960233c..35d6e93a6 100644 --- a/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskHandlerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/application/tasks/TaskHandlerTest.kt @@ -4,12 +4,14 @@ import com.ninjasquad.springmockk.MockkBean import io.mockk.every import io.mockk.verify import mu.KotlinLogging -import org.gotson.komga.application.service.MetadataLifecycle 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.domain.service.LibraryLifecycle +import org.gotson.komga.domain.service.MetadataLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach @@ -32,13 +34,15 @@ class TaskHandlerTest( @Autowired private val taskReceiver: TaskReceiver, @Autowired private val jmsTemplate: JmsTemplate, @Autowired private val libraryRepository: LibraryRepository, - @Autowired private val seriesRepository: SeriesRepository + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val libraryLifecycle: LibraryLifecycle ) { @MockkBean private lateinit var mockMetadataLifecycle: MetadataLifecycle - private val library = makeLibrary() + private var library = makeLibrary() init { jmsTemplate.receiveTimeout = JmsDestinationAccessor.RECEIVE_TIMEOUT_NO_WAIT @@ -46,7 +50,7 @@ class TaskHandlerTest( @BeforeAll fun `setup library`() { - libraryRepository.save(library) + library = libraryRepository.insert(library) } @AfterAll @@ -56,7 +60,7 @@ class TaskHandlerTest( @AfterEach fun `clear repository`() { - seriesRepository.deleteAll() + libraryLifecycle.deleteLibrary(library) } @AfterEach @@ -68,14 +72,18 @@ class TaskHandlerTest( @Test fun `when similar tasks are submitted then only a few are executed`() { - val book = makeBook("book") - val series = makeSeries("series", listOf(book)).also { it.library = library } - seriesRepository.save(series) + val book = makeBook("book", libraryId = library.id) + val series = makeSeries("series", libraryId = library.id) + seriesLifecycle.createSeries(series).let { + seriesLifecycle.addBooks(it, listOf(book)) + } every { mockMetadataLifecycle.refreshMetadata(any()) } answers { Thread.sleep(1_000) } + val createdBook = bookRepository.findAll().first() + repeat(100) { - taskReceiver.refreshBookMetadata(book) + taskReceiver.refreshBookMetadata(createdBook) } Thread.sleep(5_000) diff --git a/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt b/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt index fb3c7b376..5e28471ed 100644 --- a/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/architecture/DomainDrivenDesignRulesTest.kt @@ -7,18 +7,11 @@ 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 javax.persistence.Entity @AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) class DomainDrivenDesignRulesTest { - @ArchTest - val entities_must_reside_in_a_domain_package: ArchRule = - classes() - .that().areAnnotatedWith(Entity::class.java) - .should().resideInAPackage("..domain..model..") - @ArchTest val domain_persistence_can_only_contain_interfaces: ArchRule = classes() diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt index 04242d6a3..bdcabd02a 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/AuthorTest.kt @@ -1,30 +1,9 @@ package org.gotson.komga.domain.model import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.catchThrowable import org.junit.jupiter.api.Test class AuthorTest { - @Test - fun `given blank parameters when creating object then IllegalArgumentException is thrown`() { - val blankName = catchThrowable { Author(name = "", role = "role") } - val blankRole = catchThrowable { Author(name = "name", role = "") } - - assertThat(blankName).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankRole).isInstanceOf(IllegalArgumentException::class.java) - } - - @Test - fun `given blank parameters when setting fields then IllegalArgumentException is thrown`() { - val author = Author(name = "name", role = "role") - - val blankName = catchThrowable { author.name = " " } - val blankRole = catchThrowable { author.role = " " } - - assertThat(blankName).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankRole).isInstanceOf(IllegalArgumentException::class.java) - } - @Test fun `given untrimmed parameters when creating object then fields are trimmed and role is lowercase`() { val author = Author(name = " name ", role = " Role ") @@ -32,15 +11,4 @@ class AuthorTest { assertThat(author.name).isEqualTo("name") assertThat(author.role).isEqualTo("role") } - - @Test - fun `given untrimmed parameters when setting fields then fields are trimmed and role is lowercase`() { - val author = Author(name = "name", role = "role") - - author.name = " setName " - author.role = " set Role " - - assertThat(author.name).isEqualTo("setName") - assertThat(author.role).isEqualTo("set role") - } } diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt index b0d985b70..100450c6a 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/BookMetadataTest.kt @@ -1,31 +1,9 @@ package org.gotson.komga.domain.model import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.catchThrowable import org.junit.jupiter.api.Test class BookMetadataTest { - @Test - fun `given blank parameters when creating object then IllegalArgumentException is thrown`() { - val blankTitle = catchThrowable { BookMetadata(title = "", number = "1", numberSort = 1F) } - val blankNumber = catchThrowable { BookMetadata(title = "title", number = "", numberSort = 1F) } - - assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankNumber).isInstanceOf(IllegalArgumentException::class.java) - } - - @Test - fun `given blank parameters when setting fields then IllegalArgumentException is thrown`() { - val metadata = BookMetadata(title = "title", number = "1", numberSort = 1F) - - val blankTitle = catchThrowable { metadata.title = "" } - val blankNumber = catchThrowable { metadata.number = "" } - - - assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankNumber).isInstanceOf(IllegalArgumentException::class.java) - } - @Test fun `given untrimmed parameters when creating object then fields are trimmed`() { val metadata = BookMetadata(title = " title ", number = " number ", numberSort = 1F) @@ -33,15 +11,4 @@ class BookMetadataTest { assertThat(metadata.title).isEqualTo("title") assertThat(metadata.number).isEqualTo("number") } - - @Test - fun `given untrimmed parameters when setting fields then fields are trimmed`() { - val metadata = BookMetadata(title = "title", number = "number", numberSort = 1F) - - metadata.title = " setTitle " - metadata.number = " setNumber " - - assertThat(metadata.title).isEqualTo("setTitle") - assertThat(metadata.number).isEqualTo("setNumber") - } } diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt index 756e2016e..35a93dec3 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/SeriesMetadataTest.kt @@ -1,30 +1,9 @@ package org.gotson.komga.domain.model import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.catchThrowable import org.junit.jupiter.api.Test class SeriesMetadataTest { - @Test - fun `given blank parameters when creating object then IllegalArgumentException is thrown`() { - val blankTitle = catchThrowable { SeriesMetadata(title = "", titleSort = "title") } - val blankTitleSort = catchThrowable { SeriesMetadata(title = "title", titleSort = "") } - - assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankTitleSort).isInstanceOf(IllegalArgumentException::class.java) - } - - @Test - fun `given blank parameters when setting fields then IllegalArgumentException is thrown`() { - val metadata = SeriesMetadata(title = "title") - - val blankTitle = catchThrowable { metadata.title = "" } - val blankTitleSort = catchThrowable { metadata.titleSort = "" } - - assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java) - assertThat(blankTitleSort).isInstanceOf(IllegalArgumentException::class.java) - } - @Test fun `given untrimmed parameters when creating object then fields are trimmed`() { val metadata = SeriesMetadata(title = " title ", titleSort = " titleSort ") @@ -32,15 +11,4 @@ class SeriesMetadataTest { assertThat(metadata.title).isEqualTo("title") assertThat(metadata.titleSort).isEqualTo("titleSort") } - - @Test - fun `given untrimmed parameters when setting fields then fields are trimmed`() { - val metadata = SeriesMetadata(title = "title") - - metadata.title = " setTitle " - metadata.titleSort = " setTitleSort " - - assertThat(metadata.title).isEqualTo("setTitle") - assertThat(metadata.titleSort).isEqualTo("setTitleSort") - } } diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt index 382fc1800..53d8df02e 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt @@ -3,14 +3,25 @@ package org.gotson.komga.domain.model import java.net.URL import java.time.LocalDateTime -fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now()): Book { +fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now(), libraryId: Long = 0, seriesId: Long = 0): Book { Thread.sleep(5) - return Book(name = name, url = URL("file:/$name"), fileLastModified = fileLastModified) + return Book( + name = name, + url = URL("file:/$name"), + fileLastModified = fileLastModified, + libraryId = libraryId, + seriesId = seriesId + ) } -fun makeSeries(name: String, books: List = listOf()): Series { +fun makeSeries(name: String, libraryId: Long = 0): Series { Thread.sleep(5) - return Series(name = name, url = URL("file:/$name"), fileLastModified = LocalDateTime.now(), books = books.toMutableList()) + return Series( + name = name, + url = URL("file:/$name"), + fileLastModified = LocalDateTime.now(), + libraryId = libraryId + ) } fun makeLibrary(name: String = "default", url: String = "file:/$name"): Library { diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/persistence/AuditableEntityTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/persistence/AuditableEntityTest.kt deleted file mode 100644 index 949486cf4..000000000 --- a/komga/src/test/kotlin/org/gotson/komga/domain/persistence/AuditableEntityTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.gotson.komga.domain.persistence - -import org.assertj.core.api.Assertions.assertThat -import org.gotson.komga.domain.model.makeBook -import org.gotson.komga.domain.model.makeLibrary -import org.gotson.komga.domain.model.makeSeries -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.api.extension.ExtendWith -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.test.context.junit.jupiter.SpringExtension -import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime - -@ExtendWith(SpringExtension::class) -@DataJpaTest -@Transactional -class AuditableEntityTest( - @Autowired private val seriesRepository: SeriesRepository, - @Autowired private val libraryRepository: LibraryRepository -) { - - private val library = makeLibrary() - - @BeforeAll - fun `setup library`() { - libraryRepository.save(library) - } - - @AfterAll - fun `teardown library`() { - libraryRepository.deleteAll() - } - - @AfterEach - fun `clear repository`() { - seriesRepository.deleteAll() - } - - @Test - fun `given series with book when saving then created and modified date is also saved`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - - // when - seriesRepository.save(series) - - // then - assertThat(series.createdDate).isBefore(LocalDateTime.now()) - assertThat(series.lastModifiedDate).isBefore(LocalDateTime.now()) - assertThat(series.books.first().createdDate).isBefore(LocalDateTime.now()) - assertThat(series.books.first().lastModifiedDate).isBefore(LocalDateTime.now()) - } - - @Test - fun `given existing series with book when updating series only then created date is kept and modified date is changed for series only`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - - seriesRepository.save(series) - - val creationTimeApprox = LocalDateTime.now() - - Thread.sleep(1000) - - // when - series.name = "seriesUpdated" - seriesRepository.saveAndFlush(series) - - val modificationTimeApprox = LocalDateTime.now() - - // then - assertThat(series.createdDate) - .isBefore(creationTimeApprox) - .isNotEqualTo(series.lastModifiedDate) - assertThat(series.lastModifiedDate) - .isAfter(creationTimeApprox) - .isBefore(modificationTimeApprox) - - assertThat(series.books.first().createdDate) - .isBefore(creationTimeApprox) - .isEqualTo(series.books.first().lastModifiedDate) - } - - @Test - fun `given existing series with book when updating book only then created date is kept and modified date is changed for book only`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - - seriesRepository.save(series) - - val creationTimeApprox = LocalDateTime.now() - - Thread.sleep(1000) - - // when - series.books.first().name = "bookUpdated" - seriesRepository.saveAndFlush(series) - - val modificationTimeApprox = LocalDateTime.now() - - // then - assertThat(series.createdDate) - .isBefore(creationTimeApprox) - .isEqualTo(series.lastModifiedDate) - - assertThat(series.books.first().createdDate) - .isBefore(creationTimeApprox) - .isNotEqualTo(series.books.first().lastModifiedDate) - assertThat(series.books.first().lastModifiedDate) - .isAfter(creationTimeApprox) - .isBefore(modificationTimeApprox) - } - - @Test - fun `given existing book with media when updating media only then created date is kept and modified date is changed for media only`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - - seriesRepository.save(series) - - val creationTimeApprox = LocalDateTime.now() - - Thread.sleep(1000) - - // when - series.books.first().media.comment = "mediaUpdated" - seriesRepository.saveAndFlush(series) - - val modificationTimeApprox = LocalDateTime.now() - - // then - assertThat(series.createdDate) - .isBefore(creationTimeApprox) - .isEqualTo(series.lastModifiedDate) - - assertThat(series.books.first().createdDate) - .isBefore(creationTimeApprox) - .isEqualTo(series.books.first().lastModifiedDate) - - assertThat(series.books.first().media.createdDate) - .isBefore(creationTimeApprox) - .isNotEqualTo(series.books.first().media.lastModifiedDate) - - assertThat(series.books.first().media.lastModifiedDate) - .isAfter(creationTimeApprox) - .isBefore(modificationTimeApprox) - } -} diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/persistence/PersistenceTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/persistence/PersistenceTest.kt deleted file mode 100644 index 4fa9ff634..000000000 --- a/komga/src/test/kotlin/org/gotson/komga/domain/persistence/PersistenceTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.gotson.komga.domain.persistence - -import org.assertj.core.api.Assertions.assertThat -import org.gotson.komga.domain.model.Media -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.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.api.extension.ExtendWith -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.test.context.junit.jupiter.SpringExtension -import org.springframework.transaction.annotation.Transactional - -@ExtendWith(SpringExtension::class) -@DataJpaTest -@Transactional -class PersistenceTest( - @Autowired private val seriesRepository: SeriesRepository, - @Autowired private val bookRepository: BookRepository, - @Autowired private val mediaRepository: MediaRepository, - @Autowired private val libraryRepository: LibraryRepository -) { - - private val library = makeLibrary() - - @BeforeAll - fun `setup library`() { - libraryRepository.save(library) - } - - @AfterAll - fun `teardown library`() { - libraryRepository.deleteAll() - } - - @AfterEach - fun `clear repository`() { - seriesRepository.deleteAll() - } - - @Test - fun `given series with book when saving then media is also saved`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - - // when - seriesRepository.save(series) - - // then - assertThat(seriesRepository.count()).isEqualTo(1) - assertThat(bookRepository.count()).isEqualTo(1) - assertThat(mediaRepository.count()).isEqualTo(1) - } - - @Test - fun `given series with unordered books when saving then books are ordered with natural sort`() { - // given - val series = makeSeries(name = "series", books = listOf( - makeBook("book 1"), - makeBook("book 05"), - makeBook("book 6"), - makeBook("book 002") - )).also { it.library = library } - - // when - seriesRepository.save(series) - - // then - assertThat(seriesRepository.count()).isEqualTo(1) - assertThat(bookRepository.count()).isEqualTo(4) - assertThat(seriesRepository.findAll().first().books.map { it.name }) - .containsExactly("book 1", "book 002", "book 05", "book 6") - } - - @Test - fun `given existing book when updating media then new media is saved`() { - // given - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))).also { it.library = library } - seriesRepository.save(series) - - // when - val book = bookRepository.findAll().first() - book.media = Media(status = Media.Status.READY, mediaType = "test", pages = mutableListOf(makeBookPage("page1"))) - - bookRepository.save(book) - - // then - assertThat(seriesRepository.count()).isEqualTo(1) - assertThat(bookRepository.count()).isEqualTo(1) - assertThat(mediaRepository.count()).isEqualTo(1) - mediaRepository.findAll().first().let { - assertThat(it.status == Media.Status.READY) - assertThat(it.mediaType == "test") - assertThat(it.pages).hasSize(1) - assertThat(it.pages.first().fileName).isEqualTo("page1") - } - } -} diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt index 1dfb95011..a0a9e54ba 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt @@ -25,10 +25,10 @@ class FileSystemScannerTest { Files.createDirectory(root) // when - val series = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) // then - assertThat(series).isEmpty() + assertThat(scan).isEmpty() } } @@ -43,12 +43,14 @@ class FileSystemScannerTest { files.forEach { Files.createFile(root.resolve(it)) } // when - val series = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) + val series = scan.keys.first() + val books = scan.getValue(series) // then - assertThat(series).hasSize(1) - assertThat(series.first().books).hasSize(2) - assertThat(series.first().books.map { it.name }).containsExactlyInAnyOrderElementsOf(files.map { FilenameUtils.removeExtension(it) }) + assertThat(scan).hasSize(1) + assertThat(books).hasSize(2) + assertThat(books.map { it.name }).containsExactlyInAnyOrderElementsOf(files.map { FilenameUtils.removeExtension(it) }) } } @@ -63,12 +65,14 @@ class FileSystemScannerTest { files.forEach { Files.createFile(root.resolve(it)) } // when - val series = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) + val series = scan.keys.first() + val books = scan.getValue(series) // then - assertThat(series).hasSize(1) - assertThat(series.first().books).hasSize(1) - assertThat(series.first().books.map { it.name }).containsExactly("file1") + assertThat(scan).hasSize(1) + assertThat(books).hasSize(1) + assertThat(books.map { it.name }).containsExactly("file1") } } @@ -89,14 +93,15 @@ class FileSystemScannerTest { } // when - val scannedSeries = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) + val series = scan.keys // then - assertThat(scannedSeries).hasSize(2) + assertThat(scan).hasSize(2) - assertThat(scannedSeries.map { it.name }).containsExactlyInAnyOrderElementsOf(subDirs.keys) - scannedSeries.forEach { series -> - assertThat(series.books.map { it.name }).containsExactlyInAnyOrderElementsOf(subDirs[series.name]?.map { FilenameUtils.removeExtension(it) }) + 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) }) } } } @@ -114,13 +119,13 @@ class FileSystemScannerTest { makeSubDir(recycle, "subtrash", listOf("trash2.cbz")) // when - val scannedSeries = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) // then - assertThat(scannedSeries).hasSize(2) + assertThat(scan).hasSize(2) - assertThat(scannedSeries.map { it.name }).containsExactlyInAnyOrder("dir1", "subdir1") - assertThat(scannedSeries.flatMap { it.books }.map { it.name }).containsExactlyInAnyOrder("comic", "comic2") + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1", "subdir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") } } @@ -134,13 +139,13 @@ class FileSystemScannerTest { makeSubDir(root, "dir1", listOf("comic.Cbz", "comic2.CBR")) // when - val scannedSeries = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root) // then - assertThat(scannedSeries).hasSize(1) + assertThat(scan).hasSize(1) - assertThat(scannedSeries.map { it.name }).containsExactlyInAnyOrder("dir1") - assertThat(scannedSeries.flatMap { it.books }.map { it.name }).containsExactlyInAnyOrder("comic", "comic2") + assertThat(scan.keys.map { it.name }).containsExactlyInAnyOrder("dir1") + assertThat(scan.values.flatMap { list -> list.map { it.name } }).containsExactlyInAnyOrder("comic", "comic2") } } diff --git a/komga/src/test/kotlin/org/gotson/komga/application/service/LibraryLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt similarity index 98% rename from komga/src/test/kotlin/org/gotson/komga/application/service/LibraryLifecycleTest.kt rename to komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt index a7f96f0f8..3438b8763 100644 --- a/komga/src/test/kotlin/org/gotson/komga/application/service/LibraryLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryLifecycleTest.kt @@ -1,4 +1,4 @@ -package org.gotson.komga.application.service +package org.gotson.komga.domain.service import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.catchThrowable diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryScannerTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryScannerTest.kt index 2575846e4..80ac50b2b 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryScannerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryScannerTest.kt @@ -4,7 +4,6 @@ import com.ninjasquad.springmockk.MockkBean import io.mockk.every import io.mockk.verify import org.assertj.core.api.Assertions.assertThat -import org.gotson.komga.application.service.BookLifecycle import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBookPage @@ -12,6 +11,7 @@ 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.MediaRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -20,9 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.junit.jupiter.SpringExtension -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate import java.nio.file.Paths @ExtendWith(SpringExtension::class) @@ -34,7 +31,8 @@ class LibraryScannerTest( @Autowired private val bookRepository: BookRepository, @Autowired private val libraryScanner: LibraryScanner, @Autowired private val bookLifecycle: BookLifecycle, - @Autowired private val transactionManager: PlatformTransactionManager + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle ) { @MockkBean @@ -45,22 +43,22 @@ class LibraryScannerTest( @AfterEach fun `clear repositories`() { - seriesRepository.deleteAll() - libraryRepository.deleteAll() + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } } @Test - @Transactional fun `given existing series when adding files and scanning then only updated Books are persisted`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))) - val seriesWithMoreBooks = makeSeries(name = "series", books = listOf(makeBook("book1"), makeBook("book2"))) + val books = listOf(makeBook("book1")) + val moreBooks = listOf(makeBook("book1"), makeBook("book2")) every { mockScanner.scanRootFolder(any()) }.returnsMany( - listOf(series), - listOf(seriesWithMoreBooks) + mapOf(makeSeries(name = "series") to books), + mapOf(makeSeries(name = "series") to moreBooks) ) libraryScanner.scanRootFolder(library) @@ -69,27 +67,27 @@ class LibraryScannerTest( // then val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } verify(exactly = 2) { mockScanner.scanRootFolder(any()) } assertThat(allSeries).hasSize(1) - assertThat(allSeries.first().books).hasSize(2) - assertThat(allSeries.first().books.map { it.name }).containsExactly("book1", "book2") + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.name }).containsExactly("book1", "book2") } @Test - @Transactional fun `given existing series when removing files and scanning then only updated Books are persisted`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) - val series = makeSeries(name = "series", books = listOf(makeBook("book1"), makeBook("book2"))) - val seriesWithLessBooks = makeSeries(name = "series", books = listOf(makeBook("book1"))) + val books = listOf(makeBook("book1"), makeBook("book2")) + val lessBooks = listOf(makeBook("book1")) every { mockScanner.scanRootFolder(any()) } .returnsMany( - listOf(series), - listOf(seriesWithLessBooks) + mapOf(makeSeries(name = "series") to books), + mapOf(makeSeries(name = "series") to lessBooks) ) libraryScanner.scanRootFolder(library) @@ -98,28 +96,28 @@ class LibraryScannerTest( // then val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } verify(exactly = 2) { mockScanner.scanRootFolder(any()) } assertThat(allSeries).hasSize(1) - assertThat(allSeries.first().books).hasSize(1) - assertThat(allSeries.first().books.map { it.name }).containsExactly("book1") + assertThat(allBooks).hasSize(1) + assertThat(allBooks.map { it.name }).containsExactly("book1") assertThat(bookRepository.count()).describedAs("Orphan book has been removed").isEqualTo(1) } @Test - @Transactional fun `given existing series when updating files and scanning then Books are updated`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) - val series = makeSeries(name = "series", books = listOf(makeBook("book1"))) - val seriesWithUpdatedBooks = makeSeries(name = "series", books = listOf(makeBook("book1"))) + val books = listOf(makeBook("book1")) + val updatedBooks = listOf(makeBook("book1")) every { mockScanner.scanRootFolder(any()) } .returnsMany( - listOf(series), - listOf(seriesWithUpdatedBooks) + mapOf(makeSeries(name = "series") to books), + mapOf(makeSeries(name = "series") to updatedBooks) ) libraryScanner.scanRootFolder(library) @@ -128,25 +126,25 @@ class LibraryScannerTest( // then val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() verify(exactly = 2) { mockScanner.scanRootFolder(any()) } assertThat(allSeries).hasSize(1) - assertThat(allSeries.first().lastModifiedDate).isNotEqualTo(allSeries.first().createdDate) - assertThat(allSeries.first().books).hasSize(1) - assertThat(allSeries.first().books.map { it.name }).containsExactly("book1") - assertThat(allSeries.first().books.first().lastModifiedDate).isNotEqualTo(allSeries.first().books.first().createdDate) + assertThat(allBooks).hasSize(1) + assertThat(allBooks.map { it.name }).containsExactly("book1") + assertThat(allBooks.first().lastModifiedDate).isNotEqualTo(allBooks.first().createdDate) } @Test fun `given existing series when deleting all books and scanning then Series and Books are removed`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) every { mockScanner.scanRootFolder(any()) } .returnsMany( - listOf(makeSeries(name = "series", books = listOf(makeBook("book1")))), - emptyList() + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))), + emptyMap() ) libraryScanner.scanRootFolder(library) @@ -163,12 +161,15 @@ class LibraryScannerTest( @Test fun `given existing Series when deleting all books of one series and scanning then series and its Books are removed`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) every { mockScanner.scanRootFolder(any()) } .returnsMany( - listOf(makeSeries(name = "series", books = listOf(makeBook("book1"))), makeSeries(name = "series2", books = listOf(makeBook("book2")))), - listOf(makeSeries(name = "series", books = listOf(makeBook("book1")))) + mapOf( + makeSeries(name = "series") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book2")) + ), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))) ) libraryScanner.scanRootFolder(library) @@ -178,20 +179,20 @@ class LibraryScannerTest( // then verify(exactly = 2) { mockScanner.scanRootFolder(any()) } - assertThat(seriesRepository.count()).describedAs("Series repository should be empty").isEqualTo(1) - assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(1) + assertThat(seriesRepository.count()).describedAs("Series repository should not be empty").isEqualTo(1) + assertThat(bookRepository.count()).describedAs("Book repository should not be empty").isEqualTo(1) } @Test fun `given existing Book with media when rescanning then media is kept intact`() { // given - val library = libraryRepository.save(makeLibrary()) + val library = libraryRepository.insert(makeLibrary()) val book1 = makeBook("book1") every { mockScanner.scanRootFolder(any()) } .returnsMany( - listOf(makeSeries(name = "series", books = listOf(book1))), - listOf(makeSeries(name = "series", books = listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified)))) + mapOf(makeSeries(name = "series") to listOf(book1)), + mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))) ) libraryScanner.scanRootFolder(library) @@ -205,28 +206,31 @@ class LibraryScannerTest( verify(exactly = 2) { mockScanner.scanRootFolder(any()) } verify(exactly = 1) { mockAnalyzer.analyze(any()) } - TransactionTemplate(transactionManager).execute { - val book = bookRepository.findAll().first() - assertThat(book.media.status).isEqualTo(Media.Status.READY) - assertThat(book.media.mediaType).isEqualTo("application/zip") - assertThat(book.media.pages).hasSize(2) - assertThat(book.media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg") + 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 2 libraries when deleting all books of one and scanning then the other library is kept intact`() { // given - val library1 = libraryRepository.save(makeLibrary(name = "library1")) - val library2 = libraryRepository.save(makeLibrary(name = "library2")) + val library1 = libraryRepository.insert(makeLibrary(name = "library1")) + val library2 = libraryRepository.insert(makeLibrary(name = "library2")) every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns - listOf(makeSeries(name = "series1", books = listOf(makeBook("book1")))) + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1"))) every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany( - listOf(makeSeries(name = "series2", books = listOf(makeBook("book2")))), - emptyList() + mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))), + emptyMap() ) libraryScanner.scanRootFolder(library1) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt new file mode 100644 index 000000000..fbc4e68fc --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/SeriesLifecycleTest.kt @@ -0,0 +1,133 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +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.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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class SeriesLifecycleTest( + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + + @BeforeAll + fun `setup library`() { + library = libraryRepository.insert(library) + } + + @AfterAll + fun `teardown library`() { + libraryRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesRepository.findAll().forEach { + seriesLifecycle.deleteSeries(it.id) + } + } + + @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("book 05", 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(4) + + val savedBooks = bookRepository.findBySeriesId(createdSeries.id).sortedBy { it.number } + assertThat(savedBooks.map { it.name }).containsExactly("book 1", "book 002", "book 05", "book 6") + assertThat(savedBooks.map { it.number }).containsExactly(1, 2, 3, 4) + } + + @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.findBySeriesId(createdSeries.id).first { it.name == "book 2" } + bookLifecycle.delete(book.id) + seriesLifecycle.sortBooks(createdSeries) + + // then + assertThat(seriesRepository.count()).isEqualTo(1) + assertThat(bookRepository.count()).isEqualTo(3) + + val savedBooks = bookRepository.findBySeriesId(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.findBySeriesId(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) + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookDaoTest.kt new file mode 100644 index 000000000..2c3de4b7e --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookDaoTest.kt @@ -0,0 +1,196 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookSearch +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.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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.net.URL +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class BookDaoTest( + @Autowired private val bookDao: BookDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + private var series = makeSeries("Series") + + @BeforeAll + fun setup() { + library = libraryRepository.insert(library) + series = 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, + seriesId = series.id, + libraryId = library.id + ) + + Thread.sleep(5) + + val created = bookDao.insert(book) + + assertThat(created.id).isNotEqualTo(0) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + assertThat(created.name).isEqualTo(book.name) + assertThat(created.url).isEqualTo(book.url) + assertThat(created.fileLastModified).isEqualTo(book.fileLastModified) + assertThat(created.fileSize).isEqualTo(book.fileSize) + } + + @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 + ) + val created = bookDao.insert(book) + + Thread.sleep(5) + + val modificationDate = LocalDateTime.now() + + val updated = with(created) { + copy( + name = "Updated", + url = URL("file://updated"), + fileLastModified = modificationDate, + fileSize = 5 + ) + } + + bookDao.update(updated) + val modified = bookDao.findByIdOrNull(updated.id)!! + + assertThat(modified.id).isEqualTo(updated.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isAfterOrEqualTo(modificationDate) + .isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.name).isEqualTo("Updated") + assertThat(modified.url).isEqualTo(URL("file://updated")) + assertThat(modified.fileLastModified).isEqualTo(modificationDate) + assertThat(modified.fileSize).isEqualTo(5) + } + + @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 + ) + val created = bookDao.insert(book) + + val found = bookDao.findByIdOrNull(created.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 = BookSearch( + libraryIds = listOf(library.id), + seriesIds = listOf(series.id) + ) + val found = bookDao.findAll(search) + + 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.findAllIdByLibraryId(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.findAllIdBySeriesId(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/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt new file mode 100644 index 000000000..564306948 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt @@ -0,0 +1,255 @@ +package org.gotson.komga.infrastructure.jooq + +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.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.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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDate +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class BookMetadataDaoTest( + @Autowired private val bookMetadataDao: BookMetadataDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + private var library = makeLibrary() + private var series = makeSeries("Series") + private var book = makeBook("Book") + + @BeforeAll + fun setup() { + library = libraryRepository.insert(library) + + series = seriesRepository.insert(series.copy(libraryId = library.id)) + + book = 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, + readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT, + publisher = "publisher", + ageRating = 18, + releaseDate = LocalDate.now(), + authors = mutableListOf(Author("author", "role")), + bookId = book.id, + titleLock = true, + summaryLock = true, + numberLock = true, + numberSortLock = true, + readingDirectionLock = true, + publisherLock = true, + ageRatingLock = true, + releaseDateLock = true, + authorsLock = true + ) + + Thread.sleep(5) + + val created = bookMetadataDao.insert(metadata) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + + 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.readingDirection).isEqualTo(metadata.readingDirection) + assertThat(created.publisher).isEqualTo(metadata.publisher) + assertThat(created.ageRating).isEqualTo(metadata.ageRating) + 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.titleLock).isEqualTo(metadata.titleLock) + assertThat(created.summaryLock).isEqualTo(metadata.summaryLock) + assertThat(created.numberLock).isEqualTo(metadata.numberLock) + assertThat(created.numberSortLock).isEqualTo(metadata.numberSortLock) + assertThat(created.readingDirectionLock).isEqualTo(metadata.readingDirectionLock) + assertThat(created.publisherLock).isEqualTo(metadata.publisherLock) + assertThat(created.ageRatingLock).isEqualTo(metadata.ageRatingLock) + assertThat(created.releaseDateLock).isEqualTo(metadata.releaseDateLock) + assertThat(created.authorsLock).isEqualTo(metadata.authorsLock) + } + + @Test + fun `given a minimum metadata when inserting then it is persisted`() { + val metadata = BookMetadata( + title = "Book", + number = "1", + numberSort = 1F, + bookId = book.id + ) + + val created = bookMetadataDao.insert(metadata) + + 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.readingDirection).isNull() + assertThat(created.publisher).isBlank() + assertThat(created.ageRating).isNull() + assertThat(created.releaseDate).isNull() + assertThat(created.authors).isEmpty() + + assertThat(created.titleLock).isFalse() + assertThat(created.summaryLock).isFalse() + assertThat(created.numberLock).isFalse() + assertThat(created.numberSortLock).isFalse() + assertThat(created.readingDirectionLock).isFalse() + assertThat(created.publisherLock).isFalse() + assertThat(created.ageRatingLock).isFalse() + assertThat(created.releaseDateLock).isFalse() + assertThat(created.authorsLock).isFalse() + } + + @Test + fun `given existing metadata when updating then it is persisted`() { + val metadata = BookMetadata( + title = "Book", + summary = "Summary", + number = "1", + numberSort = 1F, + readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT, + publisher = "publisher", + ageRating = 18, + releaseDate = LocalDate.now(), + authors = mutableListOf(Author("author", "role")), + bookId = book.id + ) + val created = bookMetadataDao.insert(metadata) + + Thread.sleep(5) + + val modificationDate = LocalDateTime.now() + + val updated = with(created) { + copy( + title = "BookUpdated", + summary = "SummaryUpdated", + number = "2", + numberSort = 2F, + readingDirection = BookMetadata.ReadingDirection.RIGHT_TO_LEFT, + publisher = "publisher2", + ageRating = 15, + releaseDate = LocalDate.now(), + authors = mutableListOf(Author("author2", "role2")), + titleLock = true, + summaryLock = true, + numberLock = true, + numberSortLock = true, + readingDirectionLock = true, + publisherLock = true, + ageRatingLock = true, + releaseDateLock = true, + authorsLock = 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) + .isAfterOrEqualTo(modificationDate) + .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.readingDirection).isEqualTo(updated.readingDirection) + assertThat(modified.publisher).isEqualTo(updated.publisher) + assertThat(modified.ageRating).isEqualTo(updated.ageRating) + + 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.readingDirectionLock).isEqualTo(updated.readingDirectionLock) + assertThat(modified.publisherLock).isEqualTo(updated.publisherLock) + assertThat(modified.ageRatingLock).isEqualTo(updated.ageRatingLock) + assertThat(modified.releaseDateLock).isEqualTo(updated.releaseDateLock) + assertThat(modified.authorsLock).isEqualTo(updated.authorsLock) + + assertThat(modified.authors.first().name).isEqualTo(updated.authors.first().name) + assertThat(modified.authors.first().role).isEqualTo(updated.authors.first().role) + } + + @Test + fun `given existing metadata when finding by id then metadata is returned`() { + val metadata = BookMetadata( + title = "Book", + summary = "Summary", + number = "1", + numberSort = 1F, + readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT, + publisher = "publisher", + ageRating = 18, + releaseDate = LocalDate.now(), + authors = mutableListOf(Author("author", "role")), + bookId = book.id + ) + val created = bookMetadataDao.insert(metadata) + + val found = catchThrowable { bookMetadataDao.findById(created.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/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDaoTest.kt new file mode 100644 index 000000000..a712cd618 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDaoTest.kt @@ -0,0 +1,194 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.LibraryRepository +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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class KomgaUserDaoTest( + @Autowired private val komgaUserDao: KomgaUserDao, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + + @BeforeAll + fun setup() { + library = 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", + roleAdmin = false, + sharedLibrariesIds = setOf(library.id), + sharedAllLibraries = false + ) + + Thread.sleep(5) + + val created = komgaUserDao.save(user) + + with(created) { + assertThat(id).isNotEqualTo(0) + assertThat(createdDate).isAfter(now) + assertThat(lastModifiedDate).isAfter(now) + assertThat(email).isEqualTo("user@example.org") + assertThat(password).isEqualTo("password") + assertThat(roleAdmin).isFalse() + assertThat(sharedLibrariesIds).containsExactly(library.id) + assertThat(sharedAllLibraries).isFalse() + } + } + + @Test + fun `given existing user when modifying and saving it then it is persisted`() { + val user = KomgaUser( + email = "user@example.org", + password = "password", + roleAdmin = false, + sharedLibrariesIds = setOf(library.id), + sharedAllLibraries = false + ) + + Thread.sleep(5) + + val created = komgaUserDao.save(user) + + Thread.sleep(5) + + val modified = created.copy( + email = "user2@example.org", + password = "password2", + roleAdmin = true, + sharedLibrariesIds = emptySet(), + sharedAllLibraries = true + ) + val modifiedDate = LocalDateTime.now() + val modifiedSaved = komgaUserDao.save(modified) + + with(modifiedSaved) { + assertThat(id).isEqualTo(created.id) + assertThat(createdDate).isEqualTo(created.createdDate) + assertThat(lastModifiedDate) + .isAfterOrEqualTo(modifiedDate) + .isNotEqualTo(modified.createdDate) + assertThat(email).isEqualTo("user2@example.org") + assertThat(password).isEqualTo("password2") + assertThat(roleAdmin).isTrue() + assertThat(sharedLibrariesIds).isEmpty() + assertThat(sharedAllLibraries).isTrue() + } + } + + @Test + fun `given multiple users when saving then they are persisted`() { + komgaUserDao.saveAll(listOf( + KomgaUser("user1@example.org", "p", false), + KomgaUser("user2@example.org", "p", true) + )) + + 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.saveAll(listOf( + KomgaUser("user1@example.org", "p", false), + KomgaUser("user2@example.org", "p", true) + )) + + val count = komgaUserDao.count() + + assertThat(count).isEqualTo(2) + } + + @Test + fun `given existing user when finding by id then user is returned`() { + val existing = komgaUserDao.save( + KomgaUser("user1@example.org", "p", false) + ) + + 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 = komgaUserDao.save( + KomgaUser("user1@example.org", "p", false) + ) + + komgaUserDao.delete(existing) + + assertThat(komgaUserDao.count()).isEqualTo(0) + } + + @Test + fun `given users when checking if exists by email then return true or false`() { + komgaUserDao.save( + KomgaUser("user1@example.org", "p", false) + ) + + 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.save( + KomgaUser("user1@example.org", "p", false) + ) + + val found = komgaUserDao.findByEmailIgnoreCase("USER1@EXAMPLE.ORG") + val notFound = komgaUserDao.findByEmailIgnoreCase("USER2@EXAMPLE.ORG") + + assertThat(found).isNotNull + assertThat(found?.email).isEqualTo("user1@example.org") + assertThat(notFound).isNull() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt new file mode 100644 index 000000000..35029b654 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt @@ -0,0 +1,159 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Library +import org.junit.jupiter.api.AfterEach + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.net.URL +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +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") + ) + + Thread.sleep(5) + + val created = libraryDao.insert(library) + + assertThat(created.id).isNotEqualTo(0) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + assertThat(created.name).isEqualTo(library.name) + assertThat(created.root).isEqualTo(library.root) + } + + @Test + fun `given a library when deleting then it is deleted`() { + val library = Library( + name = "Library", + root = URL("file://library") + ) + + val created = libraryDao.insert(library) + assertThat(libraryDao.count()).isEqualTo(1) + + libraryDao.delete(created.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") + ) + + val created1 = libraryDao.insert(library) + val created2 = libraryDao.insert(library2) + + val all = libraryDao.findAllById(listOf(created1.id, created2.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") + ) + + val created = libraryDao.insert(library) + + val found = libraryDao.findByIdOrNull(created.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() + } + + @Test + fun `given libraries when checking if exists by name then returns true or false`() { + val library = Library( + name = "Library", + root = URL("file://library") + ) + libraryDao.insert(library) + + val exists = libraryDao.existsByName("LIBRARY") + val notExists = libraryDao.existsByName("LIBRARY2") + + assertThat(exists).isTrue() + assertThat(notExists).isFalse() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/MediaDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/MediaDaoTest.kt new file mode 100644 index 000000000..5d258ca2f --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/MediaDaoTest.kt @@ -0,0 +1,190 @@ +package org.gotson.komga.infrastructure.jooq + +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.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.LibraryRepository +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.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDateTime +import kotlin.random.Random + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class MediaDaoTest( + @Autowired private val mediaDao: MediaDao, + @Autowired private val bookRepository: BookRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + private var library = makeLibrary() + private var series = makeSeries("Series") + private var book = makeBook("Book") + + @BeforeAll + fun setup() { + library = libraryRepository.insert(library) + + series = seriesRepository.insert(series.copy(libraryId = library.id)) + + book = bookRepository.insert(book.copy(libraryId = library.id, seriesId = series.id)) + } + + @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", + thumbnail = Random.nextBytes(1), + pages = listOf(BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg" + )), + files = listOf("ComicInfo.xml"), + comment = "comment", + bookId = book.id + ) + + Thread.sleep(5) + + val created = mediaDao.insert(media) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + assertThat(created.status).isEqualTo(media.status) + assertThat(created.mediaType).isEqualTo(media.mediaType) + assertThat(created.thumbnail).isEqualTo(media.thumbnail) + 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(created.files).hasSize(1) + assertThat(created.files.first()).isEqualTo(media.files.first()) + } + + @Test + fun `given a minimum media when inserting then it is persisted`() { + val media = Media(bookId = book.id) + + val created = mediaDao.insert(media) + + assertThat(created.bookId).isEqualTo(book.id) + assertThat(created.status).isEqualTo(Media.Status.UNKNOWN) + assertThat(created.mediaType).isNull() + assertThat(created.thumbnail).isNull() + assertThat(created.comment).isNull() + assertThat(created.pages).isEmpty() + assertThat(created.files).isEmpty() + } + + @Test + fun `given existing media when updating then it is persisted`() { + val media = Media( + status = Media.Status.READY, + mediaType = "application/zip", + thumbnail = Random.nextBytes(1), + pages = listOf(BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg" + )), + files = listOf("ComicInfo.xml"), + comment = "comment", + bookId = book.id + ) + val created = mediaDao.insert(media) + + Thread.sleep(5) + + val modificationDate = LocalDateTime.now() + + val updated = with(created) { + copy( + status = Media.Status.ERROR, + mediaType = "application/rar", + thumbnail = Random.nextBytes(1), + pages = listOf(BookPage( + fileName = "2.png", + mediaType = "image/png" + )), + files = listOf("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) + .isAfterOrEqualTo(modificationDate) + .isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.status).isEqualTo(updated.status) + assertThat(modified.mediaType).isEqualTo(updated.mediaType) + assertThat(modified.thumbnail).isEqualTo(updated.thumbnail) + 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.files.first()).isEqualTo(updated.files.first()) + } + + @Test + fun `given existing media when finding by id then media is returned`() { + val media = Media( + status = Media.Status.READY, + mediaType = "application/zip", + thumbnail = Random.nextBytes(1), + pages = listOf(BookPage( + fileName = "1.jpg", + mediaType = "image/jpeg" + )), + files = listOf("ComicInfo.xml"), + comment = "comment", + bookId = book.id + ) + val created = mediaDao.insert(media) + + val found = catchThrowable { mediaDao.findById(created.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) + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt new file mode 100644 index 000000000..a6a2ec837 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDaoTest.kt @@ -0,0 +1,254 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesSearch +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.LibraryRepository +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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.net.URL +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class SeriesDaoTest( + @Autowired private val seriesDao: SeriesDao, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + + @BeforeAll + fun setup() { + library = 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 + ) + + Thread.sleep(5) + + val created = seriesDao.insert(series) + + assertThat(created.id).isNotEqualTo(0) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + assertThat(created.name).isEqualTo(series.name) + assertThat(created.url).isEqualTo(series.url) + assertThat(created.fileLastModified).isEqualTo(series.fileLastModified) + } + + @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 + ) + + val created = seriesDao.insert(series) + assertThat(seriesDao.count()).isEqualTo(1) + + seriesDao.delete(created.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 + ) + + val created = seriesDao.insert(series) + + val found = seriesDao.findByIdOrNull(created.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 searching then results is returned`() { + val series = Series( + name = "Series", + url = URL("file://series"), + fileLastModified = LocalDateTime.now(), + libraryId = library.id + ) + + seriesDao.insert(series) + + val search = SeriesSearch( + libraryIds = listOf(library.id) + ) + val found = seriesDao.findAll(search) + + assertThat(found).hasSize(1) + } + + @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.findByLibraryId(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.findByLibraryId(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.findByLibraryIdAndUrlNotIn(library.id, listOf(URL("file://series2"))) + val notFound = seriesDao.findByLibraryIdAndUrlNotIn(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.findByLibraryIdAndUrl(library.id, URL("file://series")) + val notFound1 = seriesDao.findByLibraryIdAndUrl(library.id, URL("file://series2")) + val notFound2 = seriesDao.findByLibraryIdAndUrl(library.id + 1, URL("file://series")) + val notFound3 = seriesDao.findByLibraryIdAndUrl(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/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDaoTest.kt new file mode 100644 index 000000000..28ea0cc7a --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesMetadataDaoTest.kt @@ -0,0 +1,157 @@ +package org.gotson.komga.infrastructure.jooq + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.gotson.komga.domain.model.SeriesMetadata +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.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.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureTestDatabase +class SeriesMetadataDaoTest( + @Autowired private val seriesMetadataDao: SeriesMetadataDao, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val libraryRepository: LibraryRepository +) { + + private var library = makeLibrary() + + @BeforeAll + fun setup() { + library = libraryRepository.insert(library) + } + + @AfterEach + fun deleteSeries() { + seriesRepository.deleteAll() + } + + @AfterAll + fun tearDown() { + libraryRepository.deleteAll() + } + + + @Test + fun `given a seriesMetadata when inserting then it is persisted`() { + val series = seriesRepository.insert( + makeSeries("Series", libraryId = library.id) + ) + + val now = LocalDateTime.now() + val metadata = SeriesMetadata( + status = SeriesMetadata.Status.ENDED, + title = "Series", + titleSort = "Series, The", + seriesId = series.id + ) + + Thread.sleep(5) + + val created = seriesMetadataDao.insert(metadata) + + assertThat(created.seriesId).isEqualTo(series.id) + assertThat(created.createdDate).isAfter(now) + assertThat(created.lastModifiedDate).isAfter(now) + assertThat(created.title).isEqualTo("Series") + assertThat(created.titleSort).isEqualTo("Series, The") + assertThat(created.status).isEqualTo(SeriesMetadata.Status.ENDED) + assertThat(created.titleLock).isFalse() + assertThat(created.titleSortLock).isFalse() + assertThat(created.statusLock).isFalse() + } + + @Test + fun `given existing seriesMetadata when finding by id then metadata is returned`() { + val series = seriesRepository.insert( + makeSeries("Series", libraryId = library.id) + ) + 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 = seriesRepository.insert( + makeSeries("Series", libraryId = library.id) + ) + + val metadata = SeriesMetadata( + status = SeriesMetadata.Status.ENDED, + title = "Series", + titleSort = "Series, The", + seriesId = series.id + ) + val created = seriesMetadataDao.insert(metadata) + + Thread.sleep(5) + + val modificationDate = LocalDateTime.now() + + val updated = with(created) { + copy( + status = SeriesMetadata.Status.HIATUS, + title = "Changed", + titleSort = "Changed, The", + statusLock = true, + titleLock = true, + titleSortLock = true + ) + } + + seriesMetadataDao.update(updated) + val modified = seriesMetadataDao.findById(updated.seriesId) + + Thread.sleep(5) + + assertThat(modified.seriesId).isEqualTo(series.id) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate) + .isAfterOrEqualTo(modificationDate) + .isNotEqualTo(modified.createdDate) + assertThat(modified.title).isEqualTo("Changed") + assertThat(modified.titleSort).isEqualTo("Changed, The") + assertThat(modified.status).isEqualTo(SeriesMetadata.Status.HIATUS) + assertThat(modified.titleLock).isTrue() + assertThat(modified.titleSortLock).isTrue() + assertThat(modified.statusLock).isTrue() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt index 0634971f4..aee16c2b5 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt @@ -22,13 +22,12 @@ class ComicInfoProviderTest { private val comicInfoProvider = ComicInfoProvider(mockMapper, mockAnalyzer) - private val book = makeBook("book").also { - it.media = Media( - status = Media.Status.READY, - mediaType = "application/zip", - files = listOf("ComicInfo.xml") - ) - } + private val book = makeBook("book") + private val media = Media( + status = Media.Status.READY, + mediaType = "application/zip", + files = listOf("ComicInfo.xml") + ) @Nested inner class Book { @@ -47,7 +46,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) with(patch!!) { assertThat(title).isEqualTo("title") @@ -69,7 +68,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) with(patch!!) { assertThat(releaseDate).isNull() @@ -84,7 +83,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) with(patch!!) { assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) @@ -105,7 +104,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) with(patch!!) { assertThat(authors).hasSize(7) @@ -128,7 +127,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) with(patch!!) { assertThat(authors).hasSize(14) @@ -139,10 +138,10 @@ class ComicInfoProviderTest { @Test fun `given book without comicInfo file when getting book metadata then return null`() { - val book = makeBook("book").also { - it.media = Media(Media.Status.READY) - } - val patch = comicInfoProvider.getBookMetadataFromBook(book) + val book = makeBook("book") + val media = Media(Media.Status.READY) + + val patch = comicInfoProvider.getBookMetadataFromBook(book, media) assertThat(patch).isNull() } @@ -158,7 +157,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book)!!.series + val patch = comicInfoProvider.getBookMetadataFromBook(book, media)!!.series with(patch!!) { assertThat(title).isEqualTo("series") diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt index 097f72aaa..64a54ca37 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt @@ -1,18 +1,22 @@ package org.gotson.komga.interfaces.rest import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.tuple +import org.assertj.core.groups.Tuple.tuple import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.Media -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.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.SeriesRepository +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 @@ -26,7 +30,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate @@ -35,7 +38,6 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvcResultMatchersDsl import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.patch -import org.springframework.transaction.annotation.Transactional import java.time.LocalDate import javax.sql.DataSource import kotlin.random.Random @@ -46,7 +48,11 @@ import kotlin.random.Random @AutoConfigureMockMvc(printOnlyOnFailure = false) class BookControllerTest( @Autowired private val seriesRepository: SeriesRepository, + @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 mockMvc: MockMvc ) { @@ -58,23 +64,27 @@ class BookControllerTest( this.jdbcTemplate = JdbcTemplate(dataSource) } - private val library = makeLibrary() + private var library = makeLibrary() @BeforeAll fun `setup library`() { jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") - libraryRepository.save(library) + library = libraryRepository.insert(library) } @AfterAll fun `teardown library`() { - libraryRepository.deleteAll() + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } } @AfterEach fun `clear repository`() { - seriesRepository.deleteAll() + seriesRepository.findAll().forEach { + seriesLifecycle.deleteSeries(it.id) + } } @Nested @@ -82,20 +92,20 @@ class BookControllerTest( @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`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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.save(otherLibrary) - - val otherSeries = makeSeries( - name = "otherSeries", - books = listOf(makeBook("2")) - ).also { it.library = otherLibrary } - seriesRepository.save(otherSeries) + val otherLibrary = libraryRepository.insert(makeLibrary("other")) + 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 { @@ -112,12 +122,14 @@ class BookControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific book then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 { isUnauthorized } } @@ -126,12 +138,14 @@ class BookControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific book thumbnail then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 { isUnauthorized } } @@ -140,12 +154,14 @@ class BookControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific book file then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 { isUnauthorized } } @@ -154,12 +170,14 @@ class BookControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific book pages then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 { isUnauthorized } } @@ -168,12 +186,14 @@ class BookControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific book page then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 { isUnauthorized } } @@ -185,12 +205,14 @@ class BookControllerTest( @Test @WithMockCustomUser fun `given book without thumbnail when getting book thumbnail then returns not found`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 } } @@ -199,12 +221,14 @@ class BookControllerTest( @Test @WithMockCustomUser fun `given book without file when getting book file then returns not found`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 } } @@ -214,12 +238,17 @@ class BookControllerTest( @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) { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1").also { it.media.status = status }) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 } } @@ -229,12 +258,17 @@ class BookControllerTest( @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) { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1").also { it.media.status = status }) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 } } @@ -245,30 +279,90 @@ class BookControllerTest( @ValueSource(strings = ["25", "-5", "0"]) @WithMockCustomUser fun `given book with pages when getting non-existent page then returns bad request`(page: String) { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1").also { - it.media.pages = listOf(BookPage("file", "image/jpeg")) - it.media.status = Media.Status.READY - }) - ).also { it.library = library } - seriesRepository.save(series) - val book = series.books.first() + 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 series = 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), + makeBook("3", libraryId = library.id) + ) + seriesLifecycle.addBooks(created, books) + seriesLifecycle.sortBooks(created) + } + } + + val first = bookRepository.findFirstIdInSeries(series.id) + val second = bookRepository.findAll(BookSearch(searchTerm = "2")).first().id + val third = bookRepository.findAll(BookSearch(searchTerm = "3")).first().id + + mockMvc.get("/api/v1/books/${first}/previous") + .andExpect { status { isNotFound } } + mockMvc.get("/api/v1/books/${first}/next") + .andExpect { + status { isOk } + jsonPath("$.name") { value("2") } + } + + mockMvc.get("/api/v1/books/${second}/previous") + .andExpect { + status { isOk } + jsonPath("$.name") { value("1") } + } + mockMvc.get("/api/v1/books/${second}/next") + .andExpect { + status { isOk } + jsonPath("$.name") { value("3") } + } + + mockMvc.get("/api/v1/books/${third}/previous") + .andExpect { + status { isOk } + jsonPath("$.name") { value("2") } + } + mockMvc.get("/api/v1/books/${third}/next") + .andExpect { status { isNotFound } } + + } + } + @Nested inner class DtoUrlSanitization { @Test @WithMockCustomUser fun `given regular user when getting books then full url is hidden`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + 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 } @@ -281,10 +375,10 @@ class BookControllerTest( mockMvc.get("/api/v1/books/latest") .andExpect(validation) - mockMvc.get("/api/v1/series/${series.id}/books") + mockMvc.get("/api/v1/series/${createdSeries.id}/books") .andExpect(validation) - mockMvc.get("/api/v1/books/${series.books.first().id}") + mockMvc.get("/api/v1/books/${book.id}") .andExpect { status { isOk } jsonPath("$.url") { value("1.cbr") } @@ -292,13 +386,16 @@ class BookControllerTest( } @Test - @WithMockCustomUser(roles = [UserRoles.ADMIN]) + @WithMockCustomUser(roles = ["ADMIN"]) fun `given admin user when getting books then full url is available`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + 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 url = "/1.cbr" val validation: MockMvcResultMatchersDsl.() -> Unit = { @@ -312,10 +409,10 @@ class BookControllerTest( mockMvc.get("/api/v1/books/latest") .andExpect(validation) - mockMvc.get("/api/v1/series/${series.id}/books") + mockMvc.get("/api/v1/series/${createdSeries.id}/books") .andExpect(validation) - mockMvc.get("/api/v1/books/${series.books.first().id}") + mockMvc.get("/api/v1/books/${book.id}") .andExpect { status { isOk } jsonPath("$.url") { value(url) } @@ -328,15 +425,20 @@ class BookControllerTest( @Test @WithMockCustomUser fun `given request with cache headers when getting thumbnail then returns 304 not modified`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr").also { - it.media.thumbnail = Random.nextBytes(100) - }) - ).also { it.library = library } - seriesRepository.save(series) + 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 url = "/api/v1/books/${series.books.first().id}/thumbnail" + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(thumbnail = Random.nextBytes(100))) + } + + + val url = "/api/v1/books/${book.id}/thumbnail" val response = mockMvc.get(url) .andReturn().response @@ -353,13 +455,16 @@ class BookControllerTest( @Test @WithMockCustomUser fun `given request with If-Modified-Since headers when getting page then returns 304 not modified`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + 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 url = "/api/v1/books/${series.books.first().id}/pages/1" + 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) @@ -372,37 +477,38 @@ class BookControllerTest( status { isNotModified } } } - } - //Not part of the above @Nested class because @Transactional fails - @Test - @WithMockCustomUser - @Transactional - fun `given request with cache headers and modified resource when getting thumbnail then returns 200 ok`() { - val book = makeBook("1.cbr").also { - it.media.thumbnail = Random.nextBytes(1) - } - val series = makeSeries( - name = "series", - books = listOf(book) - ).also { it.library = library } - seriesRepository.save(series) - - val url = "/api/v1/books/${series.books.first().id}/thumbnail" - - val response = mockMvc.get(url) - .andReturn().response - - Thread.sleep(100) - book.media.thumbnail = Random.nextBytes(1) - bookRepository.saveAndFlush(book) - - mockMvc.get(url) { - headers { - ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + @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() + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(thumbnail = Random.nextBytes(1))) + } + + val url = "/api/v1/books/${book.id}/thumbnail" + + val response = mockMvc.get(url).andReturn().response + + Thread.sleep(100) + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(thumbnail = Random.nextBytes(1))) + } + + mockMvc.get(url) { + headers { + ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + } + }.andExpect { + status { isOk } } - }.andExpect { - status { isOk } } } @@ -426,7 +532,7 @@ class BookControllerTest( """{"authors":"[{"name":""}]"}""", """{"ageRating":-1}""" ]) - @WithMockCustomUser(roles = [UserRoles.ADMIN]) + @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 @@ -435,21 +541,20 @@ class BookControllerTest( status { isBadRequest } } } - } - //Not part of the above @Nested class because @Transactional fails - @Test - @Transactional - @WithMockCustomUser(roles = [UserRoles.ADMIN]) - fun `given valid json when updating metadata then fields are updated`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) - val bookId = series.books.first().id + @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 jsonString = """ + val bookId = bookRepository.findAll().first().id + + val jsonString = """ { "title":"newTitle", "titleLock":true, @@ -481,70 +586,76 @@ class BookControllerTest( } """.trimIndent() - mockMvc.patch("/api/v1/books/${bookId}/metadata") { - contentType = MediaType.APPLICATION_JSON - content = jsonString - }.andExpect { - status { isOk } + mockMvc.patch("/api/v1/books/${bookId}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk } + } + + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(title).isEqualTo("newTitle") + assertThat(summary).isEqualTo("newSummary") + assertThat(number).isEqualTo("newNumber") + assertThat(numberSort).isEqualTo(1F) + assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) + assertThat(publisher).isEqualTo("newPublisher") + assertThat(ageRating).isEqualTo(12) + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) + assertThat(authors) + .hasSize(2) + .extracting("name", "role") + .containsExactlyInAnyOrder( + tuple("newAuthor", "newauthorrole"), + tuple("newAuthor2", "newauthorrole2") + ) + + assertThat(titleLock).isEqualTo(true) + assertThat(summaryLock).isEqualTo(true) + assertThat(numberLock).isEqualTo(true) + assertThat(numberSortLock).isEqualTo(true) + assertThat(readingDirectionLock).isEqualTo(true) + assertThat(publisherLock).isEqualTo(true) + assertThat(ageRatingLock).isEqualTo(true) + assertThat(releaseDateLock).isEqualTo(true) + assertThat(authorsLock).isEqualTo(true) + } } - val updatedBook = bookRepository.findByIdOrNull(bookId) - with(updatedBook!!.metadata) { - assertThat(title).isEqualTo("newTitle") - assertThat(summary).isEqualTo("newSummary") - assertThat(number).isEqualTo("newNumber") - assertThat(numberSort).isEqualTo(1F) - assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) - assertThat(publisher).isEqualTo("newPublisher") - assertThat(ageRating).isEqualTo(12) - assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) - assertThat(authors) - .hasSize(2) - .extracting("name", "role") - .containsExactlyInAnyOrder( - tuple("newAuthor", "newauthorrole"), - tuple("newAuthor2", "newauthorrole2") + @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 { + val updated = it.copy( + ageRating = 12, + readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT, + authors = it.authors.toMutableList().also { it.add(Author("Author", "role")) }, + releaseDate = testDate ) - assertThat(titleLock).isEqualTo(true) - assertThat(summaryLock).isEqualTo(true) - assertThat(numberLock).isEqualTo(true) - assertThat(numberSortLock).isEqualTo(true) - assertThat(readingDirectionLock).isEqualTo(true) - assertThat(publisherLock).isEqualTo(true) - assertThat(ageRatingLock).isEqualTo(true) - assertThat(releaseDateLock).isEqualTo(true) - assertThat(authorsLock).isEqualTo(true) - } - } + bookMetadataRepository.update(updated) + } - //Not part of the above @Nested class because @Transactional fails - @Test - @Transactional - @WithMockCustomUser(roles = [UserRoles.ADMIN]) - fun `given json with null fields when updating metadata then fields with null are unset`() { - val testDate = LocalDate.of(2020, 1, 1) - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr").also { - it.metadata.ageRating = 12 - it.metadata.readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT - it.metadata.authors.add(Author("Author", "role")) - it.metadata.releaseDate = testDate - }) - ).also { it.library = library } - seriesRepository.save(series) - val bookId = series.books.first().id + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) + assertThat(ageRating).isEqualTo(12) + assertThat(authors).hasSize(1) + assertThat(releaseDate).isEqualTo(testDate) + } - val initialBook = bookRepository.findByIdOrNull(bookId) - with(initialBook!!.metadata) { - assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) - assertThat(ageRating).isEqualTo(12) - assertThat(authors).hasSize(1) - assertThat(releaseDate).isEqualTo(testDate) - } - - val jsonString = """ + val jsonString = """ { "readingDirection":null, "ageRating":null, @@ -553,73 +664,77 @@ class BookControllerTest( } """.trimIndent() - mockMvc.patch("/api/v1/books/${bookId}/metadata") { - contentType = MediaType.APPLICATION_JSON - content = jsonString - }.andExpect { - status { isOk } + mockMvc.patch("/api/v1/books/${bookId}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk } + } + + val updatedMetadata = bookMetadataRepository.findById(bookId) + with(updatedMetadata) { + assertThat(readingDirection).isNull() + assertThat(ageRating).isNull() + assertThat(authors).isEmpty() + assertThat(releaseDate).isNull() + } } - val updatedBook = bookRepository.findByIdOrNull(bookId) - with(updatedBook!!.metadata) { - assertThat(readingDirection).isNull() - assertThat(ageRating).isNull() - assertThat(authors).isEmpty() - assertThat(releaseDate).isNull() - } - } + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `given json without fields when updating metadata then existing fields are untouched`() { + val testDate = LocalDate.of(2020, 1, 1) - //Not part of the above @Nested class because @Transactional fails - @Test - @Transactional - @WithMockCustomUser(roles = [UserRoles.ADMIN]) - fun `given json without fields when updating metadata then existing fields are untouched`() { - val testDate = LocalDate.of(2020, 1, 1) - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr").also { - with(it.metadata) - { - ageRating = 12 - readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT - authors.add(Author("Author", "role")) - releaseDate = testDate - summary = "summary" - number = "number" - numberLock = true - numberSort = 2F - numberSortLock = true - publisher = "publisher" - title = "title" + 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) } - }) - ).also { it.library = library } - seriesRepository.save(series) - val bookId = series.books.first().id + } - val jsonString = """ + val bookId = bookRepository.findAll().first().id + bookMetadataRepository.findById(bookId).let { + val updated = it.copy( + ageRating = 12, + readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT, + authors = it.authors.toMutableList().also { it.add(Author("Author", "role")) }, + releaseDate = testDate, + summary = "summary", + number = "number", + numberLock = true, + numberSort = 2F, + numberSortLock = true, + publisher = "publisher", + title = "title" + ) + + bookMetadataRepository.update(updated) + } + + val jsonString = """ { } """.trimIndent() - mockMvc.patch("/api/v1/books/${bookId}/metadata") { - contentType = MediaType.APPLICATION_JSON - content = jsonString - }.andExpect { - status { isOk } - } + mockMvc.patch("/api/v1/books/${bookId}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk } + } - val updatedBook = bookRepository.findByIdOrNull(bookId) - with(updatedBook!!.metadata) { - assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) - assertThat(ageRating).isEqualTo(12) - assertThat(authors).hasSize(1) - assertThat(releaseDate).isEqualTo(testDate) - assertThat(summary).isEqualTo("summary") - assertThat(number).isEqualTo("number") - assertThat(numberSort).isEqualTo(2F) - assertThat(publisher).isEqualTo("publisher") - assertThat(title).isEqualTo("title") + val metadata = bookMetadataRepository.findById(bookId) + with(metadata) { + assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT) + assertThat(ageRating).isEqualTo(12) + assertThat(authors).hasSize(1) + assertThat(releaseDate).isEqualTo(testDate) + assertThat(summary).isEqualTo("summary") + assertThat(number).isEqualTo("number") + assertThat(numberSort).isEqualTo(2F) + assertThat(publisher).isEqualTo("publisher") + assertThat(title).isEqualTo("title") + } } } } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt index d60edbca8..0c0dfd33b 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt @@ -1,6 +1,5 @@ package org.gotson.komga.interfaces.rest -import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.persistence.LibraryRepository import org.junit.jupiter.api.AfterAll @@ -28,11 +27,11 @@ class LibraryControllerTest( ) { private val route = "/api/v1/libraries" - private val library = makeLibrary(url = "file:/library1") + private var library = makeLibrary(url = "file:/library1") @BeforeAll fun `setup library`() { - libraryRepository.save(library) + library = libraryRepository.insert(library) } @AfterAll @@ -115,7 +114,7 @@ class LibraryControllerTest( } @Test - @WithMockCustomUser(roles = [UserRoles.ADMIN]) + @WithMockCustomUser(roles = ["ADMIN"]) fun `given admin user when getting books then root is available`() { mockMvc.get(route) .andExpect { diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt index 63c0b9f35..bb86d2117 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/MockSpringSecurity.kt @@ -1,10 +1,6 @@ package org.gotson.komga.interfaces.rest -import io.mockk.every -import io.mockk.mockk import org.gotson.komga.domain.model.KomgaUser -import org.gotson.komga.domain.model.Library -import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContext @@ -12,13 +8,12 @@ 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 -import java.net.URL @Retention(AnnotationRetention.RUNTIME) @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class, setupBefore = TestExecutionEvent.TEST_EXECUTION) annotation class WithMockCustomUser( val email: String = "user@example.org", - val roles: Array = [], + val roles: Array = [], val sharedAllLibraries: Boolean = true, val sharedLibraries: LongArray = [] ) @@ -31,18 +26,10 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory - user.sharedAllLibraries = customUser.sharedAllLibraries - user.sharedLibraries = customUser.sharedLibraries - .map { - val mock = mockk() - every { mock.id } returns it - every { mock.name } returns "Library #$it" - every { mock.root } returns URL("file:/library/$it") - mock - }.toMutableSet() - } + roleAdmin = customUser.roles.contains("ADMIN"), + sharedAllLibraries = customUser.sharedAllLibraries, + sharedLibrariesIds = customUser.sharedLibraries.toSet() + ) ) val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities) context.authentication = auth diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt index 2555588ed..b121f4ace 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt @@ -1,15 +1,18 @@ package org.gotson.komga.interfaces.rest import org.assertj.core.api.Assertions.assertThat -import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.SeriesMetadata -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.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.service.LibraryLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle import org.hamcrest.Matchers import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach @@ -23,7 +26,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate @@ -32,7 +34,6 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvcResultMatchersDsl import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.patch -import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource import kotlin.random.Random @@ -42,8 +43,13 @@ import kotlin.random.Random @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 bookRepository: BookRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, @Autowired private val mockMvc: MockMvc ) { @@ -54,23 +60,27 @@ class SeriesControllerTest( this.jdbcTemplate = JdbcTemplate(dataSource) } - private val library = makeLibrary() + private var library = makeLibrary() @BeforeAll fun `setup library`() { jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") - libraryRepository.save(library) + library = libraryRepository.insert(library) } @AfterAll fun `teardown library`() { - libraryRepository.deleteAll() + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } } @AfterEach fun `clear repository`() { - seriesRepository.deleteAll() + seriesRepository.findAll().forEach { + seriesLifecycle.deleteSeries(it.id) + } } @Nested @@ -78,18 +88,16 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given series with titleSort when requesting via api then series are sorted by titleSort`() { - val alpha = makeSeries("The Alpha").also { - it.metadata.titleSort = "Alpha, The" - it.library = library + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) } - seriesRepository.save(alpha) - val beta = makeSeries("Beta").also { it.library = library } - seriesRepository.save(beta) + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) mockMvc.get("/api/v1/series") .andExpect { status { isOk } - jsonPath("$.content[0].metadata.title") { value("The Alpha") } + jsonPath("$.content[0].metadata.title") { value("TheAlpha") } jsonPath("$.content[1].metadata.title") { value("Beta") } } } @@ -97,8 +105,11 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given series when requesting via api then series are sorted insensitive of case`() { - val series = listOf("a", "b", "B", "C").map { makeSeries(it).also { it.library = library } } - seriesRepository.saveAll(series) + 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") @@ -118,16 +129,18 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given books with unordered index when requesting via api then books are ordered`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1"), makeBook("3")) - ).also { it.library = library } - seriesRepository.save(series) + 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) + } + } - series.books = series.books.toMutableList().also { it.add(makeBook("2")) } - seriesRepository.save(series) + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) - mockMvc.get("/api/v1/series/${series.id}/books") + mockMvc.get("/api/v1/series/${createdSeries.id}/books") .andExpect { status { isOk } jsonPath("$.content[0].name") { value("1") } @@ -139,42 +152,18 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given many books with unordered index when requesting via api then books are ordered and paged`() { - val series = makeSeries( - name = "series", - books = (1..100 step 2).map { makeBook("$it") } - ).also { it.library = library } - seriesRepository.save(series) - - series.books = series.books.toMutableList().also { it.add(makeBook("2")) } - seriesRepository.save(series) - - mockMvc.get("/api/v1/series/${series.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) } + 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) } - } + } - @Test - @WithMockCustomUser - fun `given many books in ready state with unordered index when requesting via api then books are ordered and paged`() { - val series = makeSeries( - name = "series", - books = (1..100 step 2).map { makeBook("$it") } - ).also { it.library = library } - seriesRepository.save(series) + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) - series.books = series.books.toMutableList().also { it.add(makeBook("2")) } - series.books.forEach { it.media = Media(Media.Status.READY) } - seriesRepository.save(series) - - mockMvc.get("/api/v1/series/${series.id}/books?mediaStatus=READY") + mockMvc.get("/api/v1/series/${createdSeries.id}/books") .andExpect { status { isOk } jsonPath("$.content[0].name") { value("1") } @@ -193,20 +182,20 @@ class SeriesControllerTest( @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 series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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.save(otherLibrary) - - val otherSeries = makeSeries( - name = "otherSeries", - books = listOf(makeBook("2")) - ).also { it.library = otherLibrary } - seriesRepository.save(otherSeries) + val otherLibrary = libraryRepository.insert(makeLibrary("other")) + 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 { @@ -222,39 +211,42 @@ class SeriesControllerTest( @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific series then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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/${series.id}") + mockMvc.get("/api/v1/series/${createdSeries.id}") .andExpect { status { isUnauthorized } } } @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific series thumbnail then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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/${series.id}/thumbnail") + mockMvc.get("/api/v1/series/${createdSeries.id}/thumbnail") .andExpect { status { isUnauthorized } } } @Test @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = []) fun `given user with no access to any library when getting specific series books then returns unauthorized`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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/${series.id}/books") + mockMvc.get("/api/v1/series/${createdSeries.id}/books") .andExpect { status { isUnauthorized } } } } @@ -264,13 +256,14 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given book without thumbnail when getting series thumbnail then returns not found`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1")) - ).also { it.library = library } - seriesRepository.save(series) + 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/${series.id}/thumbnail") + mockMvc.get("/api/v1/series/${createdSeries.id}/thumbnail") .andExpect { status { isNotFound } } } } @@ -280,11 +273,12 @@ class SeriesControllerTest( @Test @WithMockCustomUser fun `given regular user when getting series then url is hidden`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + 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 } @@ -300,7 +294,7 @@ class SeriesControllerTest( mockMvc.get("/api/v1/series/new") .andExpect(validation) - mockMvc.get("/api/v1/series/${series.id}") + mockMvc.get("/api/v1/series/${createdSeries.id}") .andExpect { status { isOk } jsonPath("$.url") { value("") } @@ -308,13 +302,14 @@ class SeriesControllerTest( } @Test - @WithMockCustomUser(roles = [UserRoles.ADMIN]) + @WithMockCustomUser(roles = ["ADMIN"]) fun `given admin user when getting series then url is available`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + 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 url = "/series" val validation: MockMvcResultMatchersDsl.() -> Unit = { @@ -331,7 +326,7 @@ class SeriesControllerTest( mockMvc.get("/api/v1/series/new") .andExpect(validation) - mockMvc.get("/api/v1/series/${series.id}") + mockMvc.get("/api/v1/series/${createdSeries.id}") .andExpect { status { isOk } jsonPath("$.url") { value(url) } @@ -357,7 +352,7 @@ class SeriesControllerTest( """{"title":""}""", """{"titleSort":""}""" ]) - @WithMockCustomUser(roles = [UserRoles.ADMIN]) + @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 @@ -366,20 +361,18 @@ class SeriesControllerTest( status { isBadRequest } } } - } - //Not part of the above @Nested class because @Transactional fails - @Test - @Transactional - @WithMockCustomUser(roles = [UserRoles.ADMIN]) - fun `given valid json when updating metadata then fields are updated`() { - val series = makeSeries( - name = "series", - books = listOf(makeBook("1.cbr")) - ).also { it.library = library } - seriesRepository.save(series) + @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) + } + } - val jsonString = """ + val jsonString = """ { "title":"newTitle", "titleSort":"newTitleSort", @@ -390,82 +383,90 @@ class SeriesControllerTest( } """.trimIndent() - mockMvc.patch("/api/v1/series/${series.id}/metadata") { - contentType = MediaType.APPLICATION_JSON - content = jsonString - }.andExpect { - status { isOk } - } - - val updatedSeries = seriesRepository.findByIdOrNull(series.id) - with(updatedSeries!!.metadata) { - assertThat(title).isEqualTo("newTitle") - assertThat(titleSort).isEqualTo("newTitleSort") - assertThat(status).isEqualTo(SeriesMetadata.Status.HIATUS) - assertThat(titleLock).isEqualTo(true) - assertThat(titleSortLock).isEqualTo(true) - assertThat(statusLock).isEqualTo(true) - } - } - - @Test - @WithMockCustomUser - fun `given request with cache headers when getting series thumbnail then returns 304 not modified`() { - val book = makeBook("1.cbr").also { - it.media.thumbnail = Random.nextBytes(1) - } - val series = makeSeries( - name = "series", - books = listOf(book) - ).also { it.library = library } - seriesRepository.save(series) - - val url = "/api/v1/series/${series.id}/thumbnail" - - val response = mockMvc.get(url) - .andReturn().response - - mockMvc.get(url) { - headers { - ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + mockMvc.patch("/api/v1/series/${createdSeries.id}/metadata") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isOk } } - }.andExpect { - status { isNotModified } - } - } - //Not part of the above @Nested class because @Transactional fails - @Test - @WithMockCustomUser - @Transactional - fun `given request with cache headers and modified first book when getting series thumbnail then returns 200 ok`() { - val book = makeBook("1.cbr").also { - it.media.thumbnail = Random.nextBytes(1) - } - val book2 = makeBook("2.cbr").also { - it.media.thumbnail = Random.nextBytes(1) - } - val series = makeSeries( - name = "series", - books = listOf(book, book2) - ).also { it.library = library } - seriesRepository.save(series) - - val url = "/api/v1/series/${series.id}/thumbnail" - - val response = mockMvc.get(url) - .andReturn().response - - book.metadata.numberSort = 3F - bookRepository.saveAndFlush(book) - - mockMvc.get(url) { - headers { - ifNoneMatch = listOf(response.getHeader(HttpHeaders.ETAG)!!) + val updatedMetadata = seriesMetadataRepository.findById(createdSeries.id) + with(updatedMetadata) { + assertThat(title).isEqualTo("newTitle") + assertThat(titleSort).isEqualTo("newTitleSort") + assertThat(status).isEqualTo(SeriesMetadata.Status.HIATUS) + assertThat(titleLock).isEqualTo(true) + assertThat(titleSortLock).isEqualTo(true) + assertThat(statusLock).isEqualTo(true) } - }.andExpect { - status { isOk } } } + @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 -> + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(thumbnail = Random.nextBytes(1))) + } + } + + 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 -> + mediaRepository.findById(book.id).let { + mediaRepository.update(it.copy(thumbnail = Random.nextBytes(1))) + } + } + + 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 } + } + } + } } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/UserControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/UserControllerTest.kt index 68d21141b..38b81bd99 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/UserControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/UserControllerTest.kt @@ -1,6 +1,5 @@ package org.gotson.komga.interfaces.rest -import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired @@ -19,7 +18,6 @@ import org.springframework.test.web.servlet.patch @AutoConfigureMockMvc(printOnlyOnFailure = false) @ActiveProfiles("demo") class UserControllerTest( - @Autowired private val userDetailsLifecycle: KomgaUserDetailsLifecycle, @Autowired private val mockMvc: MockMvc ) { diff --git a/komga/src/test/resources/application-test.yml b/komga/src/test/resources/application-test.yml index 702c1bff2..7d17ef9ff 100644 --- a/komga/src/test/resources/application-test.yml +++ b/komga/src/test/resources/application-test.yml @@ -5,15 +5,10 @@ spring: url: jdbc:h2:mem:testdb flyway: enabled: true - jpa: - hibernate: - ddl-auto: none - properties: - hibernate: - format_sql: true artemis: embedded: persistent: false -logging.level.org.hibernate: - SQL: DEBUG - type.descriptor.sql.BasicBinder: TRACE + +logging: + level: + org.jooq: DEBUG