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

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.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
@EnableJpaAuditing
class Application
fun main(args: Array<String>) {

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

View file

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

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
import javax.persistence.Column
import javax.persistence.Embeddable
import javax.validation.constraints.NotBlank
@Embeddable
class Author {
constructor(name: String, role: String) {
this.name = name
this.role = role
}
@NotBlank
@Column(name = "name", nullable = false)
var name: String
set(value) {
require(value.isNotBlank()) { "name must not be blank" }
field = value.trim()
}
@NotBlank
@Column(name = "role", nullable = false)
var role: String
set(value) {
require(value.isNotBlank()) { "role must not be blank" }
field = value.trim().toLowerCase()
}
class Author(
name: String,
role: String
) {
val name = name.trim()
val role = role.trim().toLowerCase()
override fun toString(): String = "Author($name, $role)"
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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