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