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