mirror of
https://github.com/gotson/komga.git
synced 2026-05-09 05:10:19 +02:00
feat: migrate DAO from Hibernate to jOOQ
not really a feature, but the change is significant enough to warrant a release
This commit is contained in:
parent
de953a4401
commit
75e1079992
127 changed files with 4884 additions and 2594 deletions
|
|
@ -1,26 +0,0 @@
|
||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="komga [bootRun] dev,noflyway" type="GradleRunConfiguration" factoryName="Gradle">
|
|
||||||
<ExternalSystemSettings>
|
|
||||||
<option name="env">
|
|
||||||
<map>
|
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev,noflyway" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
<option name="executionName" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="externalSystemIdString" value="GRADLE" />
|
|
||||||
<option name="scriptParameters" value="" />
|
|
||||||
<option name="taskDescriptions">
|
|
||||||
<list />
|
|
||||||
</option>
|
|
||||||
<option name="taskNames">
|
|
||||||
<list>
|
|
||||||
<option value="bootRun" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="vmOptions" value="" />
|
|
||||||
</ExternalSystemSettings>
|
|
||||||
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="komga [bootRun] generatesql" type="GradleRunConfiguration" factoryName="Gradle">
|
|
||||||
<ExternalSystemSettings>
|
|
||||||
<option name="env">
|
|
||||||
<map>
|
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="generatesql" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
<option name="executionName" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="externalSystemIdString" value="GRADLE" />
|
|
||||||
<option name="scriptParameters" value="" />
|
|
||||||
<option name="taskDescriptions">
|
|
||||||
<list />
|
|
||||||
</option>
|
|
||||||
<option name="taskNames">
|
|
||||||
<list>
|
|
||||||
<option value="bootRun" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="vmOptions" value="" />
|
|
||||||
</ExternalSystemSettings>
|
|
||||||
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
|
|
@ -13,8 +13,7 @@ interface UserWithSharedLibrariesDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SharedLibraryDto {
|
interface SharedLibraryDto {
|
||||||
id: number,
|
id: number
|
||||||
name: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserCreationDto {
|
interface UserCreationDto {
|
||||||
|
|
|
||||||
|
|
@ -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.apache.tools.ant.taskdefs.condition.Os
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
|
|
@ -11,9 +16,10 @@ plugins {
|
||||||
kotlin("kapt") version kotlinVersion
|
kotlin("kapt") version kotlinVersion
|
||||||
}
|
}
|
||||||
id("org.springframework.boot") version "2.2.6.RELEASE"
|
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.github.ben-manes.versions") version "0.28.0"
|
||||||
id("com.gorylenko.gradle-git-properties") version "2.2.2"
|
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
|
jacoco
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,37 +31,40 @@ configurations.runtimeClasspath.get().extendsFrom(developmentOnly)
|
||||||
repositories {
|
repositories {
|
||||||
jcenter()
|
jcenter()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://jitpack.io")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib-jdk8"))
|
implementation(kotlin("stdlib-jdk8"))
|
||||||
implementation(kotlin("reflect"))
|
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-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-actuator")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-cache")
|
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-artemis")
|
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.apache.activemq:artemis-jms-server")
|
||||||
|
|
||||||
implementation("org.flywaydb:flyway-core")
|
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.github.microutils:kotlin-logging:1.7.9")
|
||||||
implementation("io.micrometer:micrometer-registry-influx")
|
implementation("io.micrometer:micrometer-registry-influx")
|
||||||
implementation("io.hawt:hawtio-springboot:2.10.0")
|
implementation("io.hawt:hawtio-springboot:2.10.0")
|
||||||
|
|
||||||
run {
|
run {
|
||||||
val springdocVersion = "1.3.3"
|
val springdocVersion = "1.3.4"
|
||||||
implementation("org.springdoc:springdoc-openapi-ui:$springdocVersion")
|
implementation("org.springdoc:springdoc-openapi-ui:$springdocVersion")
|
||||||
implementation("org.springdoc:springdoc-openapi-security:$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.module:jackson-module-kotlin")
|
||||||
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
|
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
|
||||||
|
|
||||||
implementation("com.github.klinq:klinq-jpaspec:0.9")
|
|
||||||
|
|
||||||
implementation("commons-io:commons-io:2.6")
|
implementation("commons-io:commons-io:2.6")
|
||||||
implementation("org.apache.commons:commons-lang3:3.10")
|
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("org.apache.commons:commons-compress:1.20")
|
||||||
implementation("com.github.junrar:junrar:4.0.0")
|
implementation("com.github.junrar:junrar:4.0.0")
|
||||||
implementation("org.apache.pdfbox:pdfbox:2.0.19")
|
implementation("org.apache.pdfbox:pdfbox:2.0.19")
|
||||||
|
|
@ -85,14 +92,15 @@ dependencies {
|
||||||
|
|
||||||
implementation("com.jakewharton.byteunits:byteunits:0.9.1")
|
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") {
|
testImplementation("org.springframework.boot:spring-boot-starter-test") {
|
||||||
exclude(module = "mockito-core")
|
exclude(module = "mockito-core")
|
||||||
}
|
}
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
testImplementation("com.ninja-squad:springmockk:2.0.1")
|
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.google.jimfs:jimfs:1.1")
|
||||||
|
|
||||||
testImplementation("com.tngtech.archunit:archunit-junit5:0.13.1")
|
testImplementation("com.tngtech.archunit:archunit-junit5:0.13.1")
|
||||||
|
|
@ -100,6 +108,7 @@ dependencies {
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val webui = "$rootDir/komga-webui"
|
||||||
tasks {
|
tasks {
|
||||||
withType<KotlinCompile> {
|
withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
|
@ -119,20 +128,18 @@ tasks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
register<Copy>("unpack") {
|
//unpack Spring Boot's fat jar for better Docker image layering
|
||||||
|
register<Sync>("unpack") {
|
||||||
dependsOn(bootJar)
|
dependsOn(bootJar)
|
||||||
from(zipTree(getByName("bootJar").outputs.files.singleFile))
|
from(zipTree(getByName("bootJar").outputs.files.singleFile))
|
||||||
into("$buildDir/dependency")
|
into("$buildDir/dependency")
|
||||||
}
|
}
|
||||||
|
|
||||||
register<Delete>("deletePublic") {
|
|
||||||
group = "web"
|
|
||||||
delete("$projectDir/src/main/resources/public/")
|
|
||||||
}
|
|
||||||
|
|
||||||
register<Exec>("npmInstall") {
|
register<Exec>("npmInstall") {
|
||||||
group = "web"
|
group = "web"
|
||||||
workingDir("$rootDir/komga-webui")
|
workingDir(webui)
|
||||||
|
inputs.file("$webui/package.json")
|
||||||
|
outputs.dir("$webui/node_modules")
|
||||||
commandLine(
|
commandLine(
|
||||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
"npm.cmd"
|
"npm.cmd"
|
||||||
|
|
@ -146,7 +153,9 @@ tasks {
|
||||||
register<Exec>("npmBuild") {
|
register<Exec>("npmBuild") {
|
||||||
group = "web"
|
group = "web"
|
||||||
dependsOn("npmInstall")
|
dependsOn("npmInstall")
|
||||||
workingDir("$rootDir/komga-webui")
|
workingDir(webui)
|
||||||
|
inputs.dir(webui)
|
||||||
|
outputs.dir("$webui/dist")
|
||||||
commandLine(
|
commandLine(
|
||||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
"npm.cmd"
|
"npm.cmd"
|
||||||
|
|
@ -158,16 +167,22 @@ tasks {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
register<Copy>("copyWebDist") {
|
//copy the webui build into public
|
||||||
|
register<Sync>("copyWebDist") {
|
||||||
group = "web"
|
group = "web"
|
||||||
dependsOn("deletePublic", "npmBuild")
|
dependsOn("npmBuild")
|
||||||
from("$rootDir/komga-webui/dist/")
|
from("$webui/dist/")
|
||||||
into("$projectDir/src/main/resources/public/")
|
into("$projectDir/src/main/resources/public/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
springBoot {
|
springBoot {
|
||||||
buildInfo()
|
buildInfo {
|
||||||
|
properties {
|
||||||
|
// prevent task bootBuildInfo to rerun every time
|
||||||
|
time = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allOpen {
|
allOpen {
|
||||||
|
|
@ -175,3 +190,70 @@ allOpen {
|
||||||
annotation("javax.persistence.MappedSuperclass")
|
annotation("javax.persistence.MappedSuperclass")
|
||||||
annotation("javax.persistence.Embeddable")
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -2,12 +2,10 @@ package org.gotson.komga
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableJpaAuditing
|
|
||||||
class Application
|
class Application
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|
|
||||||
|
|
@ -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<BookMetadataProvider>,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
package org.gotson.komga.application.tasks
|
package org.gotson.komga.application.tasks
|
||||||
|
|
||||||
import mu.KotlinLogging
|
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.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
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.LibraryScanner
|
||||||
|
import org.gotson.komga.domain.service.MetadataLifecycle
|
||||||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
|
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
|
||||||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR
|
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.jms.annotation.JmsListener
|
import org.springframework.jms.annotation.JmsListener
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import kotlin.time.measureTime
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
@ -27,12 +25,12 @@ class TaskHandler(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR)
|
@JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR)
|
||||||
@Transactional
|
|
||||||
fun handleTask(task: Task) {
|
fun handleTask(task: Task) {
|
||||||
logger.info { "Executing task: $task" }
|
logger.info { "Executing task: $task" }
|
||||||
try {
|
try {
|
||||||
measureTime {
|
measureTime {
|
||||||
when (task) {
|
when (task) {
|
||||||
|
|
||||||
is Task.ScanLibrary ->
|
is Task.ScanLibrary ->
|
||||||
libraryRepository.findByIdOrNull(task.libraryId)?.let {
|
libraryRepository.findByIdOrNull(task.libraryId)?.let {
|
||||||
libraryScanner.scanRootFolder(it)
|
libraryScanner.scanRootFolder(it)
|
||||||
|
|
@ -54,6 +52,7 @@ class TaskHandler(
|
||||||
bookRepository.findByIdOrNull(task.bookId)?.let {
|
bookRepository.findByIdOrNull(task.bookId)?.let {
|
||||||
metadataLifecycle.refreshMetadata(it)
|
metadataLifecycle.refreshMetadata(it)
|
||||||
} ?: logger.warn { "Cannot execute task $task: Book does not exist" }
|
} ?: logger.warn { "Cannot execute task $task: Book does not exist" }
|
||||||
|
|
||||||
}
|
}
|
||||||
}.also {
|
}.also {
|
||||||
logger.info { "Task $task executed in $it" }
|
logger.info { "Task $task executed in $it" }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.application.tasks
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Book
|
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.Library
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
|
|
@ -23,25 +24,36 @@ class TaskReceiver(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun scanLibraries() {
|
fun scanLibraries() {
|
||||||
libraryRepository.findAll().forEach { scanLibrary(it) }
|
libraryRepository.findAll().forEach { scanLibrary(it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scanLibrary(library: Library) {
|
fun scanLibrary(libraryId: Long) {
|
||||||
submitTask(Task.ScanLibrary(library.id))
|
submitTask(Task.ScanLibrary(libraryId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun analyzeUnknownBooks(library: Library) {
|
fun analyzeUnknownBooks(library: Library) {
|
||||||
bookRepository.findAllByMediaStatusAndSeriesLibrary(Media.Status.UNKNOWN, library).forEach {
|
bookRepository.findAllId(BookSearch(
|
||||||
submitTask(Task.AnalyzeBook(it.id))
|
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) {
|
fun analyzeBook(book: Book) {
|
||||||
submitTask(Task.AnalyzeBook(book.id))
|
submitTask(Task.AnalyzeBook(book.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateBookThumbnail(book: Book) {
|
fun generateBookThumbnail(bookId: Long) {
|
||||||
submitTask(Task.GenerateBookThumbnail(book.id))
|
submitTask(Task.GenerateBookThumbnail(bookId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshBookMetadata(bookId: Long) {
|
||||||
|
submitTask(Task.RefreshBookMetadata(bookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshBookMetadata(book: Book) {
|
fun refreshBookMetadata(book: Book) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import javax.persistence.Column
|
class Author(
|
||||||
import javax.persistence.Embeddable
|
name: String,
|
||||||
import javax.validation.constraints.NotBlank
|
role: String
|
||||||
|
) {
|
||||||
@Embeddable
|
val name = name.trim()
|
||||||
class Author {
|
val role = role.trim().toLowerCase()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String = "Author($name, $role)"
|
override fun toString(): String = "Author($name, $role)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,74 +2,25 @@ package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import com.jakewharton.byteunits.BinaryByteUnit
|
import com.jakewharton.byteunits.BinaryByteUnit
|
||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
import org.hibernate.annotations.Cache
|
|
||||||
import org.hibernate.annotations.CacheConcurrencyStrategy
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.time.LocalDateTime
|
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
|
data class Book(
|
||||||
@Table(name = "book")
|
val name: String,
|
||||||
@Cacheable
|
val url: URL,
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book")
|
val fileLastModified: LocalDateTime,
|
||||||
class Book(
|
val fileSize: Long = 0,
|
||||||
@NotBlank
|
val number: Int = 0,
|
||||||
@Column(name = "name", nullable = false)
|
|
||||||
var name: String,
|
|
||||||
|
|
||||||
@Column(name = "url", nullable = false)
|
val id: Long = 0,
|
||||||
var url: URL,
|
val seriesId: Long = 0,
|
||||||
|
val libraryId: Long = 0,
|
||||||
|
|
||||||
@Column(name = "file_last_modified", nullable = false)
|
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||||
var fileLastModified: LocalDateTime,
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
|
) : Auditable() {
|
||||||
@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()
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fileName(): String = FilenameUtils.getName(url.toString())
|
fun fileName(): String = FilenameUtils.getName(url.toString())
|
||||||
|
|
||||||
|
|
@ -78,6 +29,4 @@ class Book(
|
||||||
fun path(): Path = Paths.get(this.url.toURI())
|
fun path(): Path = Paths.get(this.url.toURI())
|
||||||
|
|
||||||
fun fileSizeHumanReadable(): String = BinaryByteUnit.format(fileSize)
|
fun fileSizeHumanReadable(): String = BinaryByteUnit.format(fileSize)
|
||||||
|
|
||||||
override fun toString(): String = "Book($id, ${url.toURI().path})"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,86 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import org.hibernate.annotations.Cache
|
|
||||||
import org.hibernate.annotations.CacheConcurrencyStrategy
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import javax.persistence.Cacheable
|
import java.time.LocalDateTime
|
||||||
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
|
|
||||||
|
|
||||||
@Entity
|
class BookMetadata(
|
||||||
@Table(name = "book_metadata")
|
title: String,
|
||||||
@Cacheable
|
summary: String = "",
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book_metadata")
|
number: String,
|
||||||
class BookMetadata : AuditableEntity {
|
val numberSort: Float,
|
||||||
constructor(
|
val readingDirection: ReadingDirection? = null,
|
||||||
title: String,
|
publisher: String = "",
|
||||||
summary: String = "",
|
val ageRating: Int? = null,
|
||||||
number: String,
|
val releaseDate: LocalDate? = null,
|
||||||
numberSort: Float,
|
val authors: List<Author> = emptyList(),
|
||||||
readingDirection: ReadingDirection? = null,
|
|
||||||
publisher: String = "",
|
|
||||||
ageRating: Int? = null,
|
|
||||||
releaseDate: LocalDate? = null,
|
|
||||||
authors: MutableList<Author> = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@Id
|
val titleLock: Boolean = false,
|
||||||
@GeneratedValue
|
val summaryLock: Boolean = false,
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
val numberLock: Boolean = false,
|
||||||
val id: Long = 0
|
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
|
val bookId: Long = 0,
|
||||||
@Column(name = "title", nullable = false)
|
|
||||||
var title: String
|
|
||||||
set(value) {
|
|
||||||
require(value.isNotBlank()) { "title must not be blank" }
|
|
||||||
field = value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Column(name = "summary", nullable = false)
|
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||||
var summary: String
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
set(value) {
|
) : Auditable() {
|
||||||
field = value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
@NotBlank
|
val title = title.trim()
|
||||||
@Column(name = "number", nullable = false)
|
val summary = summary.trim()
|
||||||
var number: String
|
val number = number.trim()
|
||||||
set(value) {
|
val publisher = publisher.trim()
|
||||||
require(value.isNotBlank()) { "number must not be blank" }
|
|
||||||
field = value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Column(name = "number_sort", nullable = false, columnDefinition = "REAL")
|
fun copy(
|
||||||
var numberSort: Float
|
title: String = this.title,
|
||||||
|
summary: String = this.summary,
|
||||||
@Enumerated(EnumType.STRING)
|
number: String = this.number,
|
||||||
@Column(name = "reading_direction", nullable = true)
|
numberSort: Float = this.numberSort,
|
||||||
var readingDirection: ReadingDirection?
|
readingDirection: ReadingDirection? = this.readingDirection,
|
||||||
|
publisher: String = this.publisher,
|
||||||
@Column(name = "publisher", nullable = false)
|
ageRating: Int? = this.ageRating,
|
||||||
var publisher: String
|
releaseDate: LocalDate? = this.releaseDate,
|
||||||
set(value) {
|
authors: List<Author> = this.authors.toList(),
|
||||||
field = value.trim()
|
titleLock: Boolean = this.titleLock,
|
||||||
}
|
summaryLock: Boolean = this.summaryLock,
|
||||||
|
numberLock: Boolean = this.numberLock,
|
||||||
@PositiveOrZero
|
numberSortLock: Boolean = this.numberSortLock,
|
||||||
@Column(name = "age_rating", nullable = true)
|
readingDirectionLock: Boolean = this.readingDirectionLock,
|
||||||
var ageRating: Int?
|
publisherLock: Boolean = this.publisherLock,
|
||||||
|
ageRatingLock: Boolean = this.ageRatingLock,
|
||||||
@Column(name = "release_date", nullable = true)
|
releaseDateLock: Boolean = this.releaseDateLock,
|
||||||
var releaseDate: LocalDate?
|
authorsLock: Boolean = this.authorsLock,
|
||||||
|
bookId: Long = this.bookId,
|
||||||
@ElementCollection(fetch = FetchType.LAZY)
|
createdDate: LocalDateTime = this.createdDate,
|
||||||
@CollectionTable(name = "book_metadata_author", joinColumns = [JoinColumn(name = "book_metadata_id")])
|
lastModifiedDate: LocalDateTime = this.lastModifiedDate
|
||||||
var authors: MutableList<Author>
|
) =
|
||||||
|
BookMetadata(
|
||||||
|
title = title,
|
||||||
@Column(name = "title_lock", nullable = false)
|
summary = summary,
|
||||||
var titleLock: Boolean = false
|
number = number,
|
||||||
|
numberSort = numberSort,
|
||||||
@Column(name = "summary_lock", nullable = false)
|
readingDirection = readingDirection,
|
||||||
var summaryLock: Boolean = false
|
publisher = publisher,
|
||||||
|
ageRating = ageRating,
|
||||||
@Column(name = "number_lock", nullable = false)
|
releaseDate = releaseDate,
|
||||||
var numberLock: Boolean = false
|
authors = authors,
|
||||||
|
titleLock = titleLock,
|
||||||
@Column(name = "number_sort_lock", nullable = false)
|
summaryLock = summaryLock,
|
||||||
var numberSortLock: Boolean = false
|
numberLock = numberLock,
|
||||||
|
numberSortLock = numberSortLock,
|
||||||
@Column(name = "reading_direction_lock", nullable = false)
|
readingDirectionLock = readingDirectionLock,
|
||||||
var readingDirectionLock: Boolean = false
|
publisherLock = publisherLock,
|
||||||
|
ageRatingLock = ageRatingLock,
|
||||||
@Column(name = "publisher_lock", nullable = false)
|
releaseDateLock = releaseDateLock,
|
||||||
var publisherLock: Boolean = false
|
authorsLock = authorsLock,
|
||||||
|
bookId = bookId,
|
||||||
@Column(name = "age_rating_lock", nullable = false)
|
createdDate = createdDate,
|
||||||
var ageRatingLock: Boolean = false
|
lastModifiedDate = lastModifiedDate
|
||||||
|
)
|
||||||
@Column(name = "release_date_lock", nullable = false)
|
|
||||||
var releaseDateLock: Boolean = false
|
|
||||||
|
|
||||||
@Column(name = "authors_lock", nullable = false)
|
|
||||||
var authorsLock: Boolean = false
|
|
||||||
|
|
||||||
enum class ReadingDirection {
|
enum class ReadingDirection {
|
||||||
LEFT_TO_RIGHT,
|
LEFT_TO_RIGHT,
|
||||||
|
|
@ -130,4 +88,7 @@ class BookMetadata : AuditableEntity {
|
||||||
VERTICAL,
|
VERTICAL,
|
||||||
WEBTOON
|
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')"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
class BookMetadataPatch(
|
data class BookMetadataPatch(
|
||||||
val title: String?,
|
val title: String?,
|
||||||
val summary: String?,
|
val summary: String?,
|
||||||
val number: String?,
|
val number: String?,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import javax.persistence.Column
|
data class BookPage(
|
||||||
import javax.persistence.Embeddable
|
|
||||||
|
|
||||||
@Embeddable
|
|
||||||
class BookPage(
|
|
||||||
@Column(name = "file_name", nullable = false)
|
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
|
|
||||||
@Column(name = "media_type", nullable = false)
|
|
||||||
val mediaType: String
|
val mediaType: String
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
|
data class BookSearch(
|
||||||
|
val libraryIds: Collection<Long> = emptyList(),
|
||||||
|
val seriesIds: Collection<Long> = emptyList(),
|
||||||
|
val searchTerm: String? = null,
|
||||||
|
val mediaStatus: Collection<Media.Status> = emptyList()
|
||||||
|
)
|
||||||
|
|
@ -6,3 +6,4 @@ class ImageConversionException(message: String) : Exception(message)
|
||||||
class DirectoryNotFoundException(message: String) : Exception(message)
|
class DirectoryNotFoundException(message: String) : Exception(message)
|
||||||
class DuplicateNameException(message: String) : Exception(message)
|
class DuplicateNameException(message: String) : Exception(message)
|
||||||
class PathContainedInPath(message: String) : Exception(message)
|
class PathContainedInPath(message: String) : Exception(message)
|
||||||
|
class UserEmailAlreadyExistsException(message: String) : Exception(message)
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,55 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import javax.persistence.CollectionTable
|
import java.time.LocalDateTime
|
||||||
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 javax.validation.constraints.Email
|
import javax.validation.constraints.Email
|
||||||
import javax.validation.constraints.NotBlank
|
import javax.validation.constraints.NotBlank
|
||||||
import javax.validation.constraints.NotNull
|
|
||||||
|
|
||||||
@Entity
|
data class KomgaUser(
|
||||||
@Table(name = "user")
|
|
||||||
class KomgaUser(
|
|
||||||
@Email
|
@Email
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Column(name = "email", nullable = false, unique = true)
|
val email: String,
|
||||||
var email: String,
|
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Column(name = "password", nullable = false)
|
val password: String,
|
||||||
var password: String,
|
val roleAdmin: Boolean,
|
||||||
|
val sharedLibrariesIds: Set<Long> = 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)
|
fun roles(): Set<String> {
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
val roles = mutableSetOf("USER")
|
||||||
@CollectionTable(name = "user_role", joinColumns = [JoinColumn(name = "user_id")])
|
if (roleAdmin) roles.add("ADMIN")
|
||||||
var roles: MutableSet<UserRoles> = mutableSetOf()
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
) : AuditableEntity() {
|
fun getAuthorizedLibraryIds(libraryIds: Collection<Long>?) =
|
||||||
|
when {
|
||||||
|
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries
|
||||||
|
!sharedAllLibraries && !libraryIds.isNullOrEmpty() -> libraryIds.intersect(sharedLibrariesIds)
|
||||||
|
|
||||||
@Id
|
// limited user: filter on user's authorized libraries
|
||||||
@GeneratedValue
|
!sharedAllLibraries -> sharedLibrariesIds
|
||||||
@Column(name = "id", nullable = false)
|
|
||||||
var id: Long = 0
|
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
// non-limited user: filter on provided libraries
|
||||||
@JoinTable(
|
!libraryIds.isNullOrEmpty() -> libraryIds
|
||||||
name = "user_library_sharing",
|
|
||||||
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
|
|
||||||
inverseJoinColumns = [JoinColumn(name = "library_id", referencedColumnName = "id")]
|
|
||||||
)
|
|
||||||
var sharedLibraries: MutableSet<Library> = mutableSetOf()
|
|
||||||
|
|
||||||
@NotNull
|
else -> emptyList()
|
||||||
@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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAdmin() = roles.contains(UserRoles.ADMIN)
|
|
||||||
|
|
||||||
fun canAccessBook(book: Book): Boolean {
|
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 {
|
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 {
|
fun canAccessLibrary(library: Library): Boolean {
|
||||||
return sharedAllLibraries || sharedLibraries.any { it.id == library.id }
|
return sharedAllLibraries || sharedLibrariesIds.any { it == library.id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class UserRoles {
|
|
||||||
ADMIN
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,19 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import org.hibernate.annotations.Cache
|
|
||||||
import org.hibernate.annotations.CacheConcurrencyStrategy
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import javax.persistence.Cacheable
|
import java.time.LocalDateTime
|
||||||
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
|
|
||||||
|
|
||||||
@Entity
|
data class Library(
|
||||||
@Table(name = "library")
|
|
||||||
@Cacheable
|
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.library")
|
|
||||||
class Library(
|
|
||||||
@NotBlank
|
|
||||||
@Column(name = "name", nullable = false, unique = true)
|
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val root: URL,
|
||||||
@NotBlank
|
val id: Long = 0,
|
||||||
@Column(name = "root", nullable = false)
|
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||||
val root: URL
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
) : AuditableEntity() {
|
) : Auditable() {
|
||||||
@Id
|
|
||||||
@GeneratedValue
|
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
|
||||||
var id: Long = 0
|
|
||||||
|
|
||||||
constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL())
|
constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL())
|
||||||
|
|
||||||
fun path(): Path = Paths.get(this.root.toURI())
|
fun path(): Path = Paths.get(this.root.toURI())
|
||||||
|
|
||||||
override fun toString() = "Library($id, $name, ${root.toURI().path})"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,48 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import org.hibernate.annotations.Cache
|
import java.time.LocalDateTime
|
||||||
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
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "media")
|
|
||||||
@Cacheable
|
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media")
|
|
||||||
class Media(
|
class Media(
|
||||||
@Enumerated(EnumType.STRING)
|
val status: Status = Status.UNKNOWN,
|
||||||
@Column(name = "status", nullable = false)
|
val mediaType: String? = null,
|
||||||
var status: Status = Status.UNKNOWN,
|
val thumbnail: ByteArray? = null,
|
||||||
|
val pages: List<BookPage> = emptyList(),
|
||||||
|
val files: List<String> = 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")
|
fun reset() = Media(bookId = this.bookId)
|
||||||
var mediaType: String? = null,
|
|
||||||
|
|
||||||
@Column(name = "thumbnail")
|
fun copy(
|
||||||
@Lob
|
status: Status = this.status,
|
||||||
var thumbnail: ByteArray? = null,
|
mediaType: String? = this.mediaType,
|
||||||
|
thumbnail: ByteArray? = this.thumbnail,
|
||||||
pages: Iterable<BookPage> = emptyList(),
|
pages: List<BookPage> = this.pages.toList(),
|
||||||
|
files: List<String> = this.files.toList(),
|
||||||
files: Iterable<String> = emptyList(),
|
comment: String? = this.comment,
|
||||||
|
bookId: Long = this.bookId,
|
||||||
@Column(name = "comment")
|
createdDate: LocalDateTime = this.createdDate,
|
||||||
var comment: String? = null
|
lastModifiedDate: LocalDateTime = this.lastModifiedDate
|
||||||
) : AuditableEntity() {
|
) =
|
||||||
@Id
|
Media(
|
||||||
@GeneratedValue
|
status = status,
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
mediaType = mediaType,
|
||||||
val id: Long = 0
|
thumbnail = thumbnail,
|
||||||
|
pages = pages,
|
||||||
@ElementCollection(fetch = FetchType.LAZY)
|
files = files,
|
||||||
@CollectionTable(name = "media_page", joinColumns = [JoinColumn(name = "media_id")])
|
comment = comment,
|
||||||
@OrderColumn(name = "number")
|
bookId = bookId,
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media.collection.pages")
|
createdDate = createdDate,
|
||||||
private var _pages: MutableList<BookPage> = mutableListOf()
|
lastModifiedDate = lastModifiedDate
|
||||||
|
)
|
||||||
var pages: List<BookPage>
|
|
||||||
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<String> = mutableListOf()
|
|
||||||
|
|
||||||
var files: List<String>
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class Status {
|
enum class Status {
|
||||||
UNKNOWN, ERROR, READY, UNSUPPORTED
|
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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
class MediaContainerEntry(
|
data class MediaContainerEntry(
|
||||||
val name: String,
|
val name: String,
|
||||||
val mediaType: String? = null,
|
val mediaType: String? = null,
|
||||||
val comment: String? = null
|
val comment: String? = null
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,16 @@
|
||||||
package org.gotson.komga.domain.model
|
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.net.URL
|
||||||
import java.time.LocalDateTime
|
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<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
data class Series(
|
||||||
|
val name: String,
|
||||||
@Entity
|
val url: URL,
|
||||||
@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)
|
|
||||||
var fileLastModified: LocalDateTime,
|
var fileLastModified: LocalDateTime,
|
||||||
|
|
||||||
books: Iterable<Book>
|
val id: Long = 0,
|
||||||
|
val libraryId: Long = 0,
|
||||||
|
|
||||||
) : AuditableEntity() {
|
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||||
@Id
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
@GeneratedValue
|
) : Auditable()
|
||||||
@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<Book> = mutableListOf()
|
|
||||||
|
|
||||||
var books: List<Book>
|
|
||||||
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})"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,51 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
import org.hibernate.annotations.Cache
|
import java.time.LocalDateTime
|
||||||
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
|
|
||||||
|
|
||||||
@Entity
|
class SeriesMetadata(
|
||||||
@Table(name = "series_metadata")
|
val status: Status = Status.ONGOING,
|
||||||
@Cacheable
|
title: String,
|
||||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series_metadata")
|
titleSort: String = title,
|
||||||
class SeriesMetadata : AuditableEntity {
|
|
||||||
constructor(
|
|
||||||
status: Status = Status.ONGOING,
|
|
||||||
title: String,
|
|
||||||
titleSort: String = title
|
|
||||||
) : super() {
|
|
||||||
this.status = status
|
|
||||||
this.title = title
|
|
||||||
this.titleSort = titleSort
|
|
||||||
}
|
|
||||||
|
|
||||||
@Id
|
val statusLock: Boolean = false,
|
||||||
@GeneratedValue
|
val titleLock: Boolean = false,
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
val titleSortLock: Boolean = false,
|
||||||
val id: Long = 0
|
|
||||||
|
|
||||||
|
val seriesId: Long = 0,
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||||
@Column(name = "status", nullable = false)
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
var status: Status
|
) : Auditable() {
|
||||||
|
val title = title.trim()
|
||||||
@NotBlank
|
val titleSort = titleSort.trim()
|
||||||
@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
|
|
||||||
|
|
||||||
|
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 {
|
enum class Status {
|
||||||
ENDED, ONGOING, ABANDONED, HIATUS
|
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')"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package org.gotson.komga.domain.model
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
class SeriesMetadataPatch(
|
data class SeriesMetadataPatch(
|
||||||
val title: String?,
|
val title: String?,
|
||||||
val titleSort: String?,
|
val titleSort: String?,
|
||||||
val status: SeriesMetadata.Status?
|
val status: SeriesMetadata.Status?
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
|
data class SeriesSearch(
|
||||||
|
val libraryIds: Collection<Long> = emptyList(),
|
||||||
|
val searchTerm: String? = null,
|
||||||
|
val metadataStatus: Collection<SeriesMetadata.Status> = emptyList()
|
||||||
|
)
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.BookMetadata
|
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 {
|
||||||
interface BookMetadataRepository : JpaRepository<BookMetadata, Long> {
|
fun findById(bookId: Long): BookMetadata
|
||||||
@Query(
|
fun findByIdOrNull(bookId: Long): BookMetadata?
|
||||||
value = "select distinct a.name from BOOK_METADATA_AUTHOR a where a.name ilike CONCAT('%', :search, '%') order by a.name",
|
|
||||||
nativeQuery = true
|
fun findAuthorsByName(search: String): List<String>
|
||||||
)
|
|
||||||
fun findAuthorsByName(@Param("search") search: String): List<String>
|
fun insert(metadata: BookMetadata): BookMetadata
|
||||||
|
fun update(metadata: BookMetadata)
|
||||||
|
|
||||||
|
fun delete(bookId: Long)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,26 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.Book
|
||||||
import org.gotson.komga.domain.model.Library
|
import org.gotson.komga.domain.model.BookSearch
|
||||||
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
|
|
||||||
|
|
||||||
@Repository
|
interface BookRepository {
|
||||||
interface BookRepository : JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> {
|
fun findByIdOrNull(bookId: Long): Book?
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun findBySeriesId(seriesId: Long): Collection<Book>
|
||||||
override fun findAll(pageable: Pageable): Page<Book>
|
fun findAll(): Collection<Book>
|
||||||
|
fun findAll(bookSearch: BookSearch): Collection<Book>
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun getLibraryId(bookId: Long): Long?
|
||||||
fun findAllBySeriesId(seriesId: Long, pageable: Pageable): Page<Book>
|
fun findFirstIdInSeries(seriesId: Long): Long?
|
||||||
|
fun findAllIdBySeriesId(seriesId: Long): Collection<Long>
|
||||||
|
fun findAllIdByLibraryId(libraryId: Long): Collection<Long>
|
||||||
|
fun findAllId(bookSearch: BookSearch): Collection<Long>
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun insert(book: Book): Book
|
||||||
fun findAllByMediaStatusInAndSeriesId(status: Collection<Media.Status>, seriesId: Long, pageable: Pageable): Page<Book>
|
fun update(book: Book)
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun delete(bookId: Long)
|
||||||
fun findBySeriesLibraryIn(seriesLibrary: Collection<Library>, pageable: Pageable): Page<Book>
|
fun deleteAll(bookIds: List<Long>)
|
||||||
|
fun deleteAll()
|
||||||
|
|
||||||
fun findBySeriesLibraryIn(seriesLibrary: Collection<Library>): List<Book>
|
fun count(): Long
|
||||||
fun findBySeriesLibrary(seriesLibrary: Library): List<Book>
|
|
||||||
|
|
||||||
fun findByUrl(url: URL): Book?
|
|
||||||
fun findAllByMediaStatusAndSeriesLibrary(status: Media.Status, library: Library): List<Book>
|
|
||||||
fun findAllByMediaThumbnailIsNull(): List<Book>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.KomgaUser
|
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 {
|
||||||
interface KomgaUserRepository : CrudRepository<KomgaUser, Long> {
|
fun count(): Long
|
||||||
|
|
||||||
|
fun findAll(): Collection<KomgaUser>
|
||||||
|
fun findByIdOrNull(id: Long): KomgaUser?
|
||||||
|
|
||||||
|
fun save(user: KomgaUser): KomgaUser
|
||||||
|
fun saveAll(users: Iterable<KomgaUser>): Collection<KomgaUser>
|
||||||
|
|
||||||
|
fun delete(user: KomgaUser)
|
||||||
|
fun deleteAll()
|
||||||
|
|
||||||
fun existsByEmailIgnoreCase(email: String): Boolean
|
fun existsByEmailIgnoreCase(email: String): Boolean
|
||||||
fun findByEmailIgnoreCase(email: String): KomgaUser?
|
fun findByEmailIgnoreCase(email: String): KomgaUser?
|
||||||
fun findBySharedLibrariesContaining(library: Library): List<KomgaUser>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.Library
|
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 {
|
||||||
interface LibraryRepository : JpaRepository<Library, Long> {
|
fun findByIdOrNull(libraryId: Long): Library?
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun findAll(): Collection<Library>
|
||||||
override fun findAll(sort: Sort): List<Library>
|
fun findAllById(libraryIds: Collection<Long>): Collection<Library>
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
|
||||||
override fun findAllById(ids: Iterable<Long>): List<Library>
|
|
||||||
|
|
||||||
fun existsByName(name: String): Boolean
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
|
fun delete(libraryId: Long)
|
||||||
|
fun deleteAll()
|
||||||
|
|
||||||
|
fun insert(library: Library): Library
|
||||||
|
|
||||||
|
fun count(): Long
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
|
|
||||||
@Repository
|
interface MediaRepository {
|
||||||
interface MediaRepository : JpaRepository<Media, Long>
|
fun findById(bookId: Long): Media
|
||||||
|
|
||||||
|
fun getThumbnail(bookId: Long): ByteArray?
|
||||||
|
|
||||||
|
fun insert(media: Media): Media
|
||||||
|
fun update(media: Media)
|
||||||
|
|
||||||
|
fun delete(bookId: Long)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,25 @@
|
||||||
package org.gotson.komga.domain.persistence
|
package org.gotson.komga.domain.persistence
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.Library
|
|
||||||
import org.gotson.komga.domain.model.Series
|
import org.gotson.komga.domain.model.Series
|
||||||
import org.hibernate.annotations.QueryHints.CACHEABLE
|
import org.gotson.komga.domain.model.SeriesSearch
|
||||||
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 java.net.URL
|
import java.net.URL
|
||||||
import javax.persistence.QueryHint
|
|
||||||
|
|
||||||
@Repository
|
interface SeriesRepository {
|
||||||
interface SeriesRepository : JpaRepository<Series, Long>, JpaSpecificationExecutor<Series> {
|
fun findAll(): Collection<Series>
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
fun findByIdOrNull(seriesId: Long): Series?
|
||||||
override fun findAll(pageable: Pageable): Page<Series>
|
fun findByLibraryId(libraryId: Long): Collection<Series>
|
||||||
|
fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection<URL>): Collection<Series>
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
|
||||||
fun findByLibraryIn(libraries: Collection<Library>, sort: Sort): List<Series>
|
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
|
||||||
fun findByLibraryIn(libraries: Collection<Library>, page: Pageable): Page<Series>
|
|
||||||
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
|
||||||
fun findByLibraryId(libraryId: Long, sort: Sort): List<Series>
|
|
||||||
|
|
||||||
@Query("select s from Series s where s.createdDate <> s.lastModifiedDate")
|
|
||||||
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
|
|
||||||
fun findRecentlyUpdated(pageable: Pageable): Page<Series>
|
|
||||||
|
|
||||||
@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<Library>, pageable: Pageable): Page<Series>
|
|
||||||
|
|
||||||
fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection<URL>): List<Series>
|
|
||||||
fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series?
|
fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series?
|
||||||
fun deleteByLibraryId(libraryId: Long)
|
fun findAll(search: SeriesSearch): Collection<Series>
|
||||||
|
|
||||||
|
fun getLibraryId(seriesId: Long): Long?
|
||||||
|
|
||||||
|
fun insert(series: Series): Series
|
||||||
|
fun update(series: Series)
|
||||||
|
|
||||||
|
fun delete(seriesId: Long)
|
||||||
|
fun deleteAll()
|
||||||
|
fun deleteAll(seriesIds: Collection<Long>)
|
||||||
|
|
||||||
|
fun count(): Long
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookPage
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
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.image.ImageConverter
|
||||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||||
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
|
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
|
||||||
|
|
@ -17,7 +18,8 @@ private val logger = KotlinLogging.logger {}
|
||||||
class BookAnalyzer(
|
class BookAnalyzer(
|
||||||
private val contentDetector: ContentDetector,
|
private val contentDetector: ContentDetector,
|
||||||
extractors: List<MediaContainerExtractor>,
|
extractors: List<MediaContainerExtractor>,
|
||||||
private val imageConverter: ImageConverter
|
private val imageConverter: ImageConverter,
|
||||||
|
private val mediaRepository: MediaRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val supportedMediaTypes = extractors
|
val supportedMediaTypes = extractors
|
||||||
|
|
@ -78,17 +80,19 @@ class BookAnalyzer(
|
||||||
fun regenerateThumbnail(book: Book): Media {
|
fun regenerateThumbnail(book: Book): Media {
|
||||||
logger.info { "Regenerate thumbnail for book: $book" }
|
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" }
|
logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" }
|
||||||
throw MediaNotReadyException()
|
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(
|
return Media(
|
||||||
mediaType = book.media.mediaType,
|
mediaType = media.mediaType,
|
||||||
status = Media.Status.READY,
|
status = Media.Status.READY,
|
||||||
pages = book.media.pages,
|
pages = media.pages,
|
||||||
thumbnail = thumbnail
|
thumbnail = thumbnail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -110,17 +114,19 @@ class BookAnalyzer(
|
||||||
fun getPageContent(book: Book, number: Int): ByteArray {
|
fun getPageContent(book: Book, number: Int): ByteArray {
|
||||||
logger.info { "Get page #$number for book: $book" }
|
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" }
|
logger.warn { "Book media is not ready, cannot get pages" }
|
||||||
throw MediaNotReadyException()
|
throw MediaNotReadyException()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (number > book.media.pages.size || number <= 0) {
|
if (number > media.pages.size || number <= 0) {
|
||||||
logger.error { "Page number #$number is out of bounds. Book has ${book.media.pages.size} pages" }
|
logger.error { "Page number #$number is out of bounds. Book has ${media.pages.size} pages" }
|
||||||
throw IndexOutOfBoundsException("Page $number does not exist")
|
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(
|
@Throws(
|
||||||
|
|
@ -129,11 +135,13 @@ class BookAnalyzer(
|
||||||
fun getFileContent(book: Book, fileName: String): ByteArray {
|
fun getFileContent(book: Book, fileName: String): ByteArray {
|
||||||
logger.info { "Get file $fileName for book: $book" }
|
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" }
|
logger.warn { "Book media is not ready, cannot get files" }
|
||||||
throw MediaNotReadyException()
|
throw MediaNotReadyException()
|
||||||
}
|
}
|
||||||
|
|
||||||
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), fileName)
|
return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.gotson.komga.application.service
|
package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Book
|
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.ImageConversionException
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.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.ImageConverter
|
||||||
import org.gotson.komga.infrastructure.image.ImageType
|
import org.gotson.komga.infrastructure.image.ImageType
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
@ -17,30 +18,32 @@ private val logger = KotlinLogging.logger {}
|
||||||
@Service
|
@Service
|
||||||
class BookLifecycle(
|
class BookLifecycle(
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val bookMetadataRepository: BookMetadataRepository,
|
||||||
private val bookAnalyzer: BookAnalyzer,
|
private val bookAnalyzer: BookAnalyzer,
|
||||||
private val imageConverter: ImageConverter
|
private val imageConverter: ImageConverter
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun analyzeAndPersist(book: Book) {
|
fun analyzeAndPersist(book: Book) {
|
||||||
logger.info { "Analyze and persist book: $book" }
|
logger.info { "Analyze and persist book: $book" }
|
||||||
try {
|
val media = try {
|
||||||
book.media = bookAnalyzer.analyze(book)
|
bookAnalyzer.analyze(book)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
logger.error(ex) { "Error while analyzing book: $book" }
|
logger.error(ex) { "Error while analyzing book: $book" }
|
||||||
book.media = Media(status = Media.Status.ERROR, comment = ex.message)
|
Media(status = Media.Status.ERROR, comment = ex.message)
|
||||||
}
|
}.copy(bookId = book.id)
|
||||||
bookRepository.save(book)
|
mediaRepository.update(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun regenerateThumbnailAndPersist(book: Book) {
|
fun regenerateThumbnailAndPersist(book: Book) {
|
||||||
logger.info { "Regenerate thumbnail and persist book: $book" }
|
logger.info { "Regenerate thumbnail and persist book: $book" }
|
||||||
try {
|
val media = try {
|
||||||
book.media = bookAnalyzer.regenerateThumbnail(book)
|
bookAnalyzer.regenerateThumbnail(book)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
logger.error(ex) { "Error while recreating thumbnail" }
|
logger.error(ex) { "Error while recreating thumbnail" }
|
||||||
book.media = Media(status = Media.Status.ERROR)
|
Media(status = Media.Status.ERROR)
|
||||||
}
|
}.copy(bookId = book.id)
|
||||||
bookRepository.save(book)
|
mediaRepository.update(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(
|
@Throws(
|
||||||
|
|
@ -49,8 +52,9 @@ class BookLifecycle(
|
||||||
IndexOutOfBoundsException::class
|
IndexOutOfBoundsException::class
|
||||||
)
|
)
|
||||||
fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent {
|
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 pageContent = bookAnalyzer.getPageContent(book, number)
|
||||||
val pageMediaType = book.media.pages[number - 1].mediaType
|
val pageMediaType = media.pages[number - 1].mediaType
|
||||||
|
|
||||||
if (resizeTo != null) {
|
if (resizeTo != null) {
|
||||||
val targetFormat = ImageType.JPEG
|
val targetFormat = ImageType.JPEG
|
||||||
|
|
@ -88,4 +92,13 @@ class BookLifecycle(
|
||||||
return BookPageContent(number, pageContent, pageMediaType)
|
return BookPageContent(number, pageContent, pageMediaType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun delete(bookId: Long) {
|
||||||
|
logger.info { "Delete book id: $bookId" }
|
||||||
|
|
||||||
|
mediaRepository.delete(bookId)
|
||||||
|
bookMetadataRepository.delete(bookId)
|
||||||
|
|
||||||
|
bookRepository.delete(bookId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,14 +25,14 @@ class FileSystemScanner(
|
||||||
|
|
||||||
val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub")
|
val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub")
|
||||||
|
|
||||||
fun scanRootFolder(root: Path): List<Series> {
|
fun scanRootFolder(root: Path): Map<Series, List<Book>> {
|
||||||
logger.info { "Scanning folder: $root" }
|
logger.info { "Scanning folder: $root" }
|
||||||
logger.info { "Supported extensions: $supportedExtensions" }
|
logger.info { "Supported extensions: $supportedExtensions" }
|
||||||
logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" }
|
logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" }
|
||||||
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
|
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
|
||||||
logger.info { "Force directory modified time: active" }
|
logger.info { "Force directory modified time: active" }
|
||||||
|
|
||||||
lateinit var scannedSeries: List<Series>
|
lateinit var scannedSeries: Map<Series, List<Book>>
|
||||||
|
|
||||||
measureTime {
|
measureTime {
|
||||||
scannedSeries = Files.walk(root, FileVisitOption.FOLLOW_LINKS).use { dirsStream ->
|
scannedSeries = Files.walk(root, FileVisitOption.FOLLOW_LINKS).use { dirsStream ->
|
||||||
|
|
@ -72,13 +72,12 @@ class FileSystemScanner(
|
||||||
fileLastModified =
|
fileLastModified =
|
||||||
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
|
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
|
||||||
maxOf(dir.getUpdatedTime(), books.map { it.fileLastModified }.max()!!)
|
maxOf(dir.getUpdatedTime(), books.map { it.fileLastModified }.max()!!)
|
||||||
else dir.getUpdatedTime(),
|
else dir.getUpdatedTime()
|
||||||
books = books.toMutableList()
|
) to books
|
||||||
)
|
}.toMap()
|
||||||
}.toList()
|
|
||||||
}
|
}
|
||||||
}.also {
|
}.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" }
|
logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
package org.gotson.komga.infrastructure.security
|
package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.KomgaUser
|
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.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.session.SessionRegistry
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService
|
import org.springframework.security.core.userdetails.UserDetailsService
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class KomgaUserDetailsLifecycle(
|
class KomgaUserLifecycle(
|
||||||
private val userRepository: KomgaUserRepository,
|
private val userRepository: KomgaUserRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val sessionRegistry: SessionRegistry
|
private val sessionRegistry: SessionRegistry
|
||||||
|
|
@ -28,39 +27,31 @@ class KomgaUserDetailsLifecycle(
|
||||||
KomgaPrincipal(it)
|
KomgaPrincipal(it)
|
||||||
} ?: throw UsernameNotFoundException(username)
|
} ?: throw UsernameNotFoundException(username)
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails {
|
fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails {
|
||||||
userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser ->
|
userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser ->
|
||||||
logger.info { "Changing password for user ${user.username}" }
|
logger.info { "Changing password for user ${user.username}" }
|
||||||
komgaUser.password = passwordEncoder.encode(newPassword)
|
val updatedUser = komgaUser.copy(password = passwordEncoder.encode(newPassword))
|
||||||
userRepository.save(komgaUser)
|
userRepository.save(updatedUser)
|
||||||
|
|
||||||
if (expireSessions) expireSessions(komgaUser)
|
if (expireSessions) expireSessions(updatedUser)
|
||||||
|
|
||||||
return KomgaPrincipal(komgaUser)
|
return KomgaPrincipal(updatedUser)
|
||||||
} ?: throw UsernameNotFoundException(user.username)
|
} ?: throw UsernameNotFoundException(user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun countUsers() = userRepository.count()
|
fun countUsers() = userRepository.count()
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Throws(UserEmailAlreadyExistsException::class)
|
@Throws(UserEmailAlreadyExistsException::class)
|
||||||
fun createUser(user: UserDetails): UserDetails {
|
fun createUser(komgaUser: KomgaUser): KomgaUser {
|
||||||
if (userRepository.existsByEmailIgnoreCase(user.username)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${user.username}")
|
if (userRepository.existsByEmailIgnoreCase(komgaUser.email)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${komgaUser.email}")
|
||||||
|
|
||||||
val komgaUser = KomgaUser(
|
val createdUser = userRepository.save(komgaUser.copy(password = passwordEncoder.encode(komgaUser.password)))
|
||||||
email = user.username,
|
logger.info { "User created: $createdUser" }
|
||||||
password = passwordEncoder.encode(user.password),
|
return createdUser
|
||||||
roles = user.authorities.toUserRoles()
|
|
||||||
)
|
|
||||||
|
|
||||||
userRepository.save(komgaUser)
|
|
||||||
logger.info { "Created user: ${komgaUser.email}, roles: ${komgaUser.roles}" }
|
|
||||||
return KomgaPrincipal(komgaUser)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteUser(user: KomgaUser) {
|
fun deleteUser(user: KomgaUser) {
|
||||||
logger.info { "Deleting user: ${user.email}" }
|
logger.info { "Deleting user: $user" }
|
||||||
userRepository.delete(user)
|
userRepository.delete(user)
|
||||||
expireSessions(user)
|
expireSessions(user)
|
||||||
}
|
}
|
||||||
|
|
@ -79,16 +70,3 @@ class KomgaUserDetailsLifecycle(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Iterable<GrantedAuthority>.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)
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package org.gotson.komga.application.service
|
package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.application.tasks.TaskReceiver
|
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.DuplicateNameException
|
||||||
import org.gotson.komga.domain.model.Library
|
import org.gotson.komga.domain.model.Library
|
||||||
import org.gotson.komga.domain.model.PathContainedInPath
|
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.LibraryRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
|
@ -19,8 +17,8 @@ private val logger = KotlinLogging.logger {}
|
||||||
@Service
|
@Service
|
||||||
class LibraryLifecycle(
|
class LibraryLifecycle(
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val seriesLifecycle: SeriesLifecycle,
|
||||||
private val seriesRepository: SeriesRepository,
|
private val seriesRepository: SeriesRepository,
|
||||||
private val userRepository: KomgaUserRepository,
|
|
||||||
private val taskReceiver: TaskReceiver
|
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()}")
|
throw PathContainedInPath("Library path ${library.path()} is a parent of existing library ${it.name}: ${it.path()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryRepository.save(library)
|
return libraryRepository.insert(library).let {
|
||||||
taskReceiver.scanLibrary(library)
|
taskReceiver.scanLibrary(it.id)
|
||||||
|
it
|
||||||
return library
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteLibrary(library: Library) {
|
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.findByLibraryId(library.id).forEach {
|
||||||
seriesRepository.deleteByLibraryId(library.id)
|
seriesLifecycle.deleteSeries(it.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryRepository.delete(library)
|
libraryRepository.delete(library.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,9 +3,9 @@ package org.gotson.komga.domain.service
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Library
|
import org.gotson.komga.domain.model.Library
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
|
@ -15,54 +15,83 @@ private val logger = KotlinLogging.logger {}
|
||||||
class LibraryScanner(
|
class LibraryScanner(
|
||||||
private val fileSystemScanner: FileSystemScanner,
|
private val fileSystemScanner: FileSystemScanner,
|
||||||
private val seriesRepository: SeriesRepository,
|
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) {
|
fun scanRootFolder(library: Library) {
|
||||||
logger.info { "Updating 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
|
// delete series that don't exist anymore
|
||||||
if (scannedSeries.isEmpty()) {
|
if (scannedSeries.isEmpty()) {
|
||||||
logger.info { "Scan returned no series, deleting all existing series" }
|
logger.info { "Scan returned no series, deleting all existing series" }
|
||||||
seriesRepository.deleteByLibraryId(library.id)
|
seriesRepository.findByLibraryId(library.id).forEach {
|
||||||
|
seriesLifecycle.deleteSeries(it.id)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
scannedSeries.map { it.url }.let { urls ->
|
scannedSeries.keys.map { it.url }.let { urls ->
|
||||||
seriesRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach {
|
seriesRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach {
|
||||||
logger.info { "Deleting series not on disk anymore: $it" }
|
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)
|
val existingSeries = seriesRepository.findByLibraryIdAndUrl(library.id, newSeries.url)
|
||||||
|
|
||||||
// if series does not exist, save it
|
// if series does not exist, save it
|
||||||
if (existingSeries == null) {
|
if (existingSeries == null) {
|
||||||
logger.info { "Adding new series: $newSeries" }
|
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 {
|
} else {
|
||||||
// if series already exists, update it
|
// if series already exists, update it
|
||||||
if (newSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
|
if (newSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
|
||||||
logger.info { "Series changed on disk, updating: $existingSeries" }
|
logger.info { "Series changed on disk, updating: $existingSeries" }
|
||||||
existingSeries.fileLastModified = newSeries.fileLastModified
|
existingSeries.fileLastModified = newSeries.fileLastModified
|
||||||
|
|
||||||
|
seriesRepository.update(existingSeries)
|
||||||
|
|
||||||
// update list of books with existing entities if they exist
|
// update list of books with existing entities if they exist
|
||||||
existingSeries.books = newSeries.books.map { newBook ->
|
val existingBooks = bookRepository.findBySeriesId(existingSeries.id)
|
||||||
val existingBook = bookRepository.findByUrl(newBook.url) ?: newBook
|
|
||||||
|
|
||||||
if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
|
// update existing books
|
||||||
logger.info { "Book changed on disk, update and reset media status: $existingBook" }
|
newBooks.forEach { newBook ->
|
||||||
existingBook.fileLastModified = newBook.fileLastModified
|
existingBooks.find { it.url == newBook.url }?.let { existingBook ->
|
||||||
existingBook.fileSize = newBook.fileSize
|
if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
|
||||||
existingBook.media.reset()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package org.gotson.komga.domain.service
|
package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
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.BookMetadataPatch
|
||||||
import org.gotson.komga.domain.model.Series
|
import org.gotson.komga.domain.model.SeriesMetadata
|
||||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
|
@ -12,113 +12,32 @@ private val logger = KotlinLogging.logger {}
|
||||||
@Service
|
@Service
|
||||||
class MetadataApplier {
|
class MetadataApplier {
|
||||||
|
|
||||||
fun apply(patch: BookMetadataPatch, book: Book) {
|
private fun <T> getIfNotLocked(original: T, patched: T?, lock: Boolean): T =
|
||||||
logger.debug { "Apply metadata for book: $book" }
|
if (patched != null && !lock) patched
|
||||||
|
else original
|
||||||
|
|
||||||
with(book.metadata) {
|
fun apply(patch: BookMetadataPatch, metadata: BookMetadata): BookMetadata =
|
||||||
patch.title?.let {
|
with(metadata) {
|
||||||
if (!titleLock) {
|
copy(
|
||||||
logger.debug { "Update title: $it" }
|
title = getIfNotLocked(title, patch.title, titleLock),
|
||||||
title = it
|
summary = getIfNotLocked(summary, patch.summary, summaryLock),
|
||||||
} else
|
number = getIfNotLocked(number, patch.number, numberLock),
|
||||||
logger.debug { "title is locked, skipping" }
|
numberSort = getIfNotLocked(numberSort, patch.numberSort, numberSortLock),
|
||||||
}
|
readingDirection = getIfNotLocked(readingDirection, patch.readingDirection, readingDirectionLock),
|
||||||
|
releaseDate = getIfNotLocked(releaseDate, patch.releaseDate, releaseDateLock),
|
||||||
|
ageRating = getIfNotLocked(ageRating, patch.ageRating, ageRatingLock),
|
||||||
patch.summary?.let {
|
publisher = getIfNotLocked(publisher, patch.publisher, publisherLock),
|
||||||
if (!summaryLock) {
|
authors = getIfNotLocked(authors, patch.authors, authorsLock)
|
||||||
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: SeriesMetadataPatch, series: Series) {
|
fun apply(patch: SeriesMetadataPatch, metadata: SeriesMetadata): SeriesMetadata =
|
||||||
logger.debug { "Apply metadata for series: $series" }
|
with(metadata) {
|
||||||
|
copy(
|
||||||
with(series.metadata) {
|
status = getIfNotLocked(status, patch.status, statusLock),
|
||||||
patch.title?.let {
|
title = getIfNotLocked(title, patch.title, titleLock),
|
||||||
if (!titleLock) {
|
titleSort = getIfNotLocked(titleSort, patch.titleSort, titleSortLock)
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<BookMetadataProvider>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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<String> = 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<Book>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Book> =
|
||||||
|
dsl.selectFrom(b)
|
||||||
|
.where(b.SERIES_ID.eq(seriesId))
|
||||||
|
.fetchInto(b)
|
||||||
|
.map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findAll(): Collection<Book> =
|
||||||
|
dsl.select(*b.fields())
|
||||||
|
.from(b)
|
||||||
|
.fetchInto(b)
|
||||||
|
.map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findAll(bookSearch: BookSearch): Collection<Book> =
|
||||||
|
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<Long> =
|
||||||
|
dsl.select(b.ID)
|
||||||
|
.from(b)
|
||||||
|
.where(b.SERIES_ID.eq(seriesId))
|
||||||
|
.fetch(0, Long::class.java)
|
||||||
|
|
||||||
|
override fun findAllIdByLibraryId(libraryId: Long): Collection<Long> =
|
||||||
|
dsl.select(b.ID)
|
||||||
|
.from(b)
|
||||||
|
.where(b.LIBRARY_ID.eq(libraryId))
|
||||||
|
.fetch(0, Long::class.java)
|
||||||
|
|
||||||
|
override fun findAllId(bookSearch: BookSearch): Collection<Long> {
|
||||||
|
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<Long>) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<BookDto> {
|
||||||
|
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<Record>.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<BookMetadataAuthorRecord>) =
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<String> {
|
||||||
|
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<Author>) =
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<KomgaUser> =
|
||||||
|
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<Record>.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<KomgaUser>): Collection<KomgaUser> =
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -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<Library> =
|
||||||
|
dsl.selectFrom(l)
|
||||||
|
.fetchInto(l)
|
||||||
|
.map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findAllById(libraryIds: Collection<Long>): Collection<Library> =
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<BookPage>, files: List<String>) =
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Series> =
|
||||||
|
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<Series> =
|
||||||
|
dsl.selectFrom(s)
|
||||||
|
.where(s.LIBRARY_ID.eq(libraryId))
|
||||||
|
.fetchInto(s)
|
||||||
|
.map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection<URL>): List<Series> =
|
||||||
|
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<Series> {
|
||||||
|
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<Long>) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<SeriesDto> {
|
||||||
|
val conditions = search.toCondition()
|
||||||
|
|
||||||
|
return findAll(conditions, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRecentlyUpdated(search: SeriesSearch, pageable: Pageable): Page<SeriesDto> {
|
||||||
|
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<SeriesDto> {
|
||||||
|
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<Record>.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<String, Field<out Any>>): List<SortField<out Any>> =
|
||||||
|
this.mapNotNull {
|
||||||
|
val f = sorts[it.property]
|
||||||
|
if (it.isAscending) f?.asc() else f?.desc()
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@ package org.gotson.komga.infrastructure.metadata
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.Book
|
||||||
import org.gotson.komga.domain.model.BookMetadataPatch
|
import org.gotson.komga.domain.model.BookMetadataPatch
|
||||||
|
import org.gotson.komga.domain.model.Media
|
||||||
|
|
||||||
interface BookMetadataProvider {
|
interface BookMetadataProvider {
|
||||||
fun getBookMetadataFromBook(book: Book): BookMetadataPatch?
|
fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.Author
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.Book
|
||||||
import org.gotson.komga.domain.model.BookMetadata
|
import org.gotson.komga.domain.model.BookMetadata
|
||||||
import org.gotson.komga.domain.model.BookMetadataPatch
|
import org.gotson.komga.domain.model.BookMetadataPatch
|
||||||
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
||||||
import org.gotson.komga.domain.service.BookAnalyzer
|
import org.gotson.komga.domain.service.BookAnalyzer
|
||||||
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
||||||
|
|
@ -25,8 +26,8 @@ class ComicInfoProvider(
|
||||||
private val bookAnalyzer: BookAnalyzer
|
private val bookAnalyzer: BookAnalyzer
|
||||||
) : BookMetadataProvider {
|
) : BookMetadataProvider {
|
||||||
|
|
||||||
override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? {
|
override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? {
|
||||||
getComicInfo(book)?.let { comicInfo ->
|
getComicInfo(book, media)?.let { comicInfo ->
|
||||||
val releaseDate = comicInfo.year?.let {
|
val releaseDate = comicInfo.year?.let {
|
||||||
LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1)
|
LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1)
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +67,9 @@ class ComicInfoProvider(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getComicInfo(book: Book): ComicInfo? {
|
private fun getComicInfo(book: Book, media: Media): ComicInfo? {
|
||||||
try {
|
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" }
|
logger.debug { "Book does not contain any $COMIC_INFO file: $book" }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import org.gotson.komga.domain.model.Author
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.Book
|
||||||
import org.gotson.komga.domain.model.BookMetadata
|
import org.gotson.komga.domain.model.BookMetadata
|
||||||
import org.gotson.komga.domain.model.BookMetadataPatch
|
import org.gotson.komga.domain.model.BookMetadataPatch
|
||||||
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
||||||
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
|
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
|
||||||
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
||||||
|
|
@ -26,8 +27,8 @@ class EpubMetadataProvider(
|
||||||
"ill" to "penciller"
|
"ill" to "penciller"
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? {
|
override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? {
|
||||||
if (book.media.mediaType != "application/epub+zip") return null
|
if (media.mediaType != "application/epub+zip") return null
|
||||||
epubExtractor.getPackageFile(book.path())?.let { packageFile ->
|
epubExtractor.getPackageFile(book.path())?.let { packageFile ->
|
||||||
val opf = Jsoup.parse(packageFile.toString())
|
val opf = Jsoup.parse(packageFile.toString())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,10 @@ class KomgaPrincipal(
|
||||||
val user: KomgaUser
|
val user: KomgaUser
|
||||||
) : UserDetails {
|
) : UserDetails {
|
||||||
|
|
||||||
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
|
override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
|
||||||
return user.roles.map { it.name }
|
user.roles()
|
||||||
.toMutableSet()
|
|
||||||
.apply { add("USER") }
|
|
||||||
.map { SimpleGrantedAuthority("ROLE_$it") }
|
.map { SimpleGrantedAuthority("ROLE_$it") }
|
||||||
.toMutableSet()
|
.toMutableSet()
|
||||||
}
|
|
||||||
|
|
||||||
override fun isEnabled() = true
|
override fun isEnabled() = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
package org.gotson.komga.interfaces.opds
|
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 mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.Book
|
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.Library
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.Series
|
import org.gotson.komga.domain.model.Series
|
||||||
import org.gotson.komga.domain.model.SeriesMetadata
|
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.LibraryRepository
|
||||||
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
|
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.interfaces.opds.dto.OpdsAuthor
|
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.OpdsLinkRel
|
||||||
import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch
|
import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch
|
||||||
import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription
|
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.HttpHeaders
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
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])
|
@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
|
||||||
class OpdsController(
|
class OpdsController(
|
||||||
servletContext: ServletContext,
|
servletContext: ServletContext,
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
private val seriesRepository: SeriesRepository,
|
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"
|
private val routeBase = "${servletContext.contextPath}$ROUTE_BASE"
|
||||||
|
|
@ -129,23 +134,16 @@ class OpdsController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@RequestParam("search") searchTerm: String?
|
@RequestParam("search") searchTerm: String?
|
||||||
): OpdsFeed {
|
): OpdsFeed {
|
||||||
val sort = Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
|
val seriesSearch = SeriesSearch(
|
||||||
val series =
|
libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet(),
|
||||||
mutableListOf<Specification<Series>>().let { specs ->
|
searchTerm = searchTerm
|
||||||
if (!principal.user.sharedAllLibraries) {
|
)
|
||||||
specs.add(Series::library.`in`(principal.user.sharedLibraries))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchTerm.isNullOrEmpty()) {
|
val entries = seriesRepository.findAll(seriesSearch)
|
||||||
specs.add(Series::metadata.toJoin().where(SeriesMetadata::title).likeLower("%$searchTerm%"))
|
.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(
|
return OpdsFeedNavigation(
|
||||||
id = ID_SERIES_ALL,
|
id = ID_SERIES_ALL,
|
||||||
|
|
@ -156,7 +154,7 @@ class OpdsController(
|
||||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL"),
|
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL"),
|
||||||
linkStart
|
linkStart
|
||||||
),
|
),
|
||||||
entries = series.map { it.toOpdsEntry() }
|
entries = entries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,13 +162,14 @@ class OpdsController(
|
||||||
fun getLatestSeries(
|
fun getLatestSeries(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal
|
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||||
): OpdsFeed {
|
): OpdsFeed {
|
||||||
val sort = Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
val seriesSearch = SeriesSearch(
|
||||||
val series =
|
libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet()
|
||||||
if (principal.user.sharedAllLibraries) {
|
)
|
||||||
seriesRepository.findAll(sort)
|
|
||||||
} else {
|
val entries = seriesRepository.findAll(seriesSearch)
|
||||||
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, sort)
|
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
|
||||||
}
|
.sortedBy { it.series.lastModifiedDate }
|
||||||
|
.map { it.toOpdsEntry() }
|
||||||
|
|
||||||
return OpdsFeedNavigation(
|
return OpdsFeedNavigation(
|
||||||
id = ID_SERIES_LATEST,
|
id = ID_SERIES_LATEST,
|
||||||
|
|
@ -181,7 +180,7 @@ class OpdsController(
|
||||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST"),
|
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST"),
|
||||||
linkStart
|
linkStart
|
||||||
),
|
),
|
||||||
entries = series.map { it.toOpdsEntry() }
|
entries = entries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +192,7 @@ class OpdsController(
|
||||||
if (principal.user.sharedAllLibraries) {
|
if (principal.user.sharedAllLibraries) {
|
||||||
libraryRepository.findAll()
|
libraryRepository.findAll()
|
||||||
} else {
|
} else {
|
||||||
principal.user.sharedLibraries
|
libraryRepository.findAllById(principal.user.sharedLibrariesIds)
|
||||||
}
|
}
|
||||||
return OpdsFeedNavigation(
|
return OpdsFeedNavigation(
|
||||||
id = ID_LIBRARIES_ALL,
|
id = ID_LIBRARIES_ALL,
|
||||||
|
|
@ -217,19 +216,27 @@ class OpdsController(
|
||||||
seriesRepository.findByIdOrNull(id)?.let { series ->
|
seriesRepository.findByIdOrNull(id)?.let { series ->
|
||||||
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
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(
|
OpdsFeedAcquisition(
|
||||||
id = series.id.toString(),
|
id = series.id.toString(),
|
||||||
title = series.metadata.title,
|
title = metadata.title,
|
||||||
updated = series.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
author = komgaAuthor,
|
author = komgaAuthor,
|
||||||
links = listOf(
|
links = listOf(
|
||||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id"),
|
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id"),
|
||||||
linkStart
|
linkStart
|
||||||
),
|
),
|
||||||
entries = series.books
|
entries = entries
|
||||||
.filter { it.media.status == Media.Status.READY }
|
|
||||||
.sortedBy { it.metadata.numberSort }
|
|
||||||
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
|
|
||||||
)
|
)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
|
@ -241,53 +248,60 @@ class OpdsController(
|
||||||
libraryRepository.findByIdOrNull(id)?.let { library ->
|
libraryRepository.findByIdOrNull(id)?.let { library ->
|
||||||
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
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(
|
OpdsFeedNavigation(
|
||||||
id = library.id.toString(),
|
id = library.id.toString(),
|
||||||
title = library.name,
|
title = library.name,
|
||||||
updated = library.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
author = komgaAuthor,
|
author = komgaAuthor,
|
||||||
links = listOf(
|
links = listOf(
|
||||||
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id"),
|
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id"),
|
||||||
linkStart
|
linkStart
|
||||||
),
|
),
|
||||||
entries = seriesRepository.findByLibraryId(library.id, Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())).map { it.toOpdsEntry() }
|
entries = entries
|
||||||
)
|
)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
private fun Series.toOpdsEntry() =
|
private fun SeriesWithInfo.toOpdsEntry(): OpdsEntryNavigation =
|
||||||
OpdsEntryNavigation(
|
OpdsEntryNavigation(
|
||||||
title = metadata.title,
|
title = metadata.title,
|
||||||
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = id.toString(),
|
id = series.id.toString(),
|
||||||
content = "",
|
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 mediaTypes = media.pages.map { it.mediaType }.distinct()
|
||||||
|
|
||||||
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
|
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 {
|
} 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(
|
return OpdsEntryAcquisition(
|
||||||
title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
|
title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
|
||||||
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = id.toString(),
|
id = book.id.toString(),
|
||||||
content = run {
|
content = run {
|
||||||
var content = "${fileExtension().toUpperCase()} - ${fileSizeHumanReadable()}"
|
var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
|
||||||
if (metadata.summary.isNotBlank())
|
if (metadata.summary.isNotBlank())
|
||||||
content += "\n\n${metadata.summary}"
|
content += "\n\n${metadata.summary}"
|
||||||
content
|
content
|
||||||
},
|
},
|
||||||
authors = metadata.authors.map { OpdsAuthor(it.name) },
|
authors = metadata.authors.map { OpdsAuthor(it.name) },
|
||||||
links = listOf(
|
links = listOf(
|
||||||
OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/$id/thumbnail"),
|
OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/${book.id}/thumbnail"),
|
||||||
OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/$id/pages/1"),
|
OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/${book.id}/pages/1"),
|
||||||
OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/$id/file/${fileName()}"),
|
OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/${book.id}/file/${book.fileName()}"),
|
||||||
opdsLinkPageStreaming
|
opdsLinkPageStreaming
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -296,7 +310,7 @@ class OpdsController(
|
||||||
private fun Library.toOpdsEntry(): OpdsEntryNavigation {
|
private fun Library.toOpdsEntry(): OpdsEntryNavigation {
|
||||||
return OpdsEntryNavigation(
|
return OpdsEntryNavigation(
|
||||||
title = name,
|
title = name,
|
||||||
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
|
||||||
id = id.toString(),
|
id = id.toString(),
|
||||||
content = "",
|
content = "",
|
||||||
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
|
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
|
||||||
|
|
@ -305,4 +319,15 @@ class OpdsController(
|
||||||
|
|
||||||
private fun shouldPrependBookNumbers(userAgent: String) =
|
private fun shouldPrependBookNumbers(userAgent: String) =
|
||||||
userAgent.contains("chunky", ignoreCase = true)
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
package org.gotson.komga.interfaces.rest
|
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.Operation
|
||||||
import io.swagger.v3.oas.annotations.Parameter
|
import io.swagger.v3.oas.annotations.Parameter
|
||||||
import io.swagger.v3.oas.annotations.media.Content
|
import io.swagger.v3.oas.annotations.media.Content
|
||||||
import io.swagger.v3.oas.annotations.media.Schema
|
import io.swagger.v3.oas.annotations.media.Schema
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.application.service.BookLifecycle
|
|
||||||
import org.gotson.komga.application.tasks.TaskReceiver
|
import org.gotson.komga.application.tasks.TaskReceiver
|
||||||
import org.gotson.komga.domain.model.Author
|
import org.gotson.komga.domain.model.Author
|
||||||
import org.gotson.komga.domain.model.Book
|
import org.gotson.komga.domain.model.BookSearch
|
||||||
import org.gotson.komga.domain.model.BookMetadata
|
|
||||||
import org.gotson.komga.domain.model.ImageConversionException
|
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.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.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.image.ImageType
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
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.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.PageDto
|
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.core.io.FileSystemResource
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.domain.Sort
|
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.CacheControl
|
||||||
import org.springframework.http.ContentDisposition
|
import org.springframework.http.ContentDisposition
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
|
|
@ -58,7 +53,6 @@ import java.io.FileNotFoundException
|
||||||
import java.nio.file.NoSuchFileException
|
import java.nio.file.NoSuchFileException
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.persistence.criteria.JoinType
|
|
||||||
import javax.validation.Valid
|
import javax.validation.Valid
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
@ -66,9 +60,12 @@ private val logger = KotlinLogging.logger {}
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
|
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
class BookController(
|
class BookController(
|
||||||
private val bookRepository: BookRepository,
|
private val taskReceiver: TaskReceiver,
|
||||||
private val bookLifecycle: BookLifecycle,
|
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
|
@PageableAsQueryParam
|
||||||
|
|
@ -87,45 +84,21 @@ class BookController(
|
||||||
else Sort.by(Sort.Order.asc("metadata.title").ignoreCase())
|
else Sort.by(Sort.Order.asc("metadata.title").ignoreCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
return mutableListOf<Specification<Book>>().let { specs ->
|
val bookSearch = BookSearch(
|
||||||
when {
|
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
|
||||||
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries
|
searchTerm = searchTerm,
|
||||||
!principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> {
|
mediaStatus = mediaStatus ?: emptyList()
|
||||||
val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id })
|
)
|
||||||
if (authorizedLibraryIDs.isEmpty()) return@let Page.empty<Book>(pageRequest)
|
|
||||||
else specs.add(Book::series.toJoin().join(Series::library, JoinType.INNER).where(Library::id).`in`(authorizedLibraryIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// limited user: filter on user's authorized libraries
|
return bookDtoRepository.findAll(bookSearch, pageRequest)
|
||||||
!principal.user.sharedAllLibraries -> specs.add(Book::series.toJoin().where(Series::library).`in`(principal.user.sharedLibraries))
|
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
|
|
||||||
// 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()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Operation(description = "Return newly added or updated books.")
|
@Operation(description = "Return newly added or updated books.")
|
||||||
@PageableWithoutSortAsQueryParam
|
@PageableWithoutSortAsQueryParam
|
||||||
@GetMapping("api/v1/books/latest")
|
@GetMapping("api/v1/books/latest")
|
||||||
fun getLatestSeries(
|
fun getLatestBooks(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@Parameter(hidden = true) page: Pageable
|
@Parameter(hidden = true) page: Pageable
|
||||||
): Page<BookDto> {
|
): Page<BookDto> {
|
||||||
|
|
@ -135,11 +108,14 @@ class BookController(
|
||||||
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (principal.user.sharedAllLibraries) {
|
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
|
||||||
bookRepository.findAll(pageRequest)
|
|
||||||
} else {
|
return bookDtoRepository.findAll(
|
||||||
bookRepository.findBySeriesLibraryIn(principal.user.sharedLibraries, pageRequest)
|
BookSearch(
|
||||||
}.map { it.toDto(includeFullUrl = principal.user.isAdmin()) }
|
libraryIds = libraryIds
|
||||||
|
),
|
||||||
|
pageRequest
|
||||||
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,42 +124,39 @@ class BookController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): BookDto =
|
): BookDto =
|
||||||
bookRepository.findByIdOrNull(bookId)?.let {
|
bookDtoRepository.findByIdOrNull(bookId)?.let {
|
||||||
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
it.toDto(includeFullUrl = principal.user.isAdmin())
|
it.restrictUrl(!principal.user.roleAdmin)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@GetMapping("api/v1/books/{bookId}/previous")
|
@GetMapping("api/v1/books/{bookId}/previous")
|
||||||
fun getBookSiblingPrevious(
|
fun getBookSiblingPrevious(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): BookDto =
|
): BookDto {
|
||||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
bookRepository.getLibraryId(bookId)?.let {
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) 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)
|
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
return bookDtoRepository.findPreviousInSeries(bookId)
|
||||||
|
?.restrictUrl(!principal.user.roleAdmin)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("api/v1/books/{bookId}/next")
|
@GetMapping("api/v1/books/{bookId}/next")
|
||||||
fun getBookSiblingNext(
|
fun getBookSiblingNext(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): BookDto =
|
): BookDto {
|
||||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
bookRepository.getLibraryId(bookId)?.let {
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) 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)
|
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: 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"))])
|
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||||
@GetMapping(value = [
|
@GetMapping(value = [
|
||||||
|
|
@ -193,16 +166,18 @@ class BookController(
|
||||||
fun getBookThumbnail(
|
fun getBookThumbnail(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): ResponseEntity<ByteArray> =
|
): ResponseEntity<ByteArray> {
|
||||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
bookRepository.getLibraryId(bookId)?.let {
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
if (book.media.thumbnail != null) {
|
|
||||||
ResponseEntity.ok()
|
|
||||||
.setCachePrivate()
|
|
||||||
.body(book.media.thumbnail)
|
|
||||||
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: 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.")
|
@Operation(description = "Download the book file.")
|
||||||
@GetMapping(value = [
|
@GetMapping(value = [
|
||||||
"api/v1/books/{bookId}/file",
|
"api/v1/books/{bookId}/file",
|
||||||
|
|
@ -216,6 +191,7 @@ class BookController(
|
||||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
try {
|
try {
|
||||||
|
val media = mediaRepository.findById(book.id)
|
||||||
with(FileSystemResource(book.path())) {
|
with(FileSystemResource(book.path())) {
|
||||||
if (!exists()) throw FileNotFoundException(path)
|
if (!exists()) throw FileNotFoundException(path)
|
||||||
ResponseEntity.ok()
|
ResponseEntity.ok()
|
||||||
|
|
@ -224,7 +200,7 @@ class BookController(
|
||||||
.filename(book.fileName())
|
.filename(book.fileName())
|
||||||
.build()
|
.build()
|
||||||
})
|
})
|
||||||
.contentType(getMediaTypeOrDefault(book.media.mediaType))
|
.contentType(getMediaTypeOrDefault(media.mediaType))
|
||||||
.body(this)
|
.body(this)
|
||||||
}
|
}
|
||||||
} catch (ex: FileNotFoundException) {
|
} catch (ex: FileNotFoundException) {
|
||||||
|
|
@ -239,12 +215,14 @@ class BookController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable bookId: Long
|
@PathVariable bookId: Long
|
||||||
): List<PageDto> =
|
): List<PageDto> =
|
||||||
bookRepository.findByIdOrNull((bookId))?.let {
|
bookRepository.findByIdOrNull((bookId))?.let { book ->
|
||||||
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessBook(book)) 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")
|
|
||||||
|
|
||||||
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)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@ApiResponse(content = [Content(
|
@ApiResponse(content = [Content(
|
||||||
|
|
@ -266,10 +244,11 @@ class BookController(
|
||||||
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
|
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
|
||||||
): ResponseEntity<ByteArray> =
|
): ResponseEntity<ByteArray> =
|
||||||
bookRepository.findByIdOrNull((bookId))?.let { book ->
|
bookRepository.findByIdOrNull((bookId))?.let { book ->
|
||||||
if (request.checkNotModified(getBookLastModified(book))) {
|
val media = mediaRepository.findById(bookId)
|
||||||
|
if (request.checkNotModified(getBookLastModified(media))) {
|
||||||
return@let ResponseEntity
|
return@let ResponseEntity
|
||||||
.status(HttpStatus.NOT_MODIFIED)
|
.status(HttpStatus.NOT_MODIFIED)
|
||||||
.setNotModified(book)
|
.setNotModified(media)
|
||||||
.body(ByteArray(0))
|
.body(ByteArray(0))
|
||||||
}
|
}
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
@ -287,7 +266,7 @@ class BookController(
|
||||||
|
|
||||||
ResponseEntity.ok()
|
ResponseEntity.ok()
|
||||||
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
|
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
|
||||||
.setNotModified(book)
|
.setNotModified(media)
|
||||||
.body(pageContent.content)
|
.body(pageContent.content)
|
||||||
} catch (ex: IndexOutOfBoundsException) {
|
} catch (ex: IndexOutOfBoundsException) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
|
||||||
|
|
@ -313,10 +292,11 @@ class BookController(
|
||||||
@PathVariable pageNumber: Int
|
@PathVariable pageNumber: Int
|
||||||
): ResponseEntity<ByteArray> =
|
): ResponseEntity<ByteArray> =
|
||||||
bookRepository.findByIdOrNull((bookId))?.let { book ->
|
bookRepository.findByIdOrNull((bookId))?.let { book ->
|
||||||
if (request.checkNotModified(getBookLastModified(book))) {
|
val media = mediaRepository.findById(bookId)
|
||||||
|
if (request.checkNotModified(getBookLastModified(media))) {
|
||||||
return@let ResponseEntity
|
return@let ResponseEntity
|
||||||
.status(HttpStatus.NOT_MODIFIED)
|
.status(HttpStatus.NOT_MODIFIED)
|
||||||
.setNotModified(book)
|
.setNotModified(media)
|
||||||
.body(ByteArray(0))
|
.body(ByteArray(0))
|
||||||
}
|
}
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
@ -325,7 +305,7 @@ class BookController(
|
||||||
|
|
||||||
ResponseEntity.ok()
|
ResponseEntity.ok()
|
||||||
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
|
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
|
||||||
.setNotModified(book)
|
.setNotModified(media)
|
||||||
.body(pageContent.content)
|
.body(pageContent.content)
|
||||||
} catch (ex: IndexOutOfBoundsException) {
|
} catch (ex: IndexOutOfBoundsException) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
|
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.")
|
@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
|
@Valid @RequestBody newMetadata: BookMetadataUpdateDto
|
||||||
): BookDto =
|
): BookDto =
|
||||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
|
||||||
with(newMetadata) {
|
val updated = with(newMetadata) {
|
||||||
title?.let { book.metadata.title = it }
|
existing.copy(
|
||||||
titleLock?.let { book.metadata.titleLock = it }
|
title = title ?: existing.title,
|
||||||
summary?.let { book.metadata.summary = it }
|
titleLock = titleLock ?: existing.titleLock,
|
||||||
summaryLock?.let { book.metadata.summaryLock = it }
|
summary = summary ?: existing.summary,
|
||||||
number?.let { book.metadata.number = it }
|
summaryLock = summaryLock ?: existing.summaryLock,
|
||||||
numberLock?.let { book.metadata.numberLock = it }
|
number = number ?: existing.number,
|
||||||
numberSort?.let { book.metadata.numberSort = it }
|
numberLock = numberLock ?: existing.numberLock,
|
||||||
numberSortLock?.let { book.metadata.numberSortLock = it }
|
numberSort = numberSort ?: existing.numberSort,
|
||||||
if (isSet("readingDirection")) book.metadata.readingDirection = newMetadata.readingDirection
|
numberSortLock = numberSortLock ?: existing.numberSortLock,
|
||||||
readingDirectionLock?.let { book.metadata.readingDirectionLock = it }
|
readingDirection = if (isSet("readingDirection")) readingDirection else existing.readingDirection,
|
||||||
publisher?.let { book.metadata.publisher = it }
|
readingDirectionLock = readingDirectionLock ?: existing.readingDirectionLock,
|
||||||
publisherLock?.let { book.metadata.publisherLock = it }
|
publisher = publisher ?: existing.publisher,
|
||||||
if (isSet("ageRating")) book.metadata.ageRating = newMetadata.ageRating
|
publisherLock = publisherLock ?: existing.publisherLock,
|
||||||
ageRatingLock?.let { book.metadata.ageRatingLock = it }
|
ageRating = if (isSet("ageRating")) ageRating else existing.ageRating,
|
||||||
if (isSet("releaseDate")) {
|
ageRatingLock = ageRatingLock ?: existing.ageRatingLock,
|
||||||
book.metadata.releaseDate = newMetadata.releaseDate
|
releaseDate = if (isSet("releaseDate")) releaseDate else existing.releaseDate,
|
||||||
}
|
releaseDateLock = releaseDateLock ?: existing.releaseDateLock,
|
||||||
releaseDateLock?.let { book.metadata.releaseDateLock = it }
|
authors = if (isSet("authors")) {
|
||||||
if (isSet("authors")) {
|
if (authors != null) authors!!.map { Author(it.name ?: "", it.role ?: "") } else emptyList()
|
||||||
if (authors != null) {
|
} else existing.authors,
|
||||||
book.metadata.authors = authors!!.map {
|
authorsLock = authorsLock ?: existing.authorsLock
|
||||||
Author(it.name ?: "", it.role ?: "")
|
)
|
||||||
}.toMutableList()
|
|
||||||
} else book.metadata.authors = mutableListOf()
|
|
||||||
}
|
|
||||||
authorsLock?.let { book.metadata.authorsLock = it }
|
|
||||||
}
|
}
|
||||||
bookRepository.save(book).toDto(includeFullUrl = true)
|
bookMetadataRepository.update(updated)
|
||||||
|
bookDtoRepository.findByIdOrNull(bookId)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
private fun ResponseEntity.BodyBuilder.setCachePrivate() =
|
private fun ResponseEntity.BodyBuilder.setCachePrivate() =
|
||||||
|
|
@ -402,11 +379,11 @@ class BookController(
|
||||||
.mustRevalidate()
|
.mustRevalidate()
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) =
|
private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) =
|
||||||
this.setCachePrivate().lastModified(getBookLastModified(book))
|
this.setCachePrivate().lastModified(getBookLastModified(media))
|
||||||
|
|
||||||
private fun getBookLastModified(book: Book) =
|
private fun getBookLastModified(media: Media) =
|
||||||
book.media.lastModifiedDate!!.toInstant(ZoneOffset.UTC).toEpochMilli()
|
media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli()
|
||||||
|
|
||||||
|
|
||||||
private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {
|
private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
package org.gotson.komga.interfaces.rest
|
package org.gotson.komga.interfaces.rest
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.UserRoles
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.domain.service.KomgaUserLifecycle
|
||||||
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle
|
import org.gotson.komga.interfaces.rest.dto.UserDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.core.userdetails.User
|
|
||||||
import org.springframework.validation.annotation.Validated
|
import org.springframework.validation.annotation.Validated
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestHeader
|
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])
|
@RequestMapping("api/v1/claim", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
@Validated
|
@Validated
|
||||||
class ClaimController(
|
class ClaimController(
|
||||||
private val userDetailsLifecycle: KomgaUserDetailsLifecycle
|
private val userDetailsLifecycle: KomgaUserLifecycle
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun claimAdmin(
|
fun claimAdmin(
|
||||||
|
|
@ -32,11 +31,12 @@ class ClaimController(
|
||||||
if (userDetailsLifecycle.countUsers() > 0)
|
if (userDetailsLifecycle.countUsers() > 0)
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed")
|
||||||
|
|
||||||
return (userDetailsLifecycle.createUser(
|
return userDetailsLifecycle.createUser(
|
||||||
User.withUsername(email)
|
KomgaUser(
|
||||||
.password(password)
|
email = email,
|
||||||
.roles(UserRoles.ADMIN.name)
|
password = password,
|
||||||
.build()) as KomgaPrincipal
|
roleAdmin = true
|
||||||
).toDto()
|
)
|
||||||
|
).toDto()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package org.gotson.komga.interfaces.rest
|
package org.gotson.komga.interfaces.rest
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.application.service.LibraryLifecycle
|
|
||||||
import org.gotson.komga.application.tasks.TaskReceiver
|
import org.gotson.komga.application.tasks.TaskReceiver
|
||||||
import org.gotson.komga.domain.model.DirectoryNotFoundException
|
import org.gotson.komga.domain.model.DirectoryNotFoundException
|
||||||
import org.gotson.komga.domain.model.DuplicateNameException
|
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.model.PathContainedInPath
|
||||||
import org.gotson.komga.domain.persistence.BookRepository
|
import org.gotson.komga.domain.persistence.BookRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
|
import org.gotson.komga.domain.service.LibraryLifecycle
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
|
@ -33,10 +32,10 @@ private val logger = KotlinLogging.logger {}
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE])
|
@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
class LibraryController(
|
class LibraryController(
|
||||||
|
private val taskReceiver: TaskReceiver,
|
||||||
private val libraryLifecycle: LibraryLifecycle,
|
private val libraryLifecycle: LibraryLifecycle,
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository
|
||||||
private val taskReceiver: TaskReceiver
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|
@ -46,8 +45,8 @@ class LibraryController(
|
||||||
if (principal.user.sharedAllLibraries) {
|
if (principal.user.sharedAllLibraries) {
|
||||||
libraryRepository.findAll()
|
libraryRepository.findAll()
|
||||||
} else {
|
} else {
|
||||||
principal.user.sharedLibraries
|
libraryRepository.findAllById(principal.user.sharedLibrariesIds)
|
||||||
}.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.isAdmin()) }
|
}.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.roleAdmin) }
|
||||||
|
|
||||||
@GetMapping("{id}")
|
@GetMapping("{id}")
|
||||||
fun getOne(
|
fun getOne(
|
||||||
|
|
@ -56,7 +55,7 @@ class LibraryController(
|
||||||
): LibraryDto =
|
): LibraryDto =
|
||||||
libraryRepository.findByIdOrNull(id)?.let {
|
libraryRepository.findByIdOrNull(id)?.let {
|
||||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
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)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -66,7 +65,7 @@ class LibraryController(
|
||||||
@Valid @RequestBody library: LibraryCreationDto
|
@Valid @RequestBody library: LibraryCreationDto
|
||||||
): LibraryDto =
|
): LibraryDto =
|
||||||
try {
|
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) {
|
} catch (e: Exception) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is FileNotFoundException,
|
is FileNotFoundException,
|
||||||
|
|
@ -92,7 +91,7 @@ class LibraryController(
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
fun scan(@PathVariable libraryId: Long) {
|
fun scan(@PathVariable libraryId: Long) {
|
||||||
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
|
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
|
||||||
taskReceiver.scanLibrary(library)
|
taskReceiver.scanLibrary(library.id)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,18 +99,18 @@ class LibraryController(
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
fun analyze(@PathVariable libraryId: Long) {
|
fun analyze(@PathVariable libraryId: Long) {
|
||||||
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
|
bookRepository.findAllIdByLibraryId(libraryId).forEach {
|
||||||
bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.analyzeBook(it) }
|
taskReceiver.analyzeBook(it)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("{libraryId}/metadata/refresh")
|
@PostMapping("{libraryId}/metadata/refresh")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
fun refreshMetadata(@PathVariable libraryId: Long) {
|
fun refreshMetadata(@PathVariable libraryId: Long) {
|
||||||
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
|
bookRepository.findAllIdByLibraryId(libraryId).forEach {
|
||||||
bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.refreshBookMetadata(it) }
|
taskReceiver.refreshBookMetadata(it)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package org.gotson.komga.interfaces.rest
|
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.Operation
|
||||||
import io.swagger.v3.oas.annotations.Parameter
|
import io.swagger.v3.oas.annotations.Parameter
|
||||||
import io.swagger.v3.oas.annotations.media.Content
|
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 io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.gotson.komga.application.tasks.TaskReceiver
|
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.Media
|
||||||
import org.gotson.komga.domain.model.Series
|
|
||||||
import org.gotson.komga.domain.model.SeriesMetadata
|
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.BookRepository
|
||||||
|
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
|
||||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
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.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.SeriesDto
|
import org.gotson.komga.interfaces.rest.dto.SeriesDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||||
import org.springdoc.api.annotations.ParameterObject
|
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.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.domain.Sort
|
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.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
|
|
@ -52,19 +49,23 @@ private val logger = KotlinLogging.logger {}
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE])
|
@RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
class SeriesController(
|
class SeriesController(
|
||||||
|
private val taskReceiver: TaskReceiver,
|
||||||
private val seriesRepository: SeriesRepository,
|
private val seriesRepository: SeriesRepository,
|
||||||
|
private val seriesMetadataRepository: SeriesMetadataRepository,
|
||||||
|
private val seriesDtoRepository: SeriesDtoRepository,
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
private val bookController: BookController,
|
private val bookDtoRepository: BookDtoRepository,
|
||||||
private val taskReceiver: TaskReceiver
|
private val bookController: BookController
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@PageableAsQueryParam
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getAllSeries(
|
fun getAllSeries(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@RequestParam(name = "search", required = false) searchTerm: String?,
|
@RequestParam(name = "search", required = false) searchTerm: String?,
|
||||||
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
|
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
|
||||||
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
|
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
|
||||||
@ParameterObject page: Pageable
|
@Parameter(hidden = true) page: Pageable
|
||||||
): Page<SeriesDto> {
|
): Page<SeriesDto> {
|
||||||
val pageRequest = PageRequest.of(
|
val pageRequest = PageRequest.of(
|
||||||
page.pageNumber,
|
page.pageNumber,
|
||||||
|
|
@ -73,38 +74,14 @@ class SeriesController(
|
||||||
else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
|
else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
return mutableListOf<Specification<Series>>().let { specs ->
|
val seriesSearch = SeriesSearch(
|
||||||
when {
|
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
|
||||||
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries
|
searchTerm = searchTerm,
|
||||||
!principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> {
|
metadataStatus = metadataStatus ?: emptyList()
|
||||||
val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id })
|
)
|
||||||
if (authorizedLibraryIDs.isEmpty()) return@let Page.empty<Series>(pageRequest)
|
|
||||||
else specs.add(Series::library.toJoin().where(Library::id).`in`(authorizedLibraryIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// limited user: filter on user's authorized libraries
|
return seriesDtoRepository.findAll(seriesSearch, pageRequest)
|
||||||
!principal.user.sharedAllLibraries -> specs.add(Series::library.`in`(principal.user.sharedLibraries))
|
.map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
|
|
||||||
// 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()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(description = "Return recently added or updated series.")
|
@Operation(description = "Return recently added or updated series.")
|
||||||
|
|
@ -120,11 +97,12 @@ class SeriesController(
|
||||||
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (principal.user.sharedAllLibraries) {
|
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
|
||||||
seriesRepository.findAll(pageRequest)
|
|
||||||
} else {
|
return seriesDtoRepository.findAll(
|
||||||
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest)
|
SeriesSearch(libraryIds = libraryIds),
|
||||||
}.map { it.toDto(includeUrl = principal.user.isAdmin()) }
|
pageRequest
|
||||||
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(description = "Return newly added series.")
|
@Operation(description = "Return newly added series.")
|
||||||
|
|
@ -140,11 +118,12 @@ class SeriesController(
|
||||||
Sort.by(Sort.Direction.DESC, "createdDate")
|
Sort.by(Sort.Direction.DESC, "createdDate")
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (principal.user.sharedAllLibraries) {
|
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
|
||||||
seriesRepository.findAll(pageRequest)
|
|
||||||
} else {
|
return seriesDtoRepository.findAll(
|
||||||
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest)
|
SeriesSearch(libraryIds = libraryIds),
|
||||||
}.map { it.toDto(includeUrl = principal.user.isAdmin()) }
|
pageRequest
|
||||||
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(description = "Return recently updated series, but not newly added ones.")
|
@Operation(description = "Return recently updated series, but not newly added ones.")
|
||||||
|
|
@ -160,11 +139,12 @@ class SeriesController(
|
||||||
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (principal.user.sharedAllLibraries) {
|
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
|
||||||
seriesRepository.findRecentlyUpdated(pageRequest)
|
|
||||||
} else {
|
return seriesDtoRepository.findRecentlyUpdated(
|
||||||
seriesRepository.findRecentlyUpdatedByLibraryIn(principal.user.sharedLibraries, pageRequest)
|
SeriesSearch(libraryIds = libraryIds),
|
||||||
}.map { it.toDto(includeUrl = principal.user.isAdmin()) }
|
pageRequest
|
||||||
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("{seriesId}")
|
@GetMapping("{seriesId}")
|
||||||
|
|
@ -172,35 +152,36 @@ class SeriesController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable(name = "seriesId") id: Long
|
@PathVariable(name = "seriesId") id: Long
|
||||||
): SeriesDto =
|
): SeriesDto =
|
||||||
seriesRepository.findByIdOrNull(id)?.let {
|
seriesDtoRepository.findByIdOrNull(id)?.let {
|
||||||
if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
it.toDto(includeUrl = principal.user.isAdmin())
|
it.restrictUrl(!principal.user.roleAdmin)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
|
||||||
@GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
|
@GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
|
||||||
fun getSeriesThumbnail(
|
fun getSeriesThumbnail(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable(name = "seriesId") id: Long
|
@PathVariable(name = "seriesId") seriesId: Long
|
||||||
): ResponseEntity<ByteArray> =
|
): ResponseEntity<ByteArray> {
|
||||||
seriesRepository.findByIdOrNull(id)?.let { series ->
|
seriesRepository.getLibraryId(seriesId)?.let {
|
||||||
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
series.books.minBy { it.metadata.numberSort }?.let { firstBook ->
|
|
||||||
bookController.getBookThumbnail(principal, firstBook.id)
|
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
return bookRepository.findFirstIdInSeries(seriesId)?.let {
|
||||||
|
bookController.getBookThumbnail(principal, it)
|
||||||
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
@PageableAsQueryParam
|
@PageableAsQueryParam
|
||||||
@GetMapping("{seriesId}/books")
|
@GetMapping("{seriesId}/books")
|
||||||
fun getAllBooksBySeries(
|
fun getAllBooksBySeries(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||||
@PathVariable(name = "seriesId") id: Long,
|
@PathVariable(name = "seriesId") seriesId: Long,
|
||||||
@RequestParam(name = "media_status", required = false) mediaStatus: List<Media.Status>?,
|
@RequestParam(name = "media_status", required = false) mediaStatus: List<Media.Status>?,
|
||||||
@Parameter(hidden = true) page: Pageable
|
@Parameter(hidden = true) page: Pageable
|
||||||
): Page<BookDto> {
|
): Page<BookDto> {
|
||||||
seriesRepository.findByIdOrNull(id)?.let {
|
seriesRepository.getLibraryId(seriesId)?.let {
|
||||||
if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
val pageRequest = PageRequest.of(
|
val pageRequest = PageRequest.of(
|
||||||
|
|
@ -210,28 +191,31 @@ class SeriesController(
|
||||||
else Sort.by(Sort.Order.asc("metadata.numberSort"))
|
else Sort.by(Sort.Order.asc("metadata.numberSort"))
|
||||||
)
|
)
|
||||||
|
|
||||||
return (if (!mediaStatus.isNullOrEmpty())
|
return bookDtoRepository.findAll(
|
||||||
bookRepository.findAllByMediaStatusInAndSeriesId(mediaStatus, id, pageRequest)
|
BookSearch(
|
||||||
else
|
seriesIds = listOf(seriesId),
|
||||||
bookRepository.findAllBySeriesId(id, pageRequest)).map { it.toDto(includeFullUrl = principal.user.isAdmin()) }
|
mediaStatus = mediaStatus ?: emptyList()
|
||||||
|
),
|
||||||
|
pageRequest
|
||||||
|
).map { it.restrictUrl(!principal.user.roleAdmin) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("{seriesId}/analyze")
|
@PostMapping("{seriesId}/analyze")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
fun analyze(@PathVariable seriesId: Long) {
|
fun analyze(@PathVariable seriesId: Long) {
|
||||||
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
|
bookRepository.findAllIdBySeriesId(seriesId).forEach {
|
||||||
series.books.forEach { taskReceiver.analyzeBook(it) }
|
taskReceiver.analyzeBook(it)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("{seriesId}/metadata/refresh")
|
@PostMapping("{seriesId}/metadata/refresh")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
fun refreshMetadata(@PathVariable seriesId: Long) {
|
fun refreshMetadata(@PathVariable seriesId: Long) {
|
||||||
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
|
bookRepository.findAllIdBySeriesId(seriesId).forEach {
|
||||||
series.books.forEach { taskReceiver.refreshBookMetadata(it) }
|
taskReceiver.refreshBookMetadata(it)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("{seriesId}/metadata")
|
@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.")
|
@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
|
@Valid @RequestBody newMetadata: SeriesMetadataUpdateDto
|
||||||
): SeriesDto =
|
): SeriesDto =
|
||||||
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
|
seriesMetadataRepository.findByIdOrNull(seriesId)?.let { existing ->
|
||||||
with(newMetadata) {
|
val updated = with(newMetadata) {
|
||||||
status?.let { series.metadata.status = it }
|
existing.copy(
|
||||||
statusLock?.let { series.metadata.statusLock = it }
|
status = status ?: existing.status,
|
||||||
title?.let { series.metadata.title = it }
|
statusLock = statusLock ?: existing.statusLock,
|
||||||
titleLock?.let { series.metadata.titleLock = it }
|
title = title ?: existing.title,
|
||||||
titleSort?.let { series.metadata.titleSort = it }
|
titleLock = titleLock ?: existing.titleLock,
|
||||||
titleSortLock?.let { series.metadata.titleSortLock = it }
|
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)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
package org.gotson.komga.interfaces.rest
|
package org.gotson.komga.interfaces.rest
|
||||||
|
|
||||||
import mu.KotlinLogging
|
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.KomgaUserRepository
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
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.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle
|
import org.gotson.komga.interfaces.rest.dto.PasswordUpdateDto
|
||||||
import org.gotson.komga.infrastructure.security.UserEmailAlreadyExistsException
|
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.toDto
|
||||||
|
import org.gotson.komga.interfaces.rest.dto.toWithSharedLibrariesDto
|
||||||
import org.springframework.core.env.Environment
|
import org.springframework.core.env.Environment
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
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.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
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.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import javax.validation.Valid
|
import javax.validation.Valid
|
||||||
import javax.validation.constraints.Email
|
|
||||||
import javax.validation.constraints.NotBlank
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE])
|
@RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
class UserController(
|
class UserController(
|
||||||
private val userDetailsLifecycle: KomgaUserDetailsLifecycle,
|
private val userLifecycle: KomgaUserLifecycle,
|
||||||
private val userRepository: KomgaUserRepository,
|
private val userRepository: KomgaUserRepository,
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
env: Environment
|
env: Environment
|
||||||
|
|
@ -54,7 +54,7 @@ class UserController(
|
||||||
@Valid @RequestBody newPasswordDto: PasswordUpdateDto
|
@Valid @RequestBody newPasswordDto: PasswordUpdateDto
|
||||||
) {
|
) {
|
||||||
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||||
userDetailsLifecycle.updatePassword(principal, newPasswordDto.password, false)
|
userLifecycle.updatePassword(principal, newPasswordDto.password, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|
@ -67,7 +67,7 @@ class UserController(
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto =
|
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto =
|
||||||
try {
|
try {
|
||||||
(userDetailsLifecycle.createUser(newUser.toUserDetails()) as KomgaPrincipal).toDto()
|
userLifecycle.createUser(newUser.toDomain()).toDto()
|
||||||
} catch (e: UserEmailAlreadyExistsException) {
|
} catch (e: UserEmailAlreadyExistsException) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +80,7 @@ class UserController(
|
||||||
@AuthenticationPrincipal principal: KomgaPrincipal
|
@AuthenticationPrincipal principal: KomgaPrincipal
|
||||||
) {
|
) {
|
||||||
userRepository.findByIdOrNull(id)?.let {
|
userRepository.findByIdOrNull(id)?.let {
|
||||||
userDetailsLifecycle.deleteUser(it)
|
userLifecycle.deleteUser(it)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,63 +92,15 @@ class UserController(
|
||||||
@Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto
|
@Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto
|
||||||
) {
|
) {
|
||||||
userRepository.findByIdOrNull(id)?.let { user ->
|
userRepository.findByIdOrNull(id)?.let { user ->
|
||||||
if (sharedLibrariesUpdateDto.all) {
|
val updatedUser = user.copy(
|
||||||
user.sharedAllLibraries = true
|
sharedAllLibraries = sharedLibrariesUpdateDto.all,
|
||||||
user.sharedLibraries = mutableSetOf()
|
sharedLibrariesIds = if (sharedLibrariesUpdateDto.all) emptySet()
|
||||||
} else {
|
else libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds)
|
||||||
user.sharedAllLibraries = false
|
.map { it.id }
|
||||||
user.sharedLibraries = libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds).toMutableSet()
|
.toSet()
|
||||||
}
|
)
|
||||||
userRepository.save(user)
|
userRepository.save(updatedUser)
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class UserDto(
|
|
||||||
val id: Long,
|
|
||||||
val email: String,
|
|
||||||
val roles: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserWithSharedLibrariesDto(
|
|
||||||
val id: Long,
|
|
||||||
val email: String,
|
|
||||||
val roles: List<String>,
|
|
||||||
val sharedAllLibraries: Boolean,
|
|
||||||
val sharedLibraries: List<SharedLibraryDto>
|
|
||||||
)
|
|
||||||
|
|
||||||
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<String> = 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<Long>
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,33 @@
|
||||||
package org.gotson.komga.interfaces.rest.dto
|
package org.gotson.komga.interfaces.rest.dto
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat
|
import com.fasterxml.jackson.annotation.JsonFormat
|
||||||
|
import com.jakewharton.byteunits.BinaryByteUnit
|
||||||
import org.apache.commons.io.FilenameUtils
|
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.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class BookDto(
|
data class BookDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val seriesId: Long,
|
val seriesId: Long,
|
||||||
|
val libraryId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val number: Int,
|
val number: Int,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val created: LocalDateTime?,
|
val created: LocalDateTime,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val lastModified: LocalDateTime?,
|
val lastModified: LocalDateTime,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val fileLastModified: LocalDateTime,
|
val fileLastModified: LocalDateTime,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val size: String,
|
val size: String = BinaryByteUnit.format(sizeBytes),
|
||||||
val media: MediaDto,
|
val media: MediaDto,
|
||||||
val metadata: BookMetadataDto
|
val metadata: BookMetadataDto
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun BookDto.restrictUrl(restrict: Boolean) =
|
||||||
|
if (restrict) copy(url = FilenameUtils.getName(url)) else this
|
||||||
|
|
||||||
data class MediaDto(
|
data class MediaDto(
|
||||||
val status: String,
|
val status: String,
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
|
|
@ -61,48 +62,3 @@ data class AuthorDto(
|
||||||
val role: String
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package org.gotson.komga.interfaces.rest.dto
|
package org.gotson.komga.interfaces.rest.dto
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat
|
import com.fasterxml.jackson.annotation.JsonFormat
|
||||||
import org.gotson.komga.domain.model.Series
|
|
||||||
import org.gotson.komga.domain.model.SeriesMetadata
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class SeriesDto(
|
data class SeriesDto(
|
||||||
|
|
@ -11,47 +9,27 @@ data class SeriesDto(
|
||||||
val name: String,
|
val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val created: LocalDateTime?,
|
val created: LocalDateTime,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val lastModified: LocalDateTime?,
|
val lastModified: LocalDateTime,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val fileLastModified: LocalDateTime,
|
val fileLastModified: LocalDateTime,
|
||||||
val booksCount: Int,
|
val booksCount: Int,
|
||||||
val metadata: SeriesMetadataDto
|
val metadata: SeriesMetadataDto
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun SeriesDto.restrictUrl(restrict: Boolean) =
|
||||||
|
if (restrict) copy(url = "") else this
|
||||||
|
|
||||||
data class SeriesMetadataDto(
|
data class SeriesMetadataDto(
|
||||||
val status: String,
|
val status: String,
|
||||||
val statusLock: Boolean,
|
val statusLock: Boolean,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val created: LocalDateTime?,
|
val created: LocalDateTime,
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
val lastModified: LocalDateTime?,
|
val lastModified: LocalDateTime,
|
||||||
val title: String,
|
val title: String,
|
||||||
val titleLock: Boolean,
|
val titleLock: Boolean,
|
||||||
val titleSort: String,
|
val titleSort: String,
|
||||||
val titleSortLock: Boolean
|
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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,59 @@ package org.gotson.komga.interfaces.rest.dto
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.KomgaUser
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
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<String>
|
||||||
|
)
|
||||||
|
|
||||||
fun KomgaUser.toDto() =
|
fun KomgaUser.toDto() =
|
||||||
UserDto(
|
UserDto(
|
||||||
id = id,
|
id = id,
|
||||||
email = email,
|
email = email,
|
||||||
roles = roles.map { it.name }
|
roles = roles().toList()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun KomgaPrincipal.toDto() = user.toDto()
|
fun KomgaPrincipal.toDto() = user.toDto()
|
||||||
|
|
||||||
|
data class UserWithSharedLibrariesDto(
|
||||||
|
val id: Long,
|
||||||
|
val email: String,
|
||||||
|
val roles: List<String>,
|
||||||
|
val sharedAllLibraries: Boolean,
|
||||||
|
val sharedLibraries: List<SharedLibraryDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<String> = 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<Long>
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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<BookDto>
|
||||||
|
fun findByIdOrNull(bookId: Long): BookDto?
|
||||||
|
fun findPreviousInSeries(bookId: Long): BookDto?
|
||||||
|
fun findNextInSeries(bookId: Long): BookDto?
|
||||||
|
}
|
||||||
|
|
@ -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<SeriesDto>
|
||||||
|
fun findRecentlyUpdated(search: SeriesSearch, pageable: Pageable): Page<SeriesDto>
|
||||||
|
fun findByIdOrNull(seriesId: Long): SeriesDto?
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,12 @@ package org.gotson.komga.interfaces.scheduler
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.apache.commons.lang3.RandomStringUtils
|
import org.apache.commons.lang3.RandomStringUtils
|
||||||
import org.gotson.komga.domain.model.KomgaUser
|
import org.gotson.komga.domain.model.KomgaUser
|
||||||
import org.gotson.komga.domain.model.UserRoles
|
import org.gotson.komga.domain.service.KomgaUserLifecycle
|
||||||
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.security.core.userdetails.User
|
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
@ -18,24 +16,19 @@ private val logger = KotlinLogging.logger {}
|
||||||
@Profile("!(test | claim)")
|
@Profile("!(test | claim)")
|
||||||
@Controller
|
@Controller
|
||||||
class InitialUserController(
|
class InitialUserController(
|
||||||
private val userDetailsLifecycle: KomgaUserDetailsLifecycle,
|
private val userLifecycle: KomgaUserLifecycle,
|
||||||
private val initialUsers: List<KomgaUser>
|
private val initialUsers: List<KomgaUser>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun createInitialUserOnStartupIfNoneExist() {
|
fun createInitialUserOnStartupIfNoneExist() {
|
||||||
if (userDetailsLifecycle.countUsers() == 0L) {
|
if (userLifecycle.countUsers() == 0L) {
|
||||||
logger.info { "No users exist in database, creating initial users" }
|
logger.info { "No users exist in database, creating initial users" }
|
||||||
|
|
||||||
initialUsers
|
initialUsers
|
||||||
.map {
|
.forEach {
|
||||||
User.withUsername(it.email)
|
userLifecycle.createUser(it)
|
||||||
.password(it.password)
|
logger.info { "Initial user created. Login: ${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}" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,9 +38,9 @@ class InitialUserController(
|
||||||
@Profile("dev")
|
@Profile("dev")
|
||||||
class InitialUsersDevConfiguration {
|
class InitialUsersDevConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
fun initialUsers() = listOf(
|
fun initialUsers(): List<KomgaUser> = listOf(
|
||||||
KomgaUser("admin@example.org", "admin", mutableSetOf(UserRoles.ADMIN)),
|
KomgaUser("admin@example.org", "admin", roleAdmin = true),
|
||||||
KomgaUser("user@example.org", "user")
|
KomgaUser("user@example.org", "user", roleAdmin = false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +48,7 @@ class InitialUsersDevConfiguration {
|
||||||
@Profile("!dev")
|
@Profile("!dev")
|
||||||
class InitialUsersProdConfiguration {
|
class InitialUsersProdConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
fun initialUsers() = listOf(
|
fun initialUsers(): List<KomgaUser> = listOf(
|
||||||
KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), mutableSetOf(UserRoles.ADMIN))
|
KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), roleAdmin = true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,6 @@ komga:
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:mem:testdb
|
url: jdbc:h2:mem:testdb
|
||||||
jpa:
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
generate_statistics: true
|
|
||||||
session.events.log: false
|
|
||||||
format_sql: true
|
|
||||||
artemis:
|
artemis:
|
||||||
embedded:
|
embedded:
|
||||||
data-directory: ./artemis
|
data-directory: ./artemis
|
||||||
|
|
@ -24,14 +18,11 @@ logging:
|
||||||
name: komga-dev.log
|
name: komga-dev.log
|
||||||
level:
|
level:
|
||||||
org.apache.activemq.audit.message: WARN
|
org.apache.activemq.audit.message: WARN
|
||||||
|
# org.jooq: DEBUG
|
||||||
# web: DEBUG
|
# web: DEBUG
|
||||||
# org.gotson.komga: DEBUG
|
# org.gotson.komga: DEBUG
|
||||||
# org.springframework.jms: DEBUG
|
# org.springframework.jms: DEBUG
|
||||||
# org.springframework.security.web.FilterChainProxy: 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:
|
management.metrics.export.influx:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
spring:
|
|
||||||
flyway:
|
|
||||||
enabled: false
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: create
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue