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:
Gauthier Roebroeck 2020-06-01 09:53:55 +08:00
parent de953a4401
commit 75e1079992
127 changed files with 4884 additions and 2594 deletions

View file

@ -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>

View file

@ -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>

View file

@ -13,8 +13,7 @@ interface UserWithSharedLibrariesDto {
} }
interface SharedLibraryDto { interface SharedLibraryDto {
id: number, id: number
name: string
} }
interface UserCreationDto { interface UserCreationDto {

View file

@ -1,3 +1,8 @@
import com.rohanprabhu.gradle.plugins.kdjooq.database
import com.rohanprabhu.gradle.plugins.kdjooq.generator
import com.rohanprabhu.gradle.plugins.kdjooq.jdbc
import com.rohanprabhu.gradle.plugins.kdjooq.jooqCodegenConfiguration
import com.rohanprabhu.gradle.plugins.kdjooq.target
import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@ -11,9 +16,10 @@ plugins {
kotlin("kapt") version kotlinVersion kotlin("kapt") version kotlinVersion
} }
id("org.springframework.boot") version "2.2.6.RELEASE" id("org.springframework.boot") version "2.2.6.RELEASE"
id("io.spring.dependency-management") version "1.0.9.RELEASE"
id("com.github.ben-manes.versions") version "0.28.0" id("com.github.ben-manes.versions") version "0.28.0"
id("com.gorylenko.gradle-git-properties") version "2.2.2" id("com.gorylenko.gradle-git-properties") version "2.2.2"
id("com.rohanprabhu.kotlin-dsl-jooq") version "0.4.5"
id("org.flywaydb.flyway") version "6.4.0"
jacoco jacoco
} }
@ -25,37 +31,40 @@ configurations.runtimeClasspath.get().extendsFrom(developmentOnly)
repositories { repositories {
jcenter() jcenter()
mavenCentral() mavenCentral()
maven("https://jitpack.io")
} }
dependencies { dependencies {
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect")) implementation(kotlin("reflect"))
constraints {
implementation("org.flywaydb:flyway-core:6.4.0") {
because("support for H2 1.4.200 requires 6.1.0+")
}
}
implementation(platform("org.springframework.boot:spring-boot-dependencies:2.2.6.RELEASE"))
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-artemis") implementation("org.springframework.boot:spring-boot-starter-artemis")
implementation("org.springframework.boot:spring-boot-starter-jooq")
kapt("org.springframework.boot:spring-boot-configuration-processor") kapt("org.springframework.boot:spring-boot-configuration-processor:2.2.6.RELEASE")
implementation("org.apache.activemq:artemis-jms-server") implementation("org.apache.activemq:artemis-jms-server")
implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-core")
implementation("org.hibernate:hibernate-jcache")
implementation("com.github.ben-manes.caffeine:caffeine")
implementation("com.github.ben-manes.caffeine:jcache")
implementation("io.github.microutils:kotlin-logging:1.7.9") implementation("io.github.microutils:kotlin-logging:1.7.9")
implementation("io.micrometer:micrometer-registry-influx") implementation("io.micrometer:micrometer-registry-influx")
implementation("io.hawt:hawtio-springboot:2.10.0") implementation("io.hawt:hawtio-springboot:2.10.0")
run { run {
val springdocVersion = "1.3.3" val springdocVersion = "1.3.4"
implementation("org.springdoc:springdoc-openapi-ui:$springdocVersion") implementation("org.springdoc:springdoc-openapi-ui:$springdocVersion")
implementation("org.springdoc:springdoc-openapi-security:$springdocVersion") implementation("org.springdoc:springdoc-openapi-security:$springdocVersion")
} }
@ -63,12 +72,10 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
implementation("com.github.klinq:klinq-jpaspec:0.9")
implementation("commons-io:commons-io:2.6") implementation("commons-io:commons-io:2.6")
implementation("org.apache.commons:commons-lang3:3.10") implementation("org.apache.commons:commons-lang3:3.10")
implementation("org.apache.tika:tika-core:1.24") implementation("org.apache.tika:tika-core:1.24.1")
implementation("org.apache.commons:commons-compress:1.20") implementation("org.apache.commons:commons-compress:1.20")
implementation("com.github.junrar:junrar:4.0.0") implementation("com.github.junrar:junrar:4.0.0")
implementation("org.apache.pdfbox:pdfbox:2.0.19") implementation("org.apache.pdfbox:pdfbox:2.0.19")
@ -85,14 +92,15 @@ dependencies {
implementation("com.jakewharton.byteunits:byteunits:0.9.1") implementation("com.jakewharton.byteunits:byteunits:0.9.1")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2:1.4.200")
jooqGeneratorRuntime("com.h2database:h2:1.4.200")
testImplementation("org.springframework.boot:spring-boot-starter-test") { testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "mockito-core") exclude(module = "mockito-core")
} }
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.ninja-squad:springmockk:2.0.1") testImplementation("com.ninja-squad:springmockk:2.0.1")
testImplementation("io.mockk:mockk:1.9.3") testImplementation("io.mockk:mockk:1.10.0")
testImplementation("com.google.jimfs:jimfs:1.1") testImplementation("com.google.jimfs:jimfs:1.1")
testImplementation("com.tngtech.archunit:archunit-junit5:0.13.1") testImplementation("com.tngtech.archunit:archunit-junit5:0.13.1")
@ -100,6 +108,7 @@ dependencies {
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
} }
val webui = "$rootDir/komga-webui"
tasks { tasks {
withType<KotlinCompile> { withType<KotlinCompile> {
kotlinOptions { kotlinOptions {
@ -119,20 +128,18 @@ tasks {
} }
} }
register<Copy>("unpack") { //unpack Spring Boot's fat jar for better Docker image layering
register<Sync>("unpack") {
dependsOn(bootJar) dependsOn(bootJar)
from(zipTree(getByName("bootJar").outputs.files.singleFile)) from(zipTree(getByName("bootJar").outputs.files.singleFile))
into("$buildDir/dependency") into("$buildDir/dependency")
} }
register<Delete>("deletePublic") {
group = "web"
delete("$projectDir/src/main/resources/public/")
}
register<Exec>("npmInstall") { register<Exec>("npmInstall") {
group = "web" group = "web"
workingDir("$rootDir/komga-webui") workingDir(webui)
inputs.file("$webui/package.json")
outputs.dir("$webui/node_modules")
commandLine( commandLine(
if (Os.isFamily(Os.FAMILY_WINDOWS)) { if (Os.isFamily(Os.FAMILY_WINDOWS)) {
"npm.cmd" "npm.cmd"
@ -146,7 +153,9 @@ tasks {
register<Exec>("npmBuild") { register<Exec>("npmBuild") {
group = "web" group = "web"
dependsOn("npmInstall") dependsOn("npmInstall")
workingDir("$rootDir/komga-webui") workingDir(webui)
inputs.dir(webui)
outputs.dir("$webui/dist")
commandLine( commandLine(
if (Os.isFamily(Os.FAMILY_WINDOWS)) { if (Os.isFamily(Os.FAMILY_WINDOWS)) {
"npm.cmd" "npm.cmd"
@ -158,16 +167,22 @@ tasks {
) )
} }
register<Copy>("copyWebDist") { //copy the webui build into public
register<Sync>("copyWebDist") {
group = "web" group = "web"
dependsOn("deletePublic", "npmBuild") dependsOn("npmBuild")
from("$rootDir/komga-webui/dist/") from("$webui/dist/")
into("$projectDir/src/main/resources/public/") into("$projectDir/src/main/resources/public/")
} }
} }
springBoot { springBoot {
buildInfo() buildInfo {
properties {
// prevent task bootBuildInfo to rerun every time
time = null
}
}
} }
allOpen { allOpen {
@ -175,3 +190,70 @@ allOpen {
annotation("javax.persistence.MappedSuperclass") annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable") annotation("javax.persistence.Embeddable")
} }
sourceSets {
//add a flyway sourceSet
val flyway by creating {
compileClasspath += sourceSets.main.get().compileClasspath
runtimeClasspath += sourceSets.main.get().runtimeClasspath
}
//main sourceSet depends on the output of flyway sourceSet
main {
output.dir(flyway.output)
}
}
val jooqDb = mapOf(
"url" to "jdbc:h2:${project.buildDir}/generated/flyway/h2",
"schema" to "PUBLIC",
"user" to "sa",
"password" to ""
)
val migrationDirs = listOf(
"$projectDir/src/flyway/resources/db/migration",
"$projectDir/src/flyway/kotlin/db/migration"
)
flyway {
url = jooqDb["url"]
user = jooqDb["user"]
password = jooqDb["password"]
schemas = arrayOf(jooqDb["schema"])
locations = arrayOf("classpath:db/migration")
}
//in order to include the Java migrations, flywayClasses must be run before flywayMigrate
tasks.flywayMigrate {
dependsOn("flywayClasses")
migrationDirs.forEach { inputs.dir(it) }
outputs.dir("${project.buildDir}/generated/flyway")
doFirst { delete(outputs.files) }
}
jooqGenerator {
jooqVersion = "3.13.1"
configuration("primary", project.sourceSets.getByName("main")) {
databaseSources = migrationDirs
configuration = jooqCodegenConfiguration {
jdbc {
username = jooqDb["user"]
password = jooqDb["password"]
driver = "org.h2.Driver"
url = jooqDb["url"]
}
generator {
target {
packageName = "org.gotson.komga.jooq"
directory = "${project.buildDir}/generated/jooq/primary"
}
database {
name = "org.jooq.meta.h2.H2Database"
inputSchema = jooqDb["schema"]
}
}
}
}
}
val `jooq-codegen-primary` by project.tasks
`jooq-codegen-primary`.dependsOn("flywayMigrate")

View file

@ -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);

View file

@ -2,12 +2,10 @@ package org.gotson.komga
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableJpaAuditing
class Application class Application
fun main(args: Array<String>) { fun main(args: Array<String>) {

View file

@ -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)
}
}
}
}
}

View file

@ -1,17 +1,15 @@
package org.gotson.komga.application.tasks package org.gotson.komga.application.tasks
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.service.BookLifecycle
import org.gotson.komga.application.service.MetadataLifecycle
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.domain.service.LibraryScanner import org.gotson.komga.domain.service.LibraryScanner
import org.gotson.komga.domain.service.MetadataLifecycle
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR
import org.springframework.data.repository.findByIdOrNull
import org.springframework.jms.annotation.JmsListener import org.springframework.jms.annotation.JmsListener
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.time.measureTime import kotlin.time.measureTime
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -27,12 +25,12 @@ class TaskHandler(
) { ) {
@JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR) @JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR)
@Transactional
fun handleTask(task: Task) { fun handleTask(task: Task) {
logger.info { "Executing task: $task" } logger.info { "Executing task: $task" }
try { try {
measureTime { measureTime {
when (task) { when (task) {
is Task.ScanLibrary -> is Task.ScanLibrary ->
libraryRepository.findByIdOrNull(task.libraryId)?.let { libraryRepository.findByIdOrNull(task.libraryId)?.let {
libraryScanner.scanRootFolder(it) libraryScanner.scanRootFolder(it)
@ -54,6 +52,7 @@ class TaskHandler(
bookRepository.findByIdOrNull(task.bookId)?.let { bookRepository.findByIdOrNull(task.bookId)?.let {
metadataLifecycle.refreshMetadata(it) metadataLifecycle.refreshMetadata(it)
} ?: logger.warn { "Cannot execute task $task: Book does not exist" } } ?: logger.warn { "Cannot execute task $task: Book does not exist" }
} }
}.also { }.also {
logger.info { "Task $task executed in $it" } logger.info { "Task $task executed in $it" }

View file

@ -2,6 +2,7 @@ package org.gotson.komga.application.tasks
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
@ -23,25 +24,36 @@ class TaskReceiver(
) { ) {
fun scanLibraries() { fun scanLibraries() {
libraryRepository.findAll().forEach { scanLibrary(it) } libraryRepository.findAll().forEach { scanLibrary(it.id) }
} }
fun scanLibrary(library: Library) { fun scanLibrary(libraryId: Long) {
submitTask(Task.ScanLibrary(library.id)) submitTask(Task.ScanLibrary(libraryId))
} }
fun analyzeUnknownBooks(library: Library) { fun analyzeUnknownBooks(library: Library) {
bookRepository.findAllByMediaStatusAndSeriesLibrary(Media.Status.UNKNOWN, library).forEach { bookRepository.findAllId(BookSearch(
submitTask(Task.AnalyzeBook(it.id)) libraryIds = listOf(library.id),
mediaStatus = listOf(Media.Status.UNKNOWN)
)).forEach {
submitTask(Task.AnalyzeBook(it))
} }
} }
fun analyzeBook(bookId: Long) {
submitTask(Task.AnalyzeBook(bookId))
}
fun analyzeBook(book: Book) { fun analyzeBook(book: Book) {
submitTask(Task.AnalyzeBook(book.id)) submitTask(Task.AnalyzeBook(book.id))
} }
fun generateBookThumbnail(book: Book) { fun generateBookThumbnail(bookId: Long) {
submitTask(Task.GenerateBookThumbnail(book.id)) submitTask(Task.GenerateBookThumbnail(bookId))
}
fun refreshBookMetadata(bookId: Long) {
submitTask(Task.RefreshBookMetadata(bookId))
} }
fun refreshBookMetadata(book: Book) { fun refreshBookMetadata(book: Book) {

View file

@ -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
}

View file

@ -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
}

View file

@ -1,31 +1,11 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import javax.persistence.Column class Author(
import javax.persistence.Embeddable name: String,
import javax.validation.constraints.NotBlank role: String
) {
@Embeddable val name = name.trim()
class Author { val role = role.trim().toLowerCase()
constructor(name: String, role: String) {
this.name = name
this.role = role
}
@NotBlank
@Column(name = "name", nullable = false)
var name: String
set(value) {
require(value.isNotBlank()) { "name must not be blank" }
field = value.trim()
}
@NotBlank
@Column(name = "role", nullable = false)
var role: String
set(value) {
require(value.isNotBlank()) { "role must not be blank" }
field = value.trim().toLowerCase()
}
override fun toString(): String = "Author($name, $role)" override fun toString(): String = "Author($name, $role)"
} }

View file

@ -2,74 +2,25 @@ package org.gotson.komga.domain.model
import com.jakewharton.byteunits.BinaryByteUnit import com.jakewharton.byteunits.BinaryByteUnit
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.hibernate.annotations.Cache
import org.hibernate.annotations.CacheConcurrencyStrategy
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.Cacheable
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToOne
import javax.persistence.Table
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
@Entity data class Book(
@Table(name = "book") val name: String,
@Cacheable val url: URL,
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book") val fileLastModified: LocalDateTime,
class Book( val fileSize: Long = 0,
@NotBlank val number: Int = 0,
@Column(name = "name", nullable = false)
var name: String,
@Column(name = "url", nullable = false) val id: Long = 0,
var url: URL, val seriesId: Long = 0,
val libraryId: Long = 0,
@Column(name = "file_last_modified", nullable = false) override val createdDate: LocalDateTime = LocalDateTime.now(),
var fileLastModified: LocalDateTime, override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() {
@Column(name = "file_size", nullable = false)
var fileSize: Long = 0
) : AuditableEntity() {
@Id
@GeneratedValue
@Column(name = "id", nullable = false)
var id: Long = 0
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "series_id", nullable = false)
lateinit var series: Series
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@JoinColumn(name = "media_id", nullable = false)
var media: Media = Media()
@Column(name = "number", nullable = false)
var number: Int = 0
set(value) {
field = value
if (!metadata.numberLock) metadata.number = value.toString()
if (!metadata.numberSortLock) metadata.numberSort = value.toFloat()
}
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@JoinColumn(name = "metadata_id", nullable = false)
var metadata: BookMetadata =
BookMetadata(
title = name,
number = number.toString(),
numberSort = number.toFloat()
)
fun fileName(): String = FilenameUtils.getName(url.toString()) fun fileName(): String = FilenameUtils.getName(url.toString())
@ -78,6 +29,4 @@ class Book(
fun path(): Path = Paths.get(this.url.toURI()) fun path(): Path = Paths.get(this.url.toURI())
fun fileSizeHumanReadable(): String = BinaryByteUnit.format(fileSize) fun fileSizeHumanReadable(): String = BinaryByteUnit.format(fileSize)
override fun toString(): String = "Book($id, ${url.toURI().path})"
} }

View file

@ -1,128 +1,86 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import org.hibernate.annotations.Cache
import org.hibernate.annotations.CacheConcurrencyStrategy
import java.time.LocalDate import java.time.LocalDate
import javax.persistence.Cacheable import java.time.LocalDateTime
import javax.persistence.CollectionTable
import javax.persistence.Column
import javax.persistence.ElementCollection
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.Table
import javax.validation.constraints.NotBlank
import javax.validation.constraints.PositiveOrZero
@Entity class BookMetadata(
@Table(name = "book_metadata") title: String,
@Cacheable summary: String = "",
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book_metadata") number: String,
class BookMetadata : AuditableEntity { val numberSort: Float,
constructor( val readingDirection: ReadingDirection? = null,
title: String, publisher: String = "",
summary: String = "", val ageRating: Int? = null,
number: String, val releaseDate: LocalDate? = null,
numberSort: Float, val authors: List<Author> = emptyList(),
readingDirection: ReadingDirection? = null,
publisher: String = "",
ageRating: Int? = null,
releaseDate: LocalDate? = null,
authors: MutableList<Author> = mutableListOf()
) : super() {
this.title = title
this.summary = summary
this.number = number
this.numberSort = numberSort
this.readingDirection = readingDirection
this.publisher = publisher
this.ageRating = ageRating
this.releaseDate = releaseDate
this.authors = authors
}
@Id val titleLock: Boolean = false,
@GeneratedValue val summaryLock: Boolean = false,
@Column(name = "id", nullable = false, unique = true) val numberLock: Boolean = false,
val id: Long = 0 val numberSortLock: Boolean = false,
val readingDirectionLock: Boolean = false,
val publisherLock: Boolean = false,
val ageRatingLock: Boolean = false,
val releaseDateLock: Boolean = false,
val authorsLock: Boolean = false,
@NotBlank val bookId: Long = 0,
@Column(name = "title", nullable = false)
var title: String
set(value) {
require(value.isNotBlank()) { "title must not be blank" }
field = value.trim()
}
@Column(name = "summary", nullable = false) override val createdDate: LocalDateTime = LocalDateTime.now(),
var summary: String override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
set(value) { ) : Auditable() {
field = value.trim()
}
@NotBlank val title = title.trim()
@Column(name = "number", nullable = false) val summary = summary.trim()
var number: String val number = number.trim()
set(value) { val publisher = publisher.trim()
require(value.isNotBlank()) { "number must not be blank" }
field = value.trim()
}
@Column(name = "number_sort", nullable = false, columnDefinition = "REAL") fun copy(
var numberSort: Float title: String = this.title,
summary: String = this.summary,
@Enumerated(EnumType.STRING) number: String = this.number,
@Column(name = "reading_direction", nullable = true) numberSort: Float = this.numberSort,
var readingDirection: ReadingDirection? readingDirection: ReadingDirection? = this.readingDirection,
publisher: String = this.publisher,
@Column(name = "publisher", nullable = false) ageRating: Int? = this.ageRating,
var publisher: String releaseDate: LocalDate? = this.releaseDate,
set(value) { authors: List<Author> = this.authors.toList(),
field = value.trim() titleLock: Boolean = this.titleLock,
} summaryLock: Boolean = this.summaryLock,
numberLock: Boolean = this.numberLock,
@PositiveOrZero numberSortLock: Boolean = this.numberSortLock,
@Column(name = "age_rating", nullable = true) readingDirectionLock: Boolean = this.readingDirectionLock,
var ageRating: Int? publisherLock: Boolean = this.publisherLock,
ageRatingLock: Boolean = this.ageRatingLock,
@Column(name = "release_date", nullable = true) releaseDateLock: Boolean = this.releaseDateLock,
var releaseDate: LocalDate? authorsLock: Boolean = this.authorsLock,
bookId: Long = this.bookId,
@ElementCollection(fetch = FetchType.LAZY) createdDate: LocalDateTime = this.createdDate,
@CollectionTable(name = "book_metadata_author", joinColumns = [JoinColumn(name = "book_metadata_id")]) lastModifiedDate: LocalDateTime = this.lastModifiedDate
var authors: MutableList<Author> ) =
BookMetadata(
title = title,
@Column(name = "title_lock", nullable = false) summary = summary,
var titleLock: Boolean = false number = number,
numberSort = numberSort,
@Column(name = "summary_lock", nullable = false) readingDirection = readingDirection,
var summaryLock: Boolean = false publisher = publisher,
ageRating = ageRating,
@Column(name = "number_lock", nullable = false) releaseDate = releaseDate,
var numberLock: Boolean = false authors = authors,
titleLock = titleLock,
@Column(name = "number_sort_lock", nullable = false) summaryLock = summaryLock,
var numberSortLock: Boolean = false numberLock = numberLock,
numberSortLock = numberSortLock,
@Column(name = "reading_direction_lock", nullable = false) readingDirectionLock = readingDirectionLock,
var readingDirectionLock: Boolean = false publisherLock = publisherLock,
ageRatingLock = ageRatingLock,
@Column(name = "publisher_lock", nullable = false) releaseDateLock = releaseDateLock,
var publisherLock: Boolean = false authorsLock = authorsLock,
bookId = bookId,
@Column(name = "age_rating_lock", nullable = false) createdDate = createdDate,
var ageRatingLock: Boolean = false lastModifiedDate = lastModifiedDate
)
@Column(name = "release_date_lock", nullable = false)
var releaseDateLock: Boolean = false
@Column(name = "authors_lock", nullable = false)
var authorsLock: Boolean = false
enum class ReadingDirection { enum class ReadingDirection {
LEFT_TO_RIGHT, LEFT_TO_RIGHT,
@ -130,4 +88,7 @@ class BookMetadata : AuditableEntity {
VERTICAL, VERTICAL,
WEBTOON WEBTOON
} }
override fun toString(): String =
"BookMetadata(numberSort=$numberSort, readingDirection=$readingDirection, ageRating=$ageRating, releaseDate=$releaseDate, authors=$authors, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, readingDirectionLock=$readingDirectionLock, publisherLock=$publisherLock, ageRatingLock=$ageRatingLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, bookId=$bookId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', publisher='$publisher')"
} }

View file

@ -2,7 +2,7 @@ package org.gotson.komga.domain.model
import java.time.LocalDate import java.time.LocalDate
class BookMetadataPatch( data class BookMetadataPatch(
val title: String?, val title: String?,
val summary: String?, val summary: String?,
val number: String?, val number: String?,

View file

@ -1,13 +1,6 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import javax.persistence.Column data class BookPage(
import javax.persistence.Embeddable
@Embeddable
class BookPage(
@Column(name = "file_name", nullable = false)
val fileName: String, val fileName: String,
@Column(name = "media_type", nullable = false)
val mediaType: String val mediaType: String
) )

View file

@ -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()
)

View file

@ -6,3 +6,4 @@ class ImageConversionException(message: String) : Exception(message)
class DirectoryNotFoundException(message: String) : Exception(message) class DirectoryNotFoundException(message: String) : Exception(message)
class DuplicateNameException(message: String) : Exception(message) class DuplicateNameException(message: String) : Exception(message)
class PathContainedInPath(message: String) : Exception(message) class PathContainedInPath(message: String) : Exception(message)
class UserEmailAlreadyExistsException(message: String) : Exception(message)

View file

@ -1,77 +1,55 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import javax.persistence.CollectionTable import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.ElementCollection
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.JoinTable
import javax.persistence.ManyToMany
import javax.persistence.Table
import javax.validation.constraints.Email import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
@Entity data class KomgaUser(
@Table(name = "user")
class KomgaUser(
@Email @Email
@NotBlank @NotBlank
@Column(name = "email", nullable = false, unique = true) val email: String,
var email: String,
@NotBlank @NotBlank
@Column(name = "password", nullable = false) val password: String,
var password: String, val roleAdmin: Boolean,
val sharedLibrariesIds: Set<Long> = emptySet(),
val sharedAllLibraries: Boolean = true,
val id: Long = 0,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() {
@Enumerated(EnumType.STRING) fun roles(): Set<String> {
@ElementCollection(fetch = FetchType.EAGER) val roles = mutableSetOf("USER")
@CollectionTable(name = "user_role", joinColumns = [JoinColumn(name = "user_id")]) if (roleAdmin) roles.add("ADMIN")
var roles: MutableSet<UserRoles> = mutableSetOf() return roles
}
) : AuditableEntity() { fun getAuthorizedLibraryIds(libraryIds: Collection<Long>?) =
when {
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries
!sharedAllLibraries && !libraryIds.isNullOrEmpty() -> libraryIds.intersect(sharedLibrariesIds)
@Id // limited user: filter on user's authorized libraries
@GeneratedValue !sharedAllLibraries -> sharedLibrariesIds
@Column(name = "id", nullable = false)
var id: Long = 0
@ManyToMany(fetch = FetchType.EAGER) // non-limited user: filter on provided libraries
@JoinTable( !libraryIds.isNullOrEmpty() -> libraryIds
name = "user_library_sharing",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "library_id", referencedColumnName = "id")]
)
var sharedLibraries: MutableSet<Library> = mutableSetOf()
@NotNull else -> emptyList()
@Column(name = "shared_all_libraries", nullable = false)
var sharedAllLibraries: Boolean = true
get() = if (roles.contains(UserRoles.ADMIN)) true else field
set(value) {
field = if (roles.contains(UserRoles.ADMIN)) true else value
} }
fun isAdmin() = roles.contains(UserRoles.ADMIN)
fun canAccessBook(book: Book): Boolean { fun canAccessBook(book: Book): Boolean {
return sharedAllLibraries || sharedLibraries.any { it.id == book.series.library.id } return sharedAllLibraries || sharedLibrariesIds.any { it == book.libraryId }
} }
fun canAccessSeries(series: Series): Boolean { fun canAccessSeries(series: Series): Boolean {
return sharedAllLibraries || sharedLibraries.any { it.id == series.library.id } return sharedAllLibraries || sharedLibrariesIds.any { it == series.libraryId }
} }
fun canAccessLibrary(libraryId: Long): Boolean =
sharedAllLibraries || sharedLibrariesIds.any { it == libraryId }
fun canAccessLibrary(library: Library): Boolean { fun canAccessLibrary(library: Library): Boolean {
return sharedAllLibraries || sharedLibraries.any { it.id == library.id } return sharedAllLibraries || sharedLibrariesIds.any { it == library.id }
} }
} }
enum class UserRoles {
ADMIN
}

View file

@ -1,39 +1,19 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import org.hibernate.annotations.Cache
import org.hibernate.annotations.CacheConcurrencyStrategy
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import javax.persistence.Cacheable import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.Table
import javax.validation.constraints.NotBlank
@Entity data class Library(
@Table(name = "library")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.library")
class Library(
@NotBlank
@Column(name = "name", nullable = false, unique = true)
val name: String, val name: String,
val root: URL,
@NotBlank val id: Long = 0,
@Column(name = "root", nullable = false) override val createdDate: LocalDateTime = LocalDateTime.now(),
val root: URL override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : AuditableEntity() { ) : Auditable() {
@Id
@GeneratedValue
@Column(name = "id", nullable = false, unique = true)
var id: Long = 0
constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL()) constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL())
fun path(): Path = Paths.get(this.root.toURI()) fun path(): Path = Paths.get(this.root.toURI())
override fun toString() = "Library($id, $name, ${root.toURI().path})"
} }

View file

@ -1,91 +1,48 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import org.hibernate.annotations.Cache import java.time.LocalDateTime
import org.hibernate.annotations.CacheConcurrencyStrategy
import javax.persistence.Cacheable
import javax.persistence.CollectionTable
import javax.persistence.Column
import javax.persistence.ElementCollection
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.Lob
import javax.persistence.OrderColumn
import javax.persistence.Table
@Entity
@Table(name = "media")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media")
class Media( class Media(
@Enumerated(EnumType.STRING) val status: Status = Status.UNKNOWN,
@Column(name = "status", nullable = false) val mediaType: String? = null,
var status: Status = Status.UNKNOWN, val thumbnail: ByteArray? = null,
val pages: List<BookPage> = emptyList(),
val files: List<String> = emptyList(),
val comment: String? = null,
val bookId: Long = 0,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable() {
@Column(name = "media_type") fun reset() = Media(bookId = this.bookId)
var mediaType: String? = null,
@Column(name = "thumbnail") fun copy(
@Lob status: Status = this.status,
var thumbnail: ByteArray? = null, mediaType: String? = this.mediaType,
thumbnail: ByteArray? = this.thumbnail,
pages: Iterable<BookPage> = emptyList(), pages: List<BookPage> = this.pages.toList(),
files: List<String> = this.files.toList(),
files: Iterable<String> = emptyList(), comment: String? = this.comment,
bookId: Long = this.bookId,
@Column(name = "comment") createdDate: LocalDateTime = this.createdDate,
var comment: String? = null lastModifiedDate: LocalDateTime = this.lastModifiedDate
) : AuditableEntity() { ) =
@Id Media(
@GeneratedValue status = status,
@Column(name = "id", nullable = false, unique = true) mediaType = mediaType,
val id: Long = 0 thumbnail = thumbnail,
pages = pages,
@ElementCollection(fetch = FetchType.LAZY) files = files,
@CollectionTable(name = "media_page", joinColumns = [JoinColumn(name = "media_id")]) comment = comment,
@OrderColumn(name = "number") bookId = bookId,
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media.collection.pages") createdDate = createdDate,
private var _pages: MutableList<BookPage> = mutableListOf() lastModifiedDate = lastModifiedDate
)
var pages: List<BookPage>
get() = _pages.toList()
set(value) {
_pages.clear()
_pages.addAll(value)
}
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "media_file", joinColumns = [JoinColumn(name = "media_id")])
@Column(name = "files")
private var _files: MutableList<String> = mutableListOf()
var files: List<String>
get() = _files.toList()
set(value) {
_files.clear()
_files.addAll(value)
}
fun reset() {
status = Status.UNKNOWN
mediaType = null
thumbnail = null
comment = null
_pages.clear()
_files.clear()
}
init {
this.pages = pages.toList()
this.files = files.toList()
}
enum class Status { enum class Status {
UNKNOWN, ERROR, READY, UNSUPPORTED UNKNOWN, ERROR, READY, UNSUPPORTED
} }
override fun toString(): String =
"Media(status=$status, mediaType=$mediaType, pages=$pages, files=$files, comment=$comment, bookId=$bookId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)"
} }

View file

@ -1,6 +1,6 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
class MediaContainerEntry( data class MediaContainerEntry(
val name: String, val name: String,
val mediaType: String? = null, val mediaType: String? = null,
val comment: String? = null val comment: String? = null

View file

@ -1,76 +1,16 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.hibernate.annotations.Cache
import org.hibernate.annotations.CacheConcurrencyStrategy
import java.net.URL import java.net.URL
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.*
import javax.persistence.Cacheable
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.OneToOne
import javax.persistence.Table
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance() data class Series(
val name: String,
@Entity val url: URL,
@Table(name = "series")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series")
class Series(
@NotBlank
@Column(name = "name", nullable = false)
var name: String,
@Column(name = "url", nullable = false)
var url: URL,
@Column(name = "file_last_modified", nullable = false)
var fileLastModified: LocalDateTime, var fileLastModified: LocalDateTime,
books: Iterable<Book> val id: Long = 0,
val libraryId: Long = 0,
) : AuditableEntity() { override val createdDate: LocalDateTime = LocalDateTime.now(),
@Id override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
@GeneratedValue ) : Auditable()
@Column(name = "id", nullable = false, unique = true)
var id: Long = 0
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "library_id", nullable = false)
lateinit var library: Library
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "series")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series.collection.books")
private var _books: MutableList<Book> = mutableListOf()
var books: List<Book>
get() = _books.toList()
set(value) {
_books.clear()
value.forEach { it.series = this }
_books.addAll(value.sortedWith(compareBy(natSortComparator) { it.name }))
_books.forEachIndexed { index, book -> book.number = index + 1 }
}
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@JoinColumn(name = "metadata_id", nullable = false)
var metadata: SeriesMetadata = SeriesMetadata(title = name)
init {
this.books = books.toList()
}
override fun toString(): String = "Series($id, ${url.toURI().path})"
}

View file

@ -1,70 +1,51 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import org.hibernate.annotations.Cache import java.time.LocalDateTime
import org.hibernate.annotations.CacheConcurrencyStrategy
import javax.persistence.Cacheable
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.Table
import javax.validation.constraints.NotBlank
@Entity class SeriesMetadata(
@Table(name = "series_metadata") val status: Status = Status.ONGOING,
@Cacheable title: String,
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series_metadata") titleSort: String = title,
class SeriesMetadata : AuditableEntity {
constructor(
status: Status = Status.ONGOING,
title: String,
titleSort: String = title
) : super() {
this.status = status
this.title = title
this.titleSort = titleSort
}
@Id val statusLock: Boolean = false,
@GeneratedValue val titleLock: Boolean = false,
@Column(name = "id", nullable = false, unique = true) val titleSortLock: Boolean = false,
val id: Long = 0
val seriesId: Long = 0,
@Enumerated(EnumType.STRING) override val createdDate: LocalDateTime = LocalDateTime.now(),
@Column(name = "status", nullable = false) override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
var status: Status ) : Auditable() {
val title = title.trim()
@NotBlank val titleSort = titleSort.trim()
@Column(name = "title", nullable = false)
var title: String
set(value) {
require(value.isNotBlank()) { "title must not be blank" }
field = value.trim()
}
@NotBlank
@Column(name = "title_sort", nullable = false)
var titleSort: String
set(value) {
require(value.isNotBlank()) { "titleSort must not be blank" }
field = value.trim()
}
@Column(name = "status_lock", nullable = false)
var statusLock: Boolean = false
@Column(name = "title_lock", nullable = false)
var titleLock: Boolean = false
@Column(name = "title_sort_lock", nullable = false)
var titleSortLock: Boolean = false
fun copy(
status: Status = this.status,
title: String = this.title,
titleSort: String = this.titleSort,
statusLock: Boolean = this.statusLock,
titleLock: Boolean = this.titleLock,
titleSortLock: Boolean = this.titleSortLock,
seriesId: Long = this.seriesId,
createdDate: LocalDateTime = this.createdDate,
lastModifiedDate: LocalDateTime = this.lastModifiedDate
) =
SeriesMetadata(
status = status,
title = title,
titleSort = titleSort,
statusLock = statusLock,
titleLock = titleLock,
titleSortLock = titleSortLock,
seriesId = seriesId,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate
)
enum class Status { enum class Status {
ENDED, ONGOING, ABANDONED, HIATUS ENDED, ONGOING, ABANDONED, HIATUS
} }
override fun toString(): String =
"SeriesMetadata(status=$status, statusLock=$statusLock, titleLock=$titleLock, titleSortLock=$titleSortLock, seriesId=$seriesId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', titleSort='$titleSort')"
} }

View file

@ -1,6 +1,6 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
class SeriesMetadataPatch( data class SeriesMetadataPatch(
val title: String?, val title: String?,
val titleSort: String?, val titleSort: String?,
val status: SeriesMetadata.Status? val status: SeriesMetadata.Status?

View file

@ -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()
)

View file

@ -1,16 +1,15 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository interface BookMetadataRepository {
interface BookMetadataRepository : JpaRepository<BookMetadata, Long> { fun findById(bookId: Long): BookMetadata
@Query( fun findByIdOrNull(bookId: Long): BookMetadata?
value = "select distinct a.name from BOOK_METADATA_AUTHOR a where a.name ilike CONCAT('%', :search, '%') order by a.name",
nativeQuery = true fun findAuthorsByName(search: String): List<String>
)
fun findAuthorsByName(@Param("search") search: String): List<String> fun insert(metadata: BookMetadata): BookMetadata
fun update(metadata: BookMetadata)
fun delete(bookId: Long)
} }

View file

@ -1,36 +1,26 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Media
import org.hibernate.annotations.QueryHints.CACHEABLE
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.jpa.repository.QueryHints
import org.springframework.stereotype.Repository
import java.net.URL
import javax.persistence.QueryHint
@Repository interface BookRepository {
interface BookRepository : JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> { fun findByIdOrNull(bookId: Long): Book?
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun findBySeriesId(seriesId: Long): Collection<Book>
override fun findAll(pageable: Pageable): Page<Book> fun findAll(): Collection<Book>
fun findAll(bookSearch: BookSearch): Collection<Book>
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun getLibraryId(bookId: Long): Long?
fun findAllBySeriesId(seriesId: Long, pageable: Pageable): Page<Book> fun findFirstIdInSeries(seriesId: Long): Long?
fun findAllIdBySeriesId(seriesId: Long): Collection<Long>
fun findAllIdByLibraryId(libraryId: Long): Collection<Long>
fun findAllId(bookSearch: BookSearch): Collection<Long>
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun insert(book: Book): Book
fun findAllByMediaStatusInAndSeriesId(status: Collection<Media.Status>, seriesId: Long, pageable: Pageable): Page<Book> fun update(book: Book)
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun delete(bookId: Long)
fun findBySeriesLibraryIn(seriesLibrary: Collection<Library>, pageable: Pageable): Page<Book> fun deleteAll(bookIds: List<Long>)
fun deleteAll()
fun findBySeriesLibraryIn(seriesLibrary: Collection<Library>): List<Book> fun count(): Long
fun findBySeriesLibrary(seriesLibrary: Library): List<Book>
fun findByUrl(url: URL): Book?
fun findAllByMediaStatusAndSeriesLibrary(status: Media.Status, library: Library): List<Book>
fun findAllByMediaThumbnailIsNull(): List<Book>
} }

View file

@ -1,13 +1,19 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository
@Repository interface KomgaUserRepository {
interface KomgaUserRepository : CrudRepository<KomgaUser, Long> { fun count(): Long
fun findAll(): Collection<KomgaUser>
fun findByIdOrNull(id: Long): KomgaUser?
fun save(user: KomgaUser): KomgaUser
fun saveAll(users: Iterable<KomgaUser>): Collection<KomgaUser>
fun delete(user: KomgaUser)
fun deleteAll()
fun existsByEmailIgnoreCase(email: String): Boolean fun existsByEmailIgnoreCase(email: String): Boolean
fun findByEmailIgnoreCase(email: String): KomgaUser? fun findByEmailIgnoreCase(email: String): KomgaUser?
fun findBySharedLibrariesContaining(library: Library): List<KomgaUser>
} }

View file

@ -1,20 +1,18 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.hibernate.annotations.QueryHints.CACHEABLE
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.QueryHints
import org.springframework.stereotype.Repository
import javax.persistence.QueryHint
@Repository interface LibraryRepository {
interface LibraryRepository : JpaRepository<Library, Long> { fun findByIdOrNull(libraryId: Long): Library?
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun findAll(): Collection<Library>
override fun findAll(sort: Sort): List<Library> fun findAllById(libraryIds: Collection<Long>): Collection<Library>
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
override fun findAllById(ids: Iterable<Long>): List<Library>
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
fun delete(libraryId: Long)
fun deleteAll()
fun insert(library: Library): Library
fun count(): Long
} }

View file

@ -1,8 +1,14 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository interface MediaRepository {
interface MediaRepository : JpaRepository<Media, Long> fun findById(bookId: Long): Media
fun getThumbnail(bookId: Long): ByteArray?
fun insert(media: Media): Media
fun update(media: Media)
fun delete(bookId: Long)
}

View file

@ -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
}

View file

@ -1,43 +1,25 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.Series
import org.hibernate.annotations.QueryHints.CACHEABLE import org.gotson.komga.domain.model.SeriesSearch
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.QueryHints
import org.springframework.stereotype.Repository
import java.net.URL import java.net.URL
import javax.persistence.QueryHint
@Repository interface SeriesRepository {
interface SeriesRepository : JpaRepository<Series, Long>, JpaSpecificationExecutor<Series> { fun findAll(): Collection<Series>
@QueryHints(QueryHint(name = CACHEABLE, value = "true")) fun findByIdOrNull(seriesId: Long): Series?
override fun findAll(pageable: Pageable): Page<Series> fun findByLibraryId(libraryId: Long): Collection<Series>
fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection<URL>): Collection<Series>
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
fun findByLibraryIn(libraries: Collection<Library>, sort: Sort): List<Series>
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
fun findByLibraryIn(libraries: Collection<Library>, page: Pageable): Page<Series>
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
fun findByLibraryId(libraryId: Long, sort: Sort): List<Series>
@Query("select s from Series s where s.createdDate <> s.lastModifiedDate")
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
fun findRecentlyUpdated(pageable: Pageable): Page<Series>
@Query("select s from Series s where s.createdDate <> s.lastModifiedDate and s.library in ?1")
@QueryHints(QueryHint(name = CACHEABLE, value = "true"))
fun findRecentlyUpdatedByLibraryIn(libraries: Collection<Library>, pageable: Pageable): Page<Series>
fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Collection<URL>): List<Series>
fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series? fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Series?
fun deleteByLibraryId(libraryId: Long) fun findAll(search: SeriesSearch): Collection<Series>
fun getLibraryId(seriesId: Long): Long?
fun insert(series: Series): Series
fun update(series: Series)
fun delete(seriesId: Long)
fun deleteAll()
fun deleteAll(seriesIds: Collection<Long>)
fun count(): Long
} }

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
@ -17,7 +18,8 @@ private val logger = KotlinLogging.logger {}
class BookAnalyzer( class BookAnalyzer(
private val contentDetector: ContentDetector, private val contentDetector: ContentDetector,
extractors: List<MediaContainerExtractor>, extractors: List<MediaContainerExtractor>,
private val imageConverter: ImageConverter private val imageConverter: ImageConverter,
private val mediaRepository: MediaRepository
) { ) {
val supportedMediaTypes = extractors val supportedMediaTypes = extractors
@ -78,17 +80,19 @@ class BookAnalyzer(
fun regenerateThumbnail(book: Book): Media { fun regenerateThumbnail(book: Book): Media {
logger.info { "Regenerate thumbnail for book: $book" } logger.info { "Regenerate thumbnail for book: $book" }
if (book.media.status != Media.Status.READY) { val media = mediaRepository.findById(book.id)
if (media.status != Media.Status.READY) {
logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" } logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" }
throw MediaNotReadyException() throw MediaNotReadyException()
} }
val thumbnail = generateThumbnail(book, book.media.mediaType!!, book.media.pages.first().fileName) val thumbnail = generateThumbnail(book, media.mediaType!!, media.pages.first().fileName)
return Media( return Media(
mediaType = book.media.mediaType, mediaType = media.mediaType,
status = Media.Status.READY, status = Media.Status.READY,
pages = book.media.pages, pages = media.pages,
thumbnail = thumbnail thumbnail = thumbnail
) )
} }
@ -110,17 +114,19 @@ class BookAnalyzer(
fun getPageContent(book: Book, number: Int): ByteArray { fun getPageContent(book: Book, number: Int): ByteArray {
logger.info { "Get page #$number for book: $book" } logger.info { "Get page #$number for book: $book" }
if (book.media.status != Media.Status.READY) { val media = mediaRepository.findById(book.id)
if (media.status != Media.Status.READY) {
logger.warn { "Book media is not ready, cannot get pages" } logger.warn { "Book media is not ready, cannot get pages" }
throw MediaNotReadyException() throw MediaNotReadyException()
} }
if (number > book.media.pages.size || number <= 0) { if (number > media.pages.size || number <= 0) {
logger.error { "Page number #$number is out of bounds. Book has ${book.media.pages.size} pages" } logger.error { "Page number #$number is out of bounds. Book has ${media.pages.size} pages" }
throw IndexOutOfBoundsException("Page $number does not exist") throw IndexOutOfBoundsException("Page $number does not exist")
} }
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), book.media.pages[number - 1].fileName) return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), media.pages[number - 1].fileName)
} }
@Throws( @Throws(
@ -129,11 +135,13 @@ class BookAnalyzer(
fun getFileContent(book: Book, fileName: String): ByteArray { fun getFileContent(book: Book, fileName: String): ByteArray {
logger.info { "Get file $fileName for book: $book" } logger.info { "Get file $fileName for book: $book" }
if (book.media.status != Media.Status.READY) { val media = mediaRepository.findById(book.id)
if (media.status != Media.Status.READY) {
logger.warn { "Book media is not ready, cannot get files" } logger.warn { "Book media is not ready, cannot get files" }
throw MediaNotReadyException() throw MediaNotReadyException()
} }
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), fileName) return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), fileName)
} }
} }

View file

@ -1,4 +1,4 @@
package org.gotson.komga.application.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
@ -6,8 +6,9 @@ import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -17,30 +18,32 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class BookLifecycle( class BookLifecycle(
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val bookAnalyzer: BookAnalyzer, private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter private val imageConverter: ImageConverter
) { ) {
fun analyzeAndPersist(book: Book) { fun analyzeAndPersist(book: Book) {
logger.info { "Analyze and persist book: $book" } logger.info { "Analyze and persist book: $book" }
try { val media = try {
book.media = bookAnalyzer.analyze(book) bookAnalyzer.analyze(book)
} catch (ex: Exception) { } catch (ex: Exception) {
logger.error(ex) { "Error while analyzing book: $book" } logger.error(ex) { "Error while analyzing book: $book" }
book.media = Media(status = Media.Status.ERROR, comment = ex.message) Media(status = Media.Status.ERROR, comment = ex.message)
} }.copy(bookId = book.id)
bookRepository.save(book) mediaRepository.update(media)
} }
fun regenerateThumbnailAndPersist(book: Book) { fun regenerateThumbnailAndPersist(book: Book) {
logger.info { "Regenerate thumbnail and persist book: $book" } logger.info { "Regenerate thumbnail and persist book: $book" }
try { val media = try {
book.media = bookAnalyzer.regenerateThumbnail(book) bookAnalyzer.regenerateThumbnail(book)
} catch (ex: Exception) { } catch (ex: Exception) {
logger.error(ex) { "Error while recreating thumbnail" } logger.error(ex) { "Error while recreating thumbnail" }
book.media = Media(status = Media.Status.ERROR) Media(status = Media.Status.ERROR)
} }.copy(bookId = book.id)
bookRepository.save(book) mediaRepository.update(media)
} }
@Throws( @Throws(
@ -49,8 +52,9 @@ class BookLifecycle(
IndexOutOfBoundsException::class IndexOutOfBoundsException::class
) )
fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent { fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent {
val media = mediaRepository.findById(book.id)
val pageContent = bookAnalyzer.getPageContent(book, number) val pageContent = bookAnalyzer.getPageContent(book, number)
val pageMediaType = book.media.pages[number - 1].mediaType val pageMediaType = media.pages[number - 1].mediaType
if (resizeTo != null) { if (resizeTo != null) {
val targetFormat = ImageType.JPEG val targetFormat = ImageType.JPEG
@ -88,4 +92,13 @@ class BookLifecycle(
return BookPageContent(number, pageContent, pageMediaType) return BookPageContent(number, pageContent, pageMediaType)
} }
} }
fun delete(bookId: Long) {
logger.info { "Delete book id: $bookId" }
mediaRepository.delete(bookId)
bookMetadataRepository.delete(bookId)
bookRepository.delete(bookId)
}
} }

View file

@ -25,14 +25,14 @@ class FileSystemScanner(
val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub") val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub")
fun scanRootFolder(root: Path): List<Series> { fun scanRootFolder(root: Path): Map<Series, List<Book>> {
logger.info { "Scanning folder: $root" } logger.info { "Scanning folder: $root" }
logger.info { "Supported extensions: $supportedExtensions" } logger.info { "Supported extensions: $supportedExtensions" }
logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" } logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" }
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime) if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
logger.info { "Force directory modified time: active" } logger.info { "Force directory modified time: active" }
lateinit var scannedSeries: List<Series> lateinit var scannedSeries: Map<Series, List<Book>>
measureTime { measureTime {
scannedSeries = Files.walk(root, FileVisitOption.FOLLOW_LINKS).use { dirsStream -> scannedSeries = Files.walk(root, FileVisitOption.FOLLOW_LINKS).use { dirsStream ->
@ -72,13 +72,12 @@ class FileSystemScanner(
fileLastModified = fileLastModified =
if (komgaProperties.filesystemScannerForceDirectoryModifiedTime) if (komgaProperties.filesystemScannerForceDirectoryModifiedTime)
maxOf(dir.getUpdatedTime(), books.map { it.fileLastModified }.max()!!) maxOf(dir.getUpdatedTime(), books.map { it.fileLastModified }.max()!!)
else dir.getUpdatedTime(), else dir.getUpdatedTime()
books = books.toMutableList() ) to books
) }.toMap()
}.toList()
} }
}.also { }.also {
val countOfBooks = scannedSeries.sumBy { it.books.size } val countOfBooks = scannedSeries.values.sumBy { it.size }
logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" } logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" }
} }

View file

@ -1,22 +1,21 @@
package org.gotson.komga.infrastructure.security package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.springframework.security.core.GrantedAuthority import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@Service @Service
class KomgaUserDetailsLifecycle( class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository, private val userRepository: KomgaUserRepository,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry private val sessionRegistry: SessionRegistry
@ -28,39 +27,31 @@ class KomgaUserDetailsLifecycle(
KomgaPrincipal(it) KomgaPrincipal(it)
} ?: throw UsernameNotFoundException(username) } ?: throw UsernameNotFoundException(username)
@Transactional
fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails { fun updatePassword(user: UserDetails, newPassword: String, expireSessions: Boolean): UserDetails {
userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser -> userRepository.findByEmailIgnoreCase(user.username)?.let { komgaUser ->
logger.info { "Changing password for user ${user.username}" } logger.info { "Changing password for user ${user.username}" }
komgaUser.password = passwordEncoder.encode(newPassword) val updatedUser = komgaUser.copy(password = passwordEncoder.encode(newPassword))
userRepository.save(komgaUser) userRepository.save(updatedUser)
if (expireSessions) expireSessions(komgaUser) if (expireSessions) expireSessions(updatedUser)
return KomgaPrincipal(komgaUser) return KomgaPrincipal(updatedUser)
} ?: throw UsernameNotFoundException(user.username) } ?: throw UsernameNotFoundException(user.username)
} }
fun countUsers() = userRepository.count() fun countUsers() = userRepository.count()
@Transactional
@Throws(UserEmailAlreadyExistsException::class) @Throws(UserEmailAlreadyExistsException::class)
fun createUser(user: UserDetails): UserDetails { fun createUser(komgaUser: KomgaUser): KomgaUser {
if (userRepository.existsByEmailIgnoreCase(user.username)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${user.username}") if (userRepository.existsByEmailIgnoreCase(komgaUser.email)) throw UserEmailAlreadyExistsException("A user with the same email already exists: ${komgaUser.email}")
val komgaUser = KomgaUser( val createdUser = userRepository.save(komgaUser.copy(password = passwordEncoder.encode(komgaUser.password)))
email = user.username, logger.info { "User created: $createdUser" }
password = passwordEncoder.encode(user.password), return createdUser
roles = user.authorities.toUserRoles()
)
userRepository.save(komgaUser)
logger.info { "Created user: ${komgaUser.email}, roles: ${komgaUser.roles}" }
return KomgaPrincipal(komgaUser)
} }
fun deleteUser(user: KomgaUser) { fun deleteUser(user: KomgaUser) {
logger.info { "Deleting user: ${user.email}" } logger.info { "Deleting user: $user" }
userRepository.delete(user) userRepository.delete(user)
expireSessions(user) expireSessions(user)
} }
@ -79,16 +70,3 @@ class KomgaUserDetailsLifecycle(
} }
private fun Iterable<GrantedAuthority>.toUserRoles() =
this.filter { it.authority.startsWith("ROLE_") }
.map { it.authority.removePrefix("ROLE_") }
.mapNotNull {
try {
UserRoles.valueOf(it)
} catch (e: Exception) {
null
}
}
.toMutableSet()
class UserEmailAlreadyExistsException(message: String) : Exception(message)

View file

@ -1,4 +1,4 @@
package org.gotson.komga.application.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
@ -6,11 +6,9 @@ import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.nio.file.Files import java.nio.file.Files
@ -19,8 +17,8 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class LibraryLifecycle( class LibraryLifecycle(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val seriesLifecycle: SeriesLifecycle,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val userRepository: KomgaUserRepository,
private val taskReceiver: TaskReceiver private val taskReceiver: TaskReceiver
) { ) {
@ -49,25 +47,19 @@ class LibraryLifecycle(
throw PathContainedInPath("Library path ${library.path()} is a parent of existing library ${it.name}: ${it.path()}") throw PathContainedInPath("Library path ${library.path()} is a parent of existing library ${it.name}: ${it.path()}")
} }
libraryRepository.save(library) return libraryRepository.insert(library).let {
taskReceiver.scanLibrary(library) taskReceiver.scanLibrary(it.id)
it
return library }
} }
@Transactional
fun deleteLibrary(library: Library) { fun deleteLibrary(library: Library) {
logger.info { "Deleting library: ${library.name} with root folder: ${library.root}" } logger.info { "Deleting library: $library" }
logger.info { "Delete all series for this library" } seriesRepository.findByLibraryId(library.id).forEach {
seriesRepository.deleteByLibraryId(library.id) seriesLifecycle.deleteSeries(it.id)
logger.info { "Remove shared library access for all users" }
userRepository.findBySharedLibrariesContaining(library).let { users ->
users.forEach { user -> user.sharedLibraries.removeIf { it.id == library.id } }
userRepository.saveAll(users)
} }
libraryRepository.delete(library) libraryRepository.delete(library.id)
} }
} }

View file

@ -3,9 +3,9 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.file.Paths import java.nio.file.Paths
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@ -15,54 +15,83 @@ private val logger = KotlinLogging.logger {}
class LibraryScanner( class LibraryScanner(
private val fileSystemScanner: FileSystemScanner, private val fileSystemScanner: FileSystemScanner,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val bookRepository: BookRepository private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
private val mediaRepository: MediaRepository,
private val seriesLifecycle: SeriesLifecycle
) { ) {
@Transactional
fun scanRootFolder(library: Library) { fun scanRootFolder(library: Library) {
logger.info { "Updating library: $library" } logger.info { "Updating library: $library" }
val scannedSeries = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI())) val scannedSeries =
fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()))
.map { (series, books) ->
series.copy(libraryId = library.id) to books.map { it.copy(libraryId = library.id) }
}.toMap()
// delete series that don't exist anymore // delete series that don't exist anymore
if (scannedSeries.isEmpty()) { if (scannedSeries.isEmpty()) {
logger.info { "Scan returned no series, deleting all existing series" } logger.info { "Scan returned no series, deleting all existing series" }
seriesRepository.deleteByLibraryId(library.id) seriesRepository.findByLibraryId(library.id).forEach {
seriesLifecycle.deleteSeries(it.id)
}
} else { } else {
scannedSeries.map { it.url }.let { urls -> scannedSeries.keys.map { it.url }.let { urls ->
seriesRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach { seriesRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach {
logger.info { "Deleting series not on disk anymore: $it" } logger.info { "Deleting series not on disk anymore: $it" }
seriesRepository.delete(it) seriesLifecycle.deleteSeries(it.id)
} }
} }
} }
scannedSeries.forEach { newSeries -> scannedSeries.forEach { (newSeries, newBooks) ->
val existingSeries = seriesRepository.findByLibraryIdAndUrl(library.id, newSeries.url) val existingSeries = seriesRepository.findByLibraryIdAndUrl(library.id, newSeries.url)
// if series does not exist, save it // if series does not exist, save it
if (existingSeries == null) { if (existingSeries == null) {
logger.info { "Adding new series: $newSeries" } logger.info { "Adding new series: $newSeries" }
seriesRepository.save(newSeries.also { it.library = library }) val createdSeries = seriesLifecycle.createSeries(newSeries)
seriesLifecycle.addBooks(createdSeries, newBooks)
seriesLifecycle.sortBooks(createdSeries)
} else { } else {
// if series already exists, update it // if series already exists, update it
if (newSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) { if (newSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingSeries.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
logger.info { "Series changed on disk, updating: $existingSeries" } logger.info { "Series changed on disk, updating: $existingSeries" }
existingSeries.fileLastModified = newSeries.fileLastModified existingSeries.fileLastModified = newSeries.fileLastModified
seriesRepository.update(existingSeries)
// update list of books with existing entities if they exist // update list of books with existing entities if they exist
existingSeries.books = newSeries.books.map { newBook -> val existingBooks = bookRepository.findBySeriesId(existingSeries.id)
val existingBook = bookRepository.findByUrl(newBook.url) ?: newBook
if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) { // update existing books
logger.info { "Book changed on disk, update and reset media status: $existingBook" } newBooks.forEach { newBook ->
existingBook.fileLastModified = newBook.fileLastModified existingBooks.find { it.url == newBook.url }?.let { existingBook ->
existingBook.fileSize = newBook.fileSize if (newBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS) != existingBook.fileLastModified.truncatedTo(ChronoUnit.MILLIS)) {
existingBook.media.reset() logger.info { "Book changed on disk, update and reset media status: $existingBook" }
val updatedBook = existingBook.copy(
fileLastModified = newBook.fileLastModified,
fileSize = newBook.fileSize
)
mediaRepository.findById(existingBook.id).let {
mediaRepository.update(it.reset())
}
bookRepository.update(updatedBook)
}
} }
existingBook }
}.toMutableList()
seriesRepository.save(existingSeries) // remove books not present anymore
existingBooks
.filterNot { existingBook -> newBooks.map { it.url }.contains(existingBook.url) }
.forEach { bookLifecycle.delete(it.id) }
// add new books
val booksToAdd = newBooks.filterNot { newBook -> existingBooks.map { it.url }.contains(newBook.url) }
seriesLifecycle.addBooks(existingSeries, booksToAdd)
// sort all books
seriesLifecycle.sortBooks(existingSeries)
} }
} }
} }

View file

@ -1,9 +1,9 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesMetadataPatch import org.gotson.komga.domain.model.SeriesMetadataPatch
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -12,113 +12,32 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class MetadataApplier { class MetadataApplier {
fun apply(patch: BookMetadataPatch, book: Book) { private fun <T> getIfNotLocked(original: T, patched: T?, lock: Boolean): T =
logger.debug { "Apply metadata for book: $book" } if (patched != null && !lock) patched
else original
with(book.metadata) { fun apply(patch: BookMetadataPatch, metadata: BookMetadata): BookMetadata =
patch.title?.let { with(metadata) {
if (!titleLock) { copy(
logger.debug { "Update title: $it" } title = getIfNotLocked(title, patch.title, titleLock),
title = it summary = getIfNotLocked(summary, patch.summary, summaryLock),
} else number = getIfNotLocked(number, patch.number, numberLock),
logger.debug { "title is locked, skipping" } numberSort = getIfNotLocked(numberSort, patch.numberSort, numberSortLock),
} readingDirection = getIfNotLocked(readingDirection, patch.readingDirection, readingDirectionLock),
releaseDate = getIfNotLocked(releaseDate, patch.releaseDate, releaseDateLock),
ageRating = getIfNotLocked(ageRating, patch.ageRating, ageRatingLock),
patch.summary?.let { publisher = getIfNotLocked(publisher, patch.publisher, publisherLock),
if (!summaryLock) { authors = getIfNotLocked(authors, patch.authors, authorsLock)
logger.debug { "Update summary: $it" } )
summary = it
} else
logger.debug { "summary is locked, skipping" }
}
patch.number?.let {
if (!numberLock) {
logger.debug { "Update number: $it" }
number = it
} else
logger.debug { "number is locked, skipping" }
}
patch.numberSort?.let {
if (!numberSortLock) {
logger.debug { "Update numberSort: $it" }
numberSort = it
} else
logger.debug { "numberSort is locked, skipping" }
}
patch.readingDirection?.let {
if (!readingDirectionLock) {
logger.debug { "Update readingDirection: $it" }
readingDirection = it
} else
logger.debug { "readingDirection is locked, skipping" }
}
patch.releaseDate?.let {
if (!releaseDateLock) {
logger.debug { "Update releaseDate: $it" }
releaseDate = it
} else
logger.debug { "releaseDate is locked, skipping" }
}
patch.ageRating?.let {
if (!ageRatingLock) {
logger.debug { "Update ageRating: $it" }
ageRating = it
} else
logger.debug { "ageRating is locked, skipping" }
}
patch.publisher?.let {
if (!publisherLock) {
logger.debug { "Update publisher: $it" }
publisher = it
} else
logger.debug { "publisher is locked, skipping" }
}
patch.authors?.let {
if (!authorsLock) {
logger.debug { "Update authors: $it" }
authors = it.toMutableList()
} else
logger.debug { "authors is locked, skipping" }
}
} }
}
fun apply(patch: SeriesMetadataPatch, series: Series) { fun apply(patch: SeriesMetadataPatch, metadata: SeriesMetadata): SeriesMetadata =
logger.debug { "Apply metadata for series: $series" } with(metadata) {
copy(
with(series.metadata) { status = getIfNotLocked(status, patch.status, statusLock),
patch.title?.let { title = getIfNotLocked(title, patch.title, titleLock),
if (!titleLock) { titleSort = getIfNotLocked(titleSort, patch.titleSort, titleSortLock)
logger.debug { "Update title: $it" } )
title = it
} else
logger.debug { "title is locked, skipping" }
}
patch.titleSort?.let {
if (!titleSortLock) {
logger.debug { "Update titleSort: $it" }
titleSort = it
} else
logger.debug { "titleSort is locked, skipping" }
}
patch.status?.let {
if (!statusLock) {
logger.debug { "status number: $it" }
status = it
} else
logger.debug { "status is locked, skipping" }
}
} }
}
} }

View file

@ -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)
}
}
}
}
}
}

View file

@ -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)
}
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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()
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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()
}

View file

@ -2,7 +2,8 @@ package org.gotson.komga.infrastructure.metadata
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.Media
interface BookMetadataProvider { interface BookMetadataProvider {
fun getBookMetadataFromBook(book: Book): BookMetadataPatch? fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch?
} }

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SeriesMetadataPatch import org.gotson.komga.domain.model.SeriesMetadataPatch
import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
@ -25,8 +26,8 @@ class ComicInfoProvider(
private val bookAnalyzer: BookAnalyzer private val bookAnalyzer: BookAnalyzer
) : BookMetadataProvider { ) : BookMetadataProvider {
override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? { override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? {
getComicInfo(book)?.let { comicInfo -> getComicInfo(book, media)?.let { comicInfo ->
val releaseDate = comicInfo.year?.let { val releaseDate = comicInfo.year?.let {
LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1) LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1)
} }
@ -66,9 +67,9 @@ class ComicInfoProvider(
return null return null
} }
private fun getComicInfo(book: Book): ComicInfo? { private fun getComicInfo(book: Book, media: Media): ComicInfo? {
try { try {
if (book.media.files.none { it == COMIC_INFO }) { if (media.files.none { it == COMIC_INFO }) {
logger.debug { "Book does not contain any $COMIC_INFO file: $book" } logger.debug { "Book does not contain any $COMIC_INFO file: $book" }
return null return null
} }

View file

@ -4,6 +4,7 @@ import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SeriesMetadataPatch import org.gotson.komga.domain.model.SeriesMetadataPatch
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
@ -26,8 +27,8 @@ class EpubMetadataProvider(
"ill" to "penciller" "ill" to "penciller"
) )
override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? { override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? {
if (book.media.mediaType != "application/epub+zip") return null if (media.mediaType != "application/epub+zip") return null
epubExtractor.getPackageFile(book.path())?.let { packageFile -> epubExtractor.getPackageFile(book.path())?.let { packageFile ->
val opf = Jsoup.parse(packageFile.toString()) val opf = Jsoup.parse(packageFile.toString())

View file

@ -9,13 +9,10 @@ class KomgaPrincipal(
val user: KomgaUser val user: KomgaUser
) : UserDetails { ) : UserDetails {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> { override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
return user.roles.map { it.name } user.roles()
.toMutableSet()
.apply { add("USER") }
.map { SimpleGrantedAuthority("ROLE_$it") } .map { SimpleGrantedAuthority("ROLE_$it") }
.toMutableSet() .toMutableSet()
}
override fun isEnabled() = true override fun isEnabled() = true

View file

@ -1,15 +1,19 @@
package org.gotson.komga.interfaces.opds package org.gotson.komga.interfaces.opds
import com.github.klinq.jpaspec.`in`
import com.github.klinq.jpaspec.likeLower
import com.github.klinq.jpaspec.toJoin
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.opds.dto.OpdsAuthor import org.gotson.komga.interfaces.opds.dto.OpdsAuthor
@ -26,9 +30,6 @@ import org.gotson.komga.interfaces.opds.dto.OpdsLinkPageStreaming
import org.gotson.komga.interfaces.opds.dto.OpdsLinkRel import org.gotson.komga.interfaces.opds.dto.OpdsLinkRel
import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch
import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
@ -63,8 +64,12 @@ private const val ID_LIBRARIES_ALL = "allLibraries"
@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE]) @RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
class OpdsController( class OpdsController(
servletContext: ServletContext, servletContext: ServletContext,
private val libraryRepository: LibraryRepository,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val libraryRepository: LibraryRepository private val seriesMetadataRepository: SeriesMetadataRepository,
private val bookRepository: BookRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val mediaRepository: MediaRepository
) { ) {
private val routeBase = "${servletContext.contextPath}$ROUTE_BASE" private val routeBase = "${servletContext.contextPath}$ROUTE_BASE"
@ -129,23 +134,16 @@ class OpdsController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam("search") searchTerm: String? @RequestParam("search") searchTerm: String?
): OpdsFeed { ): OpdsFeed {
val sort = Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase()) val seriesSearch = SeriesSearch(
val series = libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet(),
mutableListOf<Specification<Series>>().let { specs -> searchTerm = searchTerm
if (!principal.user.sharedAllLibraries) { )
specs.add(Series::library.`in`(principal.user.sharedLibraries))
}
if (!searchTerm.isNullOrEmpty()) { val entries = seriesRepository.findAll(seriesSearch)
specs.add(Series::metadata.toJoin().where(SeriesMetadata::title).likeLower("%$searchTerm%")) .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
} .sortedBy { it.metadata.titleSort.toLowerCase() }
.map { it.toOpdsEntry() }
if (specs.isNotEmpty()) {
seriesRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, sort)
} else {
seriesRepository.findAll(sort)
}
}
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_SERIES_ALL, id = ID_SERIES_ALL,
@ -156,7 +154,7 @@ class OpdsController(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL"),
linkStart linkStart
), ),
entries = series.map { it.toOpdsEntry() } entries = entries
) )
} }
@ -164,13 +162,14 @@ class OpdsController(
fun getLatestSeries( fun getLatestSeries(
@AuthenticationPrincipal principal: KomgaPrincipal @AuthenticationPrincipal principal: KomgaPrincipal
): OpdsFeed { ): OpdsFeed {
val sort = Sort.by(Sort.Direction.DESC, "lastModifiedDate") val seriesSearch = SeriesSearch(
val series = libraryIds = if (!principal.user.sharedAllLibraries) principal.user.sharedLibrariesIds else emptySet()
if (principal.user.sharedAllLibraries) { )
seriesRepository.findAll(sort)
} else { val entries = seriesRepository.findAll(seriesSearch)
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, sort) .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
} .sortedBy { it.series.lastModifiedDate }
.map { it.toOpdsEntry() }
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_SERIES_LATEST, id = ID_SERIES_LATEST,
@ -181,7 +180,7 @@ class OpdsController(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST"),
linkStart linkStart
), ),
entries = series.map { it.toOpdsEntry() } entries = entries
) )
} }
@ -193,7 +192,7 @@ class OpdsController(
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
libraryRepository.findAll() libraryRepository.findAll()
} else { } else {
principal.user.sharedLibraries libraryRepository.findAllById(principal.user.sharedLibrariesIds)
} }
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_LIBRARIES_ALL, id = ID_LIBRARIES_ALL,
@ -217,19 +216,27 @@ class OpdsController(
seriesRepository.findByIdOrNull(id)?.let { series -> seriesRepository.findByIdOrNull(id)?.let { series ->
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
val books = bookRepository.findAll(BookSearch(
seriesIds = listOf(id),
mediaStatus = setOf(Media.Status.READY)
))
val metadata = seriesMetadataRepository.findById(series.id)
val entries = books
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) }
.sortedBy { it.metadata.numberSort }
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
OpdsFeedAcquisition( OpdsFeedAcquisition(
id = series.id.toString(), id = series.id.toString(),
title = series.metadata.title, title = metadata.title,
updated = series.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id"),
linkStart linkStart
), ),
entries = series.books entries = entries
.filter { it.media.status == Media.Status.READY }
.sortedBy { it.metadata.numberSort }
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -241,53 +248,60 @@ class OpdsController(
libraryRepository.findByIdOrNull(id)?.let { library -> libraryRepository.findByIdOrNull(id)?.let { library ->
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
val seriesSearch = SeriesSearch(libraryIds = setOf(library.id))
val entries = seriesRepository.findAll(seriesSearch)
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
.sortedBy { it.metadata.titleSort.toLowerCase() }
.map { it.toOpdsEntry() }
OpdsFeedNavigation( OpdsFeedNavigation(
id = library.id.toString(), id = library.id.toString(),
title = library.name, title = library.name,
updated = library.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id"),
linkStart linkStart
), ),
entries = seriesRepository.findByLibraryId(library.id, Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())).map { it.toOpdsEntry() } entries = entries
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
private fun Series.toOpdsEntry() = private fun SeriesWithInfo.toOpdsEntry(): OpdsEntryNavigation =
OpdsEntryNavigation( OpdsEntryNavigation(
title = metadata.title, title = metadata.title,
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(), id = series.id.toString(),
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/$id") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}")
) )
private fun Book.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition { private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition {
val mediaTypes = media.pages.map { it.mediaType }.distinct() val mediaTypes = media.pages.map { it.mediaType }.distinct()
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) { val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/$id/pages/{pageNumber}?zero_based=true", media.pages.size) OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/${book.id}/pages/{pageNumber}?zero_based=true", media.pages.size)
} else { } else {
OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/$id/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size) OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size)
} }
return OpdsEntryAcquisition( return OpdsEntryAcquisition(
title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}", title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(), id = book.id.toString(),
content = run { content = run {
var content = "${fileExtension().toUpperCase()} - ${fileSizeHumanReadable()}" var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
if (metadata.summary.isNotBlank()) if (metadata.summary.isNotBlank())
content += "\n\n${metadata.summary}" content += "\n\n${metadata.summary}"
content content
}, },
authors = metadata.authors.map { OpdsAuthor(it.name) }, authors = metadata.authors.map { OpdsAuthor(it.name) },
links = listOf( links = listOf(
OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/$id/thumbnail"), OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/${book.id}/thumbnail"),
OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/$id/pages/1"), OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/${book.id}/pages/1"),
OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/$id/file/${fileName()}"), OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/${book.id}/file/${book.fileName()}"),
opdsLinkPageStreaming opdsLinkPageStreaming
) )
) )
@ -296,7 +310,7 @@ class OpdsController(
private fun Library.toOpdsEntry(): OpdsEntryNavigation { private fun Library.toOpdsEntry(): OpdsEntryNavigation {
return OpdsEntryNavigation( return OpdsEntryNavigation(
title = name, title = name,
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(), id = id.toString(),
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
@ -305,4 +319,15 @@ class OpdsController(
private fun shouldPrependBookNumbers(userAgent: String) = private fun shouldPrependBookNumbers(userAgent: String) =
userAgent.contains("chunky", ignoreCase = true) userAgent.contains("chunky", ignoreCase = true)
private data class BookWithInfo(
val book: Book,
val media: Media,
val metadata: BookMetadata
)
private data class SeriesWithInfo(
val series: Series,
val metadata: SeriesMetadata
)
} }

View file

@ -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) }
}
}

View file

@ -1,25 +1,21 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import com.github.klinq.jpaspec.`in`
import com.github.klinq.jpaspec.likeLower
import com.github.klinq.jpaspec.toJoin
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.service.BookLifecycle
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
@ -27,14 +23,13 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.PageDto import org.gotson.komga.interfaces.rest.dto.PageDto
import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.springframework.core.io.FileSystemResource import org.springframework.core.io.FileSystemResource
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.CacheControl import org.springframework.http.CacheControl
import org.springframework.http.ContentDisposition import org.springframework.http.ContentDisposition
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
@ -58,7 +53,6 @@ import java.io.FileNotFoundException
import java.nio.file.NoSuchFileException import java.nio.file.NoSuchFileException
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.persistence.criteria.JoinType
import javax.validation.Valid import javax.validation.Valid
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -66,9 +60,12 @@ private val logger = KotlinLogging.logger {}
@RestController @RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
class BookController( class BookController(
private val bookRepository: BookRepository, private val taskReceiver: TaskReceiver,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val taskReceiver: TaskReceiver private val bookRepository: BookRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val mediaRepository: MediaRepository,
private val bookDtoRepository: BookDtoRepository
) { ) {
@PageableAsQueryParam @PageableAsQueryParam
@ -87,45 +84,21 @@ class BookController(
else Sort.by(Sort.Order.asc("metadata.title").ignoreCase()) else Sort.by(Sort.Order.asc("metadata.title").ignoreCase())
) )
return mutableListOf<Specification<Book>>().let { specs -> val bookSearch = BookSearch(
when { libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries searchTerm = searchTerm,
!principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> { mediaStatus = mediaStatus ?: emptyList()
val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id }) )
if (authorizedLibraryIDs.isEmpty()) return@let Page.empty<Book>(pageRequest)
else specs.add(Book::series.toJoin().join(Series::library, JoinType.INNER).where(Library::id).`in`(authorizedLibraryIDs))
}
// limited user: filter on user's authorized libraries return bookDtoRepository.findAll(bookSearch, pageRequest)
!principal.user.sharedAllLibraries -> specs.add(Book::series.toJoin().where(Series::library).`in`(principal.user.sharedLibraries)) .map { it.restrictUrl(!principal.user.roleAdmin) }
// non-limited user: filter on provided libraries
!libraryIds.isNullOrEmpty() -> {
specs.add(Book::series.toJoin().join(Series::library, JoinType.INNER).where(Library::id).`in`(libraryIds))
}
}
if (!searchTerm.isNullOrEmpty()) {
specs.add(Book::metadata.toJoin().where(BookMetadata::title).likeLower("%$searchTerm%"))
}
if (!mediaStatus.isNullOrEmpty()) {
specs.add(Book::media.toJoin().where(Media::status).`in`(mediaStatus))
}
if (specs.isNotEmpty()) {
bookRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, pageRequest)
} else {
bookRepository.findAll(pageRequest)
}
}.map { it.toDto(includeFullUrl = principal.user.isAdmin()) }
} }
@Operation(description = "Return newly added or updated books.") @Operation(description = "Return newly added or updated books.")
@PageableWithoutSortAsQueryParam @PageableWithoutSortAsQueryParam
@GetMapping("api/v1/books/latest") @GetMapping("api/v1/books/latest")
fun getLatestSeries( fun getLatestBooks(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@Parameter(hidden = true) page: Pageable @Parameter(hidden = true) page: Pageable
): Page<BookDto> { ): Page<BookDto> {
@ -135,11 +108,14 @@ class BookController(
Sort.by(Sort.Direction.DESC, "lastModifiedDate") Sort.by(Sort.Direction.DESC, "lastModifiedDate")
) )
return if (principal.user.sharedAllLibraries) { val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
bookRepository.findAll(pageRequest)
} else { return bookDtoRepository.findAll(
bookRepository.findBySeriesLibraryIn(principal.user.sharedLibraries, pageRequest) BookSearch(
}.map { it.toDto(includeFullUrl = principal.user.isAdmin()) } libraryIds = libraryIds
),
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@ -148,42 +124,39 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): BookDto = ): BookDto =
bookRepository.findByIdOrNull(bookId)?.let { bookDtoRepository.findByIdOrNull(bookId)?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.toDto(includeFullUrl = principal.user.isAdmin()) it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping("api/v1/books/{bookId}/previous") @GetMapping("api/v1/books/{bookId}/previous")
fun getBookSiblingPrevious( fun getBookSiblingPrevious(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): BookDto = ): BookDto {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.getLibraryId(bookId)?.let {
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
val previousBook = book.series.books
.sortedByDescending { it.metadata.numberSort }
.find { it.metadata.numberSort < book.metadata.numberSort }
previousBook?.toDto(includeFullUrl = principal.user.isAdmin())
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findPreviousInSeries(bookId)
?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("api/v1/books/{bookId}/next") @GetMapping("api/v1/books/{bookId}/next")
fun getBookSiblingNext( fun getBookSiblingNext(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): BookDto = ): BookDto {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.getLibraryId(bookId)?.let {
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
val nextBook = book.series.books
.sortedBy { it.metadata.numberSort }
.find { it.metadata.numberSort > book.metadata.numberSort }
nextBook?.toDto(includeFullUrl = principal.user.isAdmin()) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findNextInSeries(bookId)
?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = [ @GetMapping(value = [
@ -193,16 +166,18 @@ class BookController(
fun getBookThumbnail( fun getBookThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.getLibraryId(bookId)?.let {
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (book.media.thumbnail != null) {
ResponseEntity.ok()
.setCachePrivate()
.body(book.media.thumbnail)
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return mediaRepository.getThumbnail(bookId)?.let {
ResponseEntity.ok()
.setCachePrivate()
.body(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@Operation(description = "Download the book file.") @Operation(description = "Download the book file.")
@GetMapping(value = [ @GetMapping(value = [
"api/v1/books/{bookId}/file", "api/v1/books/{bookId}/file",
@ -216,6 +191,7 @@ class BookController(
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try { try {
val media = mediaRepository.findById(book.id)
with(FileSystemResource(book.path())) { with(FileSystemResource(book.path())) {
if (!exists()) throw FileNotFoundException(path) if (!exists()) throw FileNotFoundException(path)
ResponseEntity.ok() ResponseEntity.ok()
@ -224,7 +200,7 @@ class BookController(
.filename(book.fileName()) .filename(book.fileName())
.build() .build()
}) })
.contentType(getMediaTypeOrDefault(book.media.mediaType)) .contentType(getMediaTypeOrDefault(media.mediaType))
.body(this) .body(this)
} }
} catch (ex: FileNotFoundException) { } catch (ex: FileNotFoundException) {
@ -239,12 +215,14 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): List<PageDto> = ): List<PageDto> =
bookRepository.findByIdOrNull((bookId))?.let { bookRepository.findByIdOrNull((bookId))?.let { book ->
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (it.media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
if (it.media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
it.media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) } val media = mediaRepository.findById(book.id)
if (media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
if (media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content( @ApiResponse(content = [Content(
@ -266,10 +244,11 @@ class BookController(
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean @RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book -> bookRepository.findByIdOrNull((bookId))?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) { val media = mediaRepository.findById(bookId)
if (request.checkNotModified(getBookLastModified(media))) {
return@let ResponseEntity return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED) .status(HttpStatus.NOT_MODIFIED)
.setNotModified(book) .setNotModified(media)
.body(ByteArray(0)) .body(ByteArray(0))
} }
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
@ -287,7 +266,7 @@ class BookController(
ResponseEntity.ok() ResponseEntity.ok()
.contentType(getMediaTypeOrDefault(pageContent.mediaType)) .contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(book) .setNotModified(media)
.body(pageContent.content) .body(pageContent.content)
} catch (ex: IndexOutOfBoundsException) { } catch (ex: IndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
@ -313,10 +292,11 @@ class BookController(
@PathVariable pageNumber: Int @PathVariable pageNumber: Int
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book -> bookRepository.findByIdOrNull((bookId))?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) { val media = mediaRepository.findById(bookId)
if (request.checkNotModified(getBookLastModified(media))) {
return@let ResponseEntity return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED) .status(HttpStatus.NOT_MODIFIED)
.setNotModified(book) .setNotModified(media)
.body(ByteArray(0)) .body(ByteArray(0))
} }
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
@ -325,7 +305,7 @@ class BookController(
ResponseEntity.ok() ResponseEntity.ok()
.contentType(getMediaTypeOrDefault(pageContent.mediaType)) .contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(book) .setNotModified(media)
.body(pageContent.content) .body(pageContent.content)
} catch (ex: IndexOutOfBoundsException) { } catch (ex: IndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
@ -364,36 +344,33 @@ class BookController(
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") @Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
@Valid @RequestBody newMetadata: BookMetadataUpdateDto @Valid @RequestBody newMetadata: BookMetadataUpdateDto
): BookDto = ): BookDto =
bookRepository.findByIdOrNull(bookId)?.let { book -> bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
with(newMetadata) { val updated = with(newMetadata) {
title?.let { book.metadata.title = it } existing.copy(
titleLock?.let { book.metadata.titleLock = it } title = title ?: existing.title,
summary?.let { book.metadata.summary = it } titleLock = titleLock ?: existing.titleLock,
summaryLock?.let { book.metadata.summaryLock = it } summary = summary ?: existing.summary,
number?.let { book.metadata.number = it } summaryLock = summaryLock ?: existing.summaryLock,
numberLock?.let { book.metadata.numberLock = it } number = number ?: existing.number,
numberSort?.let { book.metadata.numberSort = it } numberLock = numberLock ?: existing.numberLock,
numberSortLock?.let { book.metadata.numberSortLock = it } numberSort = numberSort ?: existing.numberSort,
if (isSet("readingDirection")) book.metadata.readingDirection = newMetadata.readingDirection numberSortLock = numberSortLock ?: existing.numberSortLock,
readingDirectionLock?.let { book.metadata.readingDirectionLock = it } readingDirection = if (isSet("readingDirection")) readingDirection else existing.readingDirection,
publisher?.let { book.metadata.publisher = it } readingDirectionLock = readingDirectionLock ?: existing.readingDirectionLock,
publisherLock?.let { book.metadata.publisherLock = it } publisher = publisher ?: existing.publisher,
if (isSet("ageRating")) book.metadata.ageRating = newMetadata.ageRating publisherLock = publisherLock ?: existing.publisherLock,
ageRatingLock?.let { book.metadata.ageRatingLock = it } ageRating = if (isSet("ageRating")) ageRating else existing.ageRating,
if (isSet("releaseDate")) { ageRatingLock = ageRatingLock ?: existing.ageRatingLock,
book.metadata.releaseDate = newMetadata.releaseDate releaseDate = if (isSet("releaseDate")) releaseDate else existing.releaseDate,
} releaseDateLock = releaseDateLock ?: existing.releaseDateLock,
releaseDateLock?.let { book.metadata.releaseDateLock = it } authors = if (isSet("authors")) {
if (isSet("authors")) { if (authors != null) authors!!.map { Author(it.name ?: "", it.role ?: "") } else emptyList()
if (authors != null) { } else existing.authors,
book.metadata.authors = authors!!.map { authorsLock = authorsLock ?: existing.authorsLock
Author(it.name ?: "", it.role ?: "") )
}.toMutableList()
} else book.metadata.authors = mutableListOf()
}
authorsLock?.let { book.metadata.authorsLock = it }
} }
bookRepository.save(book).toDto(includeFullUrl = true) bookMetadataRepository.update(updated)
bookDtoRepository.findByIdOrNull(bookId)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
private fun ResponseEntity.BodyBuilder.setCachePrivate() = private fun ResponseEntity.BodyBuilder.setCachePrivate() =
@ -402,11 +379,11 @@ class BookController(
.mustRevalidate() .mustRevalidate()
) )
private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) = private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) =
this.setCachePrivate().lastModified(getBookLastModified(book)) this.setCachePrivate().lastModified(getBookLastModified(media))
private fun getBookLastModified(book: Book) = private fun getBookLastModified(media: Media) =
book.media.lastModifiedDate!!.toInstant(ZoneOffset.UTC).toEpochMilli() media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli()
private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {

View file

@ -1,13 +1,12 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle import org.gotson.komga.interfaces.rest.dto.UserDto
import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.dto.toDto
import org.springframework.context.annotation.Profile import org.springframework.context.annotation.Profile
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.security.core.userdetails.User
import org.springframework.validation.annotation.Validated import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestHeader
@ -22,7 +21,7 @@ import javax.validation.constraints.NotBlank
@RequestMapping("api/v1/claim", produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping("api/v1/claim", produces = [MediaType.APPLICATION_JSON_VALUE])
@Validated @Validated
class ClaimController( class ClaimController(
private val userDetailsLifecycle: KomgaUserDetailsLifecycle private val userDetailsLifecycle: KomgaUserLifecycle
) { ) {
@PostMapping @PostMapping
fun claimAdmin( fun claimAdmin(
@ -32,11 +31,12 @@ class ClaimController(
if (userDetailsLifecycle.countUsers() > 0) if (userDetailsLifecycle.countUsers() > 0)
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed")
return (userDetailsLifecycle.createUser( return userDetailsLifecycle.createUser(
User.withUsername(email) KomgaUser(
.password(password) email = email,
.roles(UserRoles.ADMIN.name) password = password,
.build()) as KomgaPrincipal roleAdmin = true
).toDto() )
).toDto()
} }
} }

View file

@ -1,7 +1,6 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.service.LibraryLifecycle
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.DirectoryNotFoundException import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.DuplicateNameException
@ -9,8 +8,8 @@ import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
@ -33,10 +32,10 @@ private val logger = KotlinLogging.logger {}
@RestController @RestController
@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE])
class LibraryController( class LibraryController(
private val taskReceiver: TaskReceiver,
private val libraryLifecycle: LibraryLifecycle, private val libraryLifecycle: LibraryLifecycle,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository, private val bookRepository: BookRepository
private val taskReceiver: TaskReceiver
) { ) {
@GetMapping @GetMapping
@ -46,8 +45,8 @@ class LibraryController(
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
libraryRepository.findAll() libraryRepository.findAll()
} else { } else {
principal.user.sharedLibraries libraryRepository.findAllById(principal.user.sharedLibrariesIds)
}.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.isAdmin()) } }.sortedBy { it.name }.map { it.toDto(includeRoot = principal.user.roleAdmin) }
@GetMapping("{id}") @GetMapping("{id}")
fun getOne( fun getOne(
@ -56,7 +55,7 @@ class LibraryController(
): LibraryDto = ): LibraryDto =
libraryRepository.findByIdOrNull(id)?.let { libraryRepository.findByIdOrNull(id)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.toDto(includeRoot = principal.user.isAdmin()) it.toDto(includeRoot = principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PostMapping @PostMapping
@ -66,7 +65,7 @@ class LibraryController(
@Valid @RequestBody library: LibraryCreationDto @Valid @RequestBody library: LibraryCreationDto
): LibraryDto = ): LibraryDto =
try { try {
libraryLifecycle.addLibrary(Library(library.name, library.root)).toDto(includeRoot = principal.user.isAdmin()) libraryLifecycle.addLibrary(Library(library.name, library.root)).toDto(includeRoot = principal.user.roleAdmin)
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is FileNotFoundException, is FileNotFoundException,
@ -92,7 +91,7 @@ class LibraryController(
@ResponseStatus(HttpStatus.ACCEPTED) @ResponseStatus(HttpStatus.ACCEPTED)
fun scan(@PathVariable libraryId: Long) { fun scan(@PathVariable libraryId: Long) {
libraryRepository.findByIdOrNull(libraryId)?.let { library -> libraryRepository.findByIdOrNull(libraryId)?.let { library ->
taskReceiver.scanLibrary(library) taskReceiver.scanLibrary(library.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@ -100,18 +99,18 @@ class LibraryController(
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED) @ResponseStatus(HttpStatus.ACCEPTED)
fun analyze(@PathVariable libraryId: Long) { fun analyze(@PathVariable libraryId: Long) {
libraryRepository.findByIdOrNull(libraryId)?.let { library -> bookRepository.findAllIdByLibraryId(libraryId).forEach {
bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.analyzeBook(it) } taskReceiver.analyzeBook(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) }
} }
@PostMapping("{libraryId}/metadata/refresh") @PostMapping("{libraryId}/metadata/refresh")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED) @ResponseStatus(HttpStatus.ACCEPTED)
fun refreshMetadata(@PathVariable libraryId: Long) { fun refreshMetadata(@PathVariable libraryId: Long) {
libraryRepository.findByIdOrNull(libraryId)?.let { library -> bookRepository.findAllIdByLibraryId(libraryId).forEach {
bookRepository.findBySeriesLibrary(library).forEach { taskReceiver.refreshBookMetadata(it) } taskReceiver.refreshBookMetadata(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) }
} }
} }

View file

@ -1,8 +1,5 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import com.github.klinq.jpaspec.`in`
import com.github.klinq.jpaspec.likeLower
import com.github.klinq.jpaspec.toJoin
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Content
@ -10,11 +7,12 @@ import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
@ -22,14 +20,13 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.springdoc.api.annotations.ParameterObject import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@ -52,19 +49,23 @@ private val logger = KotlinLogging.logger {}
@RestController @RestController
@RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE])
class SeriesController( class SeriesController(
private val taskReceiver: TaskReceiver,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val seriesDtoRepository: SeriesDtoRepository,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val bookController: BookController, private val bookDtoRepository: BookDtoRepository,
private val taskReceiver: TaskReceiver private val bookController: BookController
) { ) {
@PageableAsQueryParam
@GetMapping @GetMapping
fun getAllSeries( fun getAllSeries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?, @RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?, @RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?, @RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
@ParameterObject page: Pageable @Parameter(hidden = true) page: Pageable
): Page<SeriesDto> { ): Page<SeriesDto> {
val pageRequest = PageRequest.of( val pageRequest = PageRequest.of(
page.pageNumber, page.pageNumber,
@ -73,38 +74,14 @@ class SeriesController(
else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase()) else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
) )
return mutableListOf<Specification<Series>>().let { specs -> val seriesSearch = SeriesSearch(
when { libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
// limited user & libraryIds are specified: filter on provided libraries intersecting user's authorized libraries searchTerm = searchTerm,
!principal.user.sharedAllLibraries && !libraryIds.isNullOrEmpty() -> { metadataStatus = metadataStatus ?: emptyList()
val authorizedLibraryIDs = libraryIds.intersect(principal.user.sharedLibraries.map { it.id }) )
if (authorizedLibraryIDs.isEmpty()) return@let Page.empty<Series>(pageRequest)
else specs.add(Series::library.toJoin().where(Library::id).`in`(authorizedLibraryIDs))
}
// limited user: filter on user's authorized libraries return seriesDtoRepository.findAll(seriesSearch, pageRequest)
!principal.user.sharedAllLibraries -> specs.add(Series::library.`in`(principal.user.sharedLibraries)) .map { it.restrictUrl(!principal.user.roleAdmin) }
// non-limited user: filter on provided libraries
!libraryIds.isNullOrEmpty() -> {
specs.add(Series::library.toJoin().where(Library::id).`in`(libraryIds))
}
}
if (!searchTerm.isNullOrEmpty()) {
specs.add(Series::metadata.toJoin().where(SeriesMetadata::title).likeLower("%$searchTerm%"))
}
if (!metadataStatus.isNullOrEmpty()) {
specs.add(Series::metadata.toJoin().where(SeriesMetadata::status).`in`(metadataStatus))
}
if (specs.isNotEmpty()) {
seriesRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, pageRequest)
} else {
seriesRepository.findAll(pageRequest)
}
}.map { it.toDto(includeUrl = principal.user.isAdmin()) }
} }
@Operation(description = "Return recently added or updated series.") @Operation(description = "Return recently added or updated series.")
@ -120,11 +97,12 @@ class SeriesController(
Sort.by(Sort.Direction.DESC, "lastModifiedDate") Sort.by(Sort.Direction.DESC, "lastModifiedDate")
) )
return if (principal.user.sharedAllLibraries) { val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
seriesRepository.findAll(pageRequest)
} else { return seriesDtoRepository.findAll(
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest) SeriesSearch(libraryIds = libraryIds),
}.map { it.toDto(includeUrl = principal.user.isAdmin()) } pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@Operation(description = "Return newly added series.") @Operation(description = "Return newly added series.")
@ -140,11 +118,12 @@ class SeriesController(
Sort.by(Sort.Direction.DESC, "createdDate") Sort.by(Sort.Direction.DESC, "createdDate")
) )
return if (principal.user.sharedAllLibraries) { val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
seriesRepository.findAll(pageRequest)
} else { return seriesDtoRepository.findAll(
seriesRepository.findByLibraryIn(principal.user.sharedLibraries, pageRequest) SeriesSearch(libraryIds = libraryIds),
}.map { it.toDto(includeUrl = principal.user.isAdmin()) } pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@Operation(description = "Return recently updated series, but not newly added ones.") @Operation(description = "Return recently updated series, but not newly added ones.")
@ -160,11 +139,12 @@ class SeriesController(
Sort.by(Sort.Direction.DESC, "lastModifiedDate") Sort.by(Sort.Direction.DESC, "lastModifiedDate")
) )
return if (principal.user.sharedAllLibraries) { val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
seriesRepository.findRecentlyUpdated(pageRequest)
} else { return seriesDtoRepository.findRecentlyUpdated(
seriesRepository.findRecentlyUpdatedByLibraryIn(principal.user.sharedLibraries, pageRequest) SeriesSearch(libraryIds = libraryIds),
}.map { it.toDto(includeUrl = principal.user.isAdmin()) } pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@GetMapping("{seriesId}") @GetMapping("{seriesId}")
@ -172,35 +152,36 @@ class SeriesController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") id: Long @PathVariable(name = "seriesId") id: Long
): SeriesDto = ): SeriesDto =
seriesRepository.findByIdOrNull(id)?.let { seriesDtoRepository.findByIdOrNull(id)?.let {
if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.toDto(includeUrl = principal.user.isAdmin()) it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) @GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getSeriesThumbnail( fun getSeriesThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") id: Long @PathVariable(name = "seriesId") seriesId: Long
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> {
seriesRepository.findByIdOrNull(id)?.let { series -> seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
series.books.minBy { it.metadata.numberSort }?.let { firstBook ->
bookController.getBookThumbnail(principal, firstBook.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookRepository.findFirstIdInSeries(seriesId)?.let {
bookController.getBookThumbnail(principal, it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageableAsQueryParam @PageableAsQueryParam
@GetMapping("{seriesId}/books") @GetMapping("{seriesId}/books")
fun getAllBooksBySeries( fun getAllBooksBySeries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") id: Long, @PathVariable(name = "seriesId") seriesId: Long,
@RequestParam(name = "media_status", required = false) mediaStatus: List<Media.Status>?, @RequestParam(name = "media_status", required = false) mediaStatus: List<Media.Status>?,
@Parameter(hidden = true) page: Pageable @Parameter(hidden = true) page: Pageable
): Page<BookDto> { ): Page<BookDto> {
seriesRepository.findByIdOrNull(id)?.let { seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
val pageRequest = PageRequest.of( val pageRequest = PageRequest.of(
@ -210,28 +191,31 @@ class SeriesController(
else Sort.by(Sort.Order.asc("metadata.numberSort")) else Sort.by(Sort.Order.asc("metadata.numberSort"))
) )
return (if (!mediaStatus.isNullOrEmpty()) return bookDtoRepository.findAll(
bookRepository.findAllByMediaStatusInAndSeriesId(mediaStatus, id, pageRequest) BookSearch(
else seriesIds = listOf(seriesId),
bookRepository.findAllBySeriesId(id, pageRequest)).map { it.toDto(includeFullUrl = principal.user.isAdmin()) } mediaStatus = mediaStatus ?: emptyList()
),
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@PostMapping("{seriesId}/analyze") @PostMapping("{seriesId}/analyze")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED) @ResponseStatus(HttpStatus.ACCEPTED)
fun analyze(@PathVariable seriesId: Long) { fun analyze(@PathVariable seriesId: Long) {
seriesRepository.findByIdOrNull(seriesId)?.let { series -> bookRepository.findAllIdBySeriesId(seriesId).forEach {
series.books.forEach { taskReceiver.analyzeBook(it) } taskReceiver.analyzeBook(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) }
} }
@PostMapping("{seriesId}/metadata/refresh") @PostMapping("{seriesId}/metadata/refresh")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED) @ResponseStatus(HttpStatus.ACCEPTED)
fun refreshMetadata(@PathVariable seriesId: Long) { fun refreshMetadata(@PathVariable seriesId: Long) {
seriesRepository.findByIdOrNull(seriesId)?.let { series -> bookRepository.findAllIdBySeriesId(seriesId).forEach {
series.books.forEach { taskReceiver.refreshBookMetadata(it) } taskReceiver.refreshBookMetadata(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) }
} }
@PatchMapping("{seriesId}/metadata") @PatchMapping("{seriesId}/metadata")
@ -241,16 +225,19 @@ class SeriesController(
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") @Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
@Valid @RequestBody newMetadata: SeriesMetadataUpdateDto @Valid @RequestBody newMetadata: SeriesMetadataUpdateDto
): SeriesDto = ): SeriesDto =
seriesRepository.findByIdOrNull(seriesId)?.let { series -> seriesMetadataRepository.findByIdOrNull(seriesId)?.let { existing ->
with(newMetadata) { val updated = with(newMetadata) {
status?.let { series.metadata.status = it } existing.copy(
statusLock?.let { series.metadata.statusLock = it } status = status ?: existing.status,
title?.let { series.metadata.title = it } statusLock = statusLock ?: existing.statusLock,
titleLock?.let { series.metadata.titleLock = it } title = title ?: existing.title,
titleSort?.let { series.metadata.titleSort = it } titleLock = titleLock ?: existing.titleLock,
titleSortLock?.let { series.metadata.titleSortLock = it } titleSort = titleSort ?: existing.titleSort,
titleSortLock = titleSortLock ?: existing.titleSortLock
)
} }
seriesRepository.save(series).toDto(includeUrl = true) seriesMetadataRepository.update(updated)
seriesDtoRepository.findByIdOrNull(seriesId)!!
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }

View file

@ -1,21 +1,23 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle import org.gotson.komga.interfaces.rest.dto.PasswordUpdateDto
import org.gotson.komga.infrastructure.security.UserEmailAlreadyExistsException import org.gotson.komga.interfaces.rest.dto.SharedLibrariesUpdateDto
import org.gotson.komga.interfaces.rest.dto.UserCreationDto
import org.gotson.komga.interfaces.rest.dto.UserDto
import org.gotson.komga.interfaces.rest.dto.UserWithSharedLibrariesDto
import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.dto.toWithSharedLibrariesDto
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PatchMapping
@ -27,15 +29,13 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import javax.validation.Valid import javax.validation.Valid
import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@RestController @RestController
@RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE])
class UserController( class UserController(
private val userDetailsLifecycle: KomgaUserDetailsLifecycle, private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository, private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
env: Environment env: Environment
@ -54,7 +54,7 @@ class UserController(
@Valid @RequestBody newPasswordDto: PasswordUpdateDto @Valid @RequestBody newPasswordDto: PasswordUpdateDto
) { ) {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
userDetailsLifecycle.updatePassword(principal, newPasswordDto.password, false) userLifecycle.updatePassword(principal, newPasswordDto.password, false)
} }
@GetMapping @GetMapping
@ -67,7 +67,7 @@ class UserController(
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto = fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto =
try { try {
(userDetailsLifecycle.createUser(newUser.toUserDetails()) as KomgaPrincipal).toDto() userLifecycle.createUser(newUser.toDomain()).toDto()
} catch (e: UserEmailAlreadyExistsException) { } catch (e: UserEmailAlreadyExistsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
} }
@ -80,7 +80,7 @@ class UserController(
@AuthenticationPrincipal principal: KomgaPrincipal @AuthenticationPrincipal principal: KomgaPrincipal
) { ) {
userRepository.findByIdOrNull(id)?.let { userRepository.findByIdOrNull(id)?.let {
userDetailsLifecycle.deleteUser(it) userLifecycle.deleteUser(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@ -92,63 +92,15 @@ class UserController(
@Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto @Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto
) { ) {
userRepository.findByIdOrNull(id)?.let { user -> userRepository.findByIdOrNull(id)?.let { user ->
if (sharedLibrariesUpdateDto.all) { val updatedUser = user.copy(
user.sharedAllLibraries = true sharedAllLibraries = sharedLibrariesUpdateDto.all,
user.sharedLibraries = mutableSetOf() sharedLibrariesIds = if (sharedLibrariesUpdateDto.all) emptySet()
} else { else libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds)
user.sharedAllLibraries = false .map { it.id }
user.sharedLibraries = libraryRepository.findAllById(sharedLibrariesUpdateDto.libraryIds).toMutableSet() .toSet()
} )
userRepository.save(user) userRepository.save(updatedUser)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
} }
data class UserDto(
val id: Long,
val email: String,
val roles: List<String>
)
data class UserWithSharedLibrariesDto(
val id: Long,
val email: String,
val roles: List<String>,
val sharedAllLibraries: Boolean,
val sharedLibraries: List<SharedLibraryDto>
)
data class SharedLibraryDto(
val id: Long,
val name: String
)
fun KomgaUser.toWithSharedLibrariesDto() =
UserWithSharedLibrariesDto(
id = id,
email = email,
roles = roles.map { it.name },
sharedAllLibraries = sharedAllLibraries,
sharedLibraries = sharedLibraries.map { SharedLibraryDto(it.id, it.name) }
)
data class UserCreationDto(
@get:Email val email: String,
@get:NotBlank val password: String,
val roles: List<String> = emptyList()
) {
fun toUserDetails(): UserDetails =
User.withUsername(email)
.password(password)
.roles(*roles.toTypedArray())
.build()
}
data class PasswordUpdateDto(
@get:NotBlank val password: String
)
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<Long>
)

View file

@ -1,32 +1,33 @@
package org.gotson.komga.interfaces.rest.dto package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
import com.jakewharton.byteunits.BinaryByteUnit
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Media
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
data class BookDto( data class BookDto(
val id: Long, val id: Long,
val seriesId: Long, val seriesId: Long,
val libraryId: Long,
val name: String, val name: String,
val url: String, val url: String,
val number: Int, val number: Int,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime?, val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime?, val lastModified: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val fileLastModified: LocalDateTime, val fileLastModified: LocalDateTime,
val sizeBytes: Long, val sizeBytes: Long,
val size: String, val size: String = BinaryByteUnit.format(sizeBytes),
val media: MediaDto, val media: MediaDto,
val metadata: BookMetadataDto val metadata: BookMetadataDto
) )
fun BookDto.restrictUrl(restrict: Boolean) =
if (restrict) copy(url = FilenameUtils.getName(url)) else this
data class MediaDto( data class MediaDto(
val status: String, val status: String,
val mediaType: String, val mediaType: String,
@ -61,48 +62,3 @@ data class AuthorDto(
val role: String val role: String
) )
fun Book.toDto(includeFullUrl: Boolean) =
BookDto(
id = id,
seriesId = series.id,
name = name,
url = if (includeFullUrl) url.toURI().path else FilenameUtils.getName(url.toURI().path),
number = number,
created = createdDate?.toUTC(),
lastModified = lastModifiedDate?.toUTC(),
fileLastModified = fileLastModified.toUTC(),
sizeBytes = fileSize,
size = fileSizeHumanReadable(),
media = media.toDto(),
metadata = metadata.toDto()
)
fun Media.toDto() = MediaDto(
status = status.toString(),
mediaType = mediaType ?: "",
pagesCount = pages.size,
comment = comment ?: ""
)
fun BookMetadata.toDto() = BookMetadataDto(
title = title,
titleLock = titleLock,
summary = summary,
summaryLock = summaryLock,
number = number,
numberLock = numberLock,
numberSort = numberSort,
numberSortLock = numberSortLock,
readingDirection = readingDirection?.name ?: "",
readingDirectionLock = readingDirectionLock,
publisher = publisher,
publisherLock = publisherLock,
ageRating = ageRating,
ageRatingLock = ageRatingLock,
releaseDate = releaseDate,
releaseDateLock = releaseDateLock,
authors = authors.map { it.toDto() },
authorsLock = authorsLock
)
fun Author.toDto() = AuthorDto(name = name, role = role)

View file

@ -1,8 +1,6 @@
package org.gotson.komga.interfaces.rest.dto package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesMetadata
import java.time.LocalDateTime import java.time.LocalDateTime
data class SeriesDto( data class SeriesDto(
@ -11,47 +9,27 @@ data class SeriesDto(
val name: String, val name: String,
val url: String, val url: String,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime?, val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime?, val lastModified: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val fileLastModified: LocalDateTime, val fileLastModified: LocalDateTime,
val booksCount: Int, val booksCount: Int,
val metadata: SeriesMetadataDto val metadata: SeriesMetadataDto
) )
fun SeriesDto.restrictUrl(restrict: Boolean) =
if (restrict) copy(url = "") else this
data class SeriesMetadataDto( data class SeriesMetadataDto(
val status: String, val status: String,
val statusLock: Boolean, val statusLock: Boolean,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime?, val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime?, val lastModified: LocalDateTime,
val title: String, val title: String,
val titleLock: Boolean, val titleLock: Boolean,
val titleSort: String, val titleSort: String,
val titleSortLock: Boolean val titleSortLock: Boolean
) )
fun Series.toDto(includeUrl: Boolean) = SeriesDto(
id = id,
libraryId = library.id,
name = name,
url = if (includeUrl) url.toURI().path else "",
created = createdDate?.toUTC(),
lastModified = lastModifiedDate?.toUTC(),
fileLastModified = fileLastModified.toUTC(),
booksCount = books.size,
metadata = metadata.toDto()
)
fun SeriesMetadata.toDto() = SeriesMetadataDto(
status = status.name,
statusLock = statusLock,
created = createdDate?.toUTC(),
lastModified = lastModifiedDate?.toUTC(),
title = title,
titleLock = titleLock,
titleSort = titleSort,
titleSortLock = titleSortLock
)

View file

@ -2,13 +2,59 @@ package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.rest.UserDto import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank
data class UserDto(
val id: Long,
val email: String,
val roles: List<String>
)
fun KomgaUser.toDto() = fun KomgaUser.toDto() =
UserDto( UserDto(
id = id, id = id,
email = email, email = email,
roles = roles.map { it.name } roles = roles().toList()
) )
fun KomgaPrincipal.toDto() = user.toDto() fun KomgaPrincipal.toDto() = user.toDto()
data class UserWithSharedLibrariesDto(
val id: Long,
val email: String,
val roles: List<String>,
val sharedAllLibraries: Boolean,
val sharedLibraries: List<SharedLibraryDto>
)
data class SharedLibraryDto(
val id: Long
)
fun KomgaUser.toWithSharedLibrariesDto() =
UserWithSharedLibrariesDto(
id = id,
email = email,
roles = roles().toList(),
sharedAllLibraries = sharedAllLibraries,
sharedLibraries = sharedLibrariesIds.map { SharedLibraryDto(it) }
)
data class UserCreationDto(
@get:Email val email: String,
@get:NotBlank val password: String,
val roles: List<String> = emptyList()
) {
fun toDomain(): KomgaUser =
KomgaUser(email, password, roleAdmin = roles.contains("ADMIN"))
}
data class PasswordUpdateDto(
@get:NotBlank val password: String
)
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<Long>
)

View file

@ -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?
}

View file

@ -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?
}

View file

@ -3,14 +3,12 @@ package org.gotson.komga.interfaces.scheduler
import mu.KotlinLogging import mu.KotlinLogging
import org.apache.commons.lang3.RandomStringUtils import org.apache.commons.lang3.RandomStringUtils
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.security.KomgaUserDetailsLifecycle
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -18,24 +16,19 @@ private val logger = KotlinLogging.logger {}
@Profile("!(test | claim)") @Profile("!(test | claim)")
@Controller @Controller
class InitialUserController( class InitialUserController(
private val userDetailsLifecycle: KomgaUserDetailsLifecycle, private val userLifecycle: KomgaUserLifecycle,
private val initialUsers: List<KomgaUser> private val initialUsers: List<KomgaUser>
) { ) {
@EventListener(ApplicationReadyEvent::class) @EventListener(ApplicationReadyEvent::class)
fun createInitialUserOnStartupIfNoneExist() { fun createInitialUserOnStartupIfNoneExist() {
if (userDetailsLifecycle.countUsers() == 0L) { if (userLifecycle.countUsers() == 0L) {
logger.info { "No users exist in database, creating initial users" } logger.info { "No users exist in database, creating initial users" }
initialUsers initialUsers
.map { .forEach {
User.withUsername(it.email) userLifecycle.createUser(it)
.password(it.password) logger.info { "Initial user created. Login: ${it.email}, Password: ${it.password}" }
.roles(*it.roles.map { it.name }.toTypedArray())
.build()
}.forEach {
userDetailsLifecycle.createUser(it)
logger.info { "Initial user created. Login: ${it.username}, Password: ${it.password}" }
} }
} }
} }
@ -45,9 +38,9 @@ class InitialUserController(
@Profile("dev") @Profile("dev")
class InitialUsersDevConfiguration { class InitialUsersDevConfiguration {
@Bean @Bean
fun initialUsers() = listOf( fun initialUsers(): List<KomgaUser> = listOf(
KomgaUser("admin@example.org", "admin", mutableSetOf(UserRoles.ADMIN)), KomgaUser("admin@example.org", "admin", roleAdmin = true),
KomgaUser("user@example.org", "user") KomgaUser("user@example.org", "user", roleAdmin = false)
) )
} }
@ -55,7 +48,7 @@ class InitialUsersDevConfiguration {
@Profile("!dev") @Profile("!dev")
class InitialUsersProdConfiguration { class InitialUsersProdConfiguration {
@Bean @Bean
fun initialUsers() = listOf( fun initialUsers(): List<KomgaUser> = listOf(
KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), mutableSetOf(UserRoles.ADMIN)) KomgaUser("admin@example.org", RandomStringUtils.randomAlphanumeric(12), roleAdmin = true)
) )
} }

View file

@ -9,12 +9,6 @@ komga:
spring: spring:
datasource: datasource:
url: jdbc:h2:mem:testdb url: jdbc:h2:mem:testdb
jpa:
properties:
hibernate:
generate_statistics: true
session.events.log: false
format_sql: true
artemis: artemis:
embedded: embedded:
data-directory: ./artemis data-directory: ./artemis
@ -24,14 +18,11 @@ logging:
name: komga-dev.log name: komga-dev.log
level: level:
org.apache.activemq.audit.message: WARN org.apache.activemq.audit.message: WARN
# org.jooq: DEBUG
# web: DEBUG # web: DEBUG
# org.gotson.komga: DEBUG # org.gotson.komga: DEBUG
# org.springframework.jms: DEBUG # org.springframework.jms: DEBUG
# org.springframework.security.web.FilterChainProxy: DEBUG # org.springframework.security.web.FilterChainProxy: DEBUG
# org.hibernate.stat: DEBUG
# org.hibernate.SQL: DEBUG
# org.hibernate.cache: DEBUG
# org.hibernate.type.descriptor.sql.BasicBinder: TRACE
management.metrics.export.influx: management.metrics.export.influx:
# enabled: true # enabled: true

View file

@ -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

View file

@ -1,6 +0,0 @@
spring:
flyway:
enabled: false
jpa:
hibernate:
ddl-auto: create

View file

@ -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