mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-03 19:04:39 -05:00
Refactoring OPDS API for a more versatile root, allowing selection of manga listing by: all, source, genre, category, language, status. (#1262)
* Añadiendo algunos cambios iniciales para probar OPDS * Add suport to OPDS v1.2 * Added support for OPDS-PSE and reorganized controllers * Rename chapterIndex to chapterId in the API and controller, and update descriptions in OPDS * Refactor OPDS to use formatted timestamps and proxy thumbnail URLs * Refactor OPDS to use formatted timestamps and proxy thumbnail URLs * Update Manga API to download chapters cbz using only chapterId and improve chapter download query * Optimize OPDS queries * Update Manga API to download chapters cbz using only chapterId and improve chapter download query * Optimize OPDS queries * Use SourceDataClass to map sources and optimize thumbnail URL retrieval * Kotlin lint errors in ChapterDownloadHelper and Opds * Kotlin lint errors in ChapterDownloadHelper and Opds * Refactor OPDS API endpoints and rename OpdsController to OpdsV1Controller * Translate OpdsV1Controller comments to English and remove unused imports * Translate comments in OpdsAPI.kt to English * Add SearchCriteria class and update OpdsV1Controller * Remove spanish comments * Refactor search handling in OpdsV1Controller and update search feed endpoint * Fix search
This commit is contained in:
@@ -1,22 +1,53 @@
|
|||||||
package suwayomi.tachidesk.opds
|
package suwayomi.tachidesk.opds
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (C) Contributors to the Suwayomi project
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
||||||
|
|
||||||
import io.javalin.apibuilder.ApiBuilder.get
|
import io.javalin.apibuilder.ApiBuilder.get
|
||||||
import io.javalin.apibuilder.ApiBuilder.path
|
import io.javalin.apibuilder.ApiBuilder.path
|
||||||
import suwayomi.tachidesk.opds.controller.OpdsController
|
import suwayomi.tachidesk.opds.controller.OpdsV1Controller
|
||||||
|
|
||||||
object OpdsAPI {
|
object OpdsAPI {
|
||||||
fun defineEndpoints() {
|
fun defineEndpoints() {
|
||||||
path("opds/v1.2") {
|
path("opds/v1.2") {
|
||||||
get(OpdsController.rootFeed)
|
// Root feed (Navigation Feed)
|
||||||
get("source/{sourceId}", OpdsController.sourceFeed)
|
get(OpdsV1Controller.rootFeed)
|
||||||
get("manga/{mangaId}", OpdsController.mangaFeed)
|
|
||||||
|
// Search Description
|
||||||
|
get("search", OpdsV1Controller.searchFeed)
|
||||||
|
|
||||||
|
// Complete feed for crawlers
|
||||||
|
// get("complete", OpdsV1Controller.completeFeed)
|
||||||
|
|
||||||
|
// Main groupings
|
||||||
|
get("mangas", OpdsV1Controller.mangasFeed)
|
||||||
|
get("sources", OpdsV1Controller.sourcesFeed)
|
||||||
|
get("categories", OpdsV1Controller.categoriesFeed)
|
||||||
|
get("genres", OpdsV1Controller.genresFeed)
|
||||||
|
get("status", OpdsV1Controller.statusFeed)
|
||||||
|
get("languages", OpdsV1Controller.languagesFeed)
|
||||||
|
|
||||||
|
// Faceted feeds (Acquisition Feeds)
|
||||||
|
path("manga/{mangaId}") {
|
||||||
|
get(OpdsV1Controller.mangaFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
path("source/{sourceId}") {
|
||||||
|
get(OpdsV1Controller.sourceFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
path("category/{categoryId}") {
|
||||||
|
get(OpdsV1Controller.categoryFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
path("genre/{genre}") {
|
||||||
|
get(OpdsV1Controller.genreFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
path("status/{statusId}") {
|
||||||
|
get(OpdsV1Controller.statusMangaFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
path("language/{langCode}") {
|
||||||
|
get(OpdsV1Controller.languageFeed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
package suwayomi.tachidesk.opds.controller
|
|
||||||
|
|
||||||
import io.javalin.http.HttpStatus
|
|
||||||
import suwayomi.tachidesk.opds.impl.Opds
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
|
||||||
import suwayomi.tachidesk.server.util.handler
|
|
||||||
import suwayomi.tachidesk.server.util.pathParam
|
|
||||||
import suwayomi.tachidesk.server.util.queryParam
|
|
||||||
import suwayomi.tachidesk.server.util.withOperation
|
|
||||||
|
|
||||||
object OpdsController {
|
|
||||||
private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8"
|
|
||||||
private const val BASE_URL = "/api/opds/v1.2"
|
|
||||||
|
|
||||||
val rootFeed =
|
|
||||||
handler(
|
|
||||||
documentWith = {
|
|
||||||
withOperation {
|
|
||||||
summary("OPDS Root Feed")
|
|
||||||
description("OPDS feed for the list of available manga sources")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
behaviorOf = { ctx ->
|
|
||||||
ctx.future {
|
|
||||||
future {
|
|
||||||
Opds.getRootFeed(BASE_URL)
|
|
||||||
}.thenApply { xml ->
|
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
withResults = {
|
|
||||||
httpCode(HttpStatus.OK)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val sourceFeed =
|
|
||||||
handler(
|
|
||||||
pathParam<Long>("sourceId"),
|
|
||||||
queryParam<Int?>("pageNumber"),
|
|
||||||
documentWith = {
|
|
||||||
withOperation {
|
|
||||||
summary("OPDS Source Feed")
|
|
||||||
description("OPDS feed for a specific manga source")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
behaviorOf = { ctx, sourceId, pageNumber ->
|
|
||||||
ctx.future {
|
|
||||||
future {
|
|
||||||
Opds.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1)
|
|
||||||
}.thenApply { xml ->
|
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
withResults = {
|
|
||||||
httpCode(HttpStatus.OK)
|
|
||||||
httpCode(HttpStatus.NOT_FOUND)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val mangaFeed =
|
|
||||||
handler(
|
|
||||||
pathParam<Int>("mangaId"),
|
|
||||||
queryParam<Int?>("pageNumber"),
|
|
||||||
documentWith = {
|
|
||||||
withOperation {
|
|
||||||
summary("OPDS Manga Feed")
|
|
||||||
description("OPDS feed for chapters of a specific manga")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
behaviorOf = { ctx, mangaId, pageNumber ->
|
|
||||||
ctx.future {
|
|
||||||
future {
|
|
||||||
Opds.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1)
|
|
||||||
}.thenApply { xml ->
|
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
withResults = {
|
|
||||||
httpCode(HttpStatus.OK)
|
|
||||||
httpCode(HttpStatus.NOT_FOUND)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package suwayomi.tachidesk.opds.controller
|
||||||
|
|
||||||
|
import SearchCriteria
|
||||||
|
import io.javalin.http.HttpStatus
|
||||||
|
import suwayomi.tachidesk.opds.impl.Opds
|
||||||
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
|
import suwayomi.tachidesk.server.util.handler
|
||||||
|
import suwayomi.tachidesk.server.util.pathParam
|
||||||
|
import suwayomi.tachidesk.server.util.queryParam
|
||||||
|
import suwayomi.tachidesk.server.util.withOperation
|
||||||
|
|
||||||
|
object OpdsV1Controller {
|
||||||
|
private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8"
|
||||||
|
private const val BASE_URL = "/api/opds/v1.2"
|
||||||
|
|
||||||
|
// Root Feed
|
||||||
|
val rootFeed =
|
||||||
|
handler(
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Root Feed")
|
||||||
|
description("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getRootFeed(BASE_URL)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search Description
|
||||||
|
val searchFeed =
|
||||||
|
handler(
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OpenSearch Description")
|
||||||
|
description("XML description for OPDS searches")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
ctx.contentType("application/opensearchdescription+xml").result(
|
||||||
|
"""
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<ShortName>Suwayomi OPDS Search</ShortName>
|
||||||
|
<Description>Search manga in the catalog</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
rel="results"
|
||||||
|
template="$BASE_URL/mangas?query={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Complete Feed for Crawlers
|
||||||
|
// val completeFeed = handler(
|
||||||
|
// documentWith = {
|
||||||
|
// withOperation {
|
||||||
|
// summary("OPDS Complete Acquisition Feed")
|
||||||
|
// description(
|
||||||
|
// "Complete Acquisition Feed for Crawling: " +
|
||||||
|
// "This feed provides a full representation of every unique catalog entry " +
|
||||||
|
// "to facilitate crawling and aggregation. " +
|
||||||
|
// "It must be referenced using the relation 'http://opds-spec.org/crawlable' " +
|
||||||
|
// "and is not paginated unless extremely large."
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// behaviorOf = { ctx ->
|
||||||
|
// ctx.future {
|
||||||
|
// future {
|
||||||
|
// Opds.getCompleteFeed(BASE_URL)
|
||||||
|
// }.thenApply { xml ->
|
||||||
|
// ctx.contentType("application/atom+xml;profile=opds-catalog;kind=acquisition").result(xml)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// withResults = {
|
||||||
|
// httpCode(HttpStatus.OK)
|
||||||
|
// },
|
||||||
|
// )
|
||||||
|
|
||||||
|
// Main Manga Grouping
|
||||||
|
// Search Feed
|
||||||
|
val mangasFeed =
|
||||||
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
queryParam<String?>("query"),
|
||||||
|
queryParam<String?>("author"),
|
||||||
|
queryParam<String?>("title"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Mangas Feed")
|
||||||
|
description("OPDS feed for primary grouping of manga entries")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, pageNumber, query, author, title ->
|
||||||
|
if (query != null || author != null || title != null) {
|
||||||
|
val searchCriteria = SearchCriteria(query, author, title)
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getMangasFeed(searchCriteria, BASE_URL, 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getMangasFeed(null, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main Sources Grouping
|
||||||
|
val sourcesFeed =
|
||||||
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Sources Feed")
|
||||||
|
description("OPDS feed for primary grouping of manga sources")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getSourcesFeed(BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main Categories Grouping
|
||||||
|
val categoriesFeed =
|
||||||
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Categories Feed")
|
||||||
|
description("OPDS feed for primary grouping of manga categories")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getCategoriesFeed(BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main Genres Grouping
|
||||||
|
val genresFeed =
|
||||||
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Genres Feed")
|
||||||
|
description("OPDS feed for primary grouping of manga genres")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getGenresFeed(BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main Status Grouping
|
||||||
|
val statusFeed =
|
||||||
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Status Feed")
|
||||||
|
description("OPDS feed for primary grouping of manga by status")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getStatusFeed(BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main Languages Grouping
|
||||||
|
val languagesFeed =
|
||||||
|
handler(
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Languages Feed")
|
||||||
|
description("OPDS feed for primary grouping of available languages")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getLanguagesFeed(BASE_URL)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manga Chapters Feed
|
||||||
|
val mangaFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<Int>("mangaId"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Manga Feed")
|
||||||
|
description("OPDS feed for chapters of a specific manga")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, mangaId, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Specific Source Feed
|
||||||
|
val sourceFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<Long>("sourceId"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Source Feed")
|
||||||
|
description("OPDS feed for a specific manga source")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, sourceId, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facet Feed: Specific Category
|
||||||
|
val categoryFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<Int>("categoryId"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Category Feed")
|
||||||
|
description("OPDS feed for a specific manga category")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, categoryId, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getCategoryFeed(categoryId, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facet Feed: Specific Genre
|
||||||
|
val genreFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<String>("genre"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Genre Feed")
|
||||||
|
description("OPDS feed for a specific manga genre")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, genre, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getGenreFeed(genre, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facet Feed: Specific Status
|
||||||
|
val statusMangaFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<Long>("statusId"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Status Manga Feed")
|
||||||
|
description("OPDS feed for manga filtered by status")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, statusId, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getStatusMangaFeed(statusId, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facet Feed: Specific Language
|
||||||
|
val languageFeed =
|
||||||
|
handler(
|
||||||
|
pathParam<String>("langCode"),
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("OPDS Language Feed")
|
||||||
|
description("OPDS feed for manga filtered by language")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, langCode, pageNumber ->
|
||||||
|
ctx.future {
|
||||||
|
future {
|
||||||
|
Opds.getLanguageFeed(langCode, BASE_URL, pageNumber ?: 1)
|
||||||
|
}.thenApply { xml ->
|
||||||
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpStatus.OK)
|
||||||
|
httpCode(HttpStatus.NOT_FOUND)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package suwayomi.tachidesk.opds.impl
|
package suwayomi.tachidesk.opds.impl
|
||||||
|
|
||||||
|
import SearchCriteria
|
||||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||||
import nl.adaptivity.xmlutil.serialization.XML
|
import nl.adaptivity.xmlutil.serialization.XML
|
||||||
import org.jetbrains.exposed.sql.JoinType
|
import org.jetbrains.exposed.sql.JoinType
|
||||||
|
import org.jetbrains.exposed.sql.Op
|
||||||
import org.jetbrains.exposed.sql.SortOrder
|
import org.jetbrains.exposed.sql.SortOrder
|
||||||
import org.jetbrains.exposed.sql.and
|
import org.jetbrains.exposed.sql.and
|
||||||
|
import org.jetbrains.exposed.sql.or
|
||||||
import org.jetbrains.exposed.sql.selectAll
|
import org.jetbrains.exposed.sql.selectAll
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||||
@@ -13,14 +16,19 @@ import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
|||||||
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
|
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.OpdsDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||||
|
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
|
import suwayomi.tachidesk.opds.model.OpdsXmlModels
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -29,61 +37,160 @@ object Opds {
|
|||||||
private const val ITEMS_PER_PAGE = 20
|
private const val ITEMS_PER_PAGE = 20
|
||||||
|
|
||||||
fun getRootFeed(baseUrl: String): String {
|
fun getRootFeed(baseUrl: String): String {
|
||||||
val formattedNow = opdsDateFormatter.format(Instant.now())
|
val builder =
|
||||||
val sources =
|
FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
|
||||||
transaction {
|
totalResults = 6
|
||||||
SourceTable
|
entries +=
|
||||||
.join(MangaTable, JoinType.INNER) {
|
listOf(
|
||||||
MangaTable.sourceReference eq SourceTable.id
|
"mangas" to "All Manga",
|
||||||
}.join(ChapterTable, JoinType.INNER) {
|
"sources" to "Sources",
|
||||||
ChapterTable.manga eq MangaTable.id
|
"categories" to "Categories",
|
||||||
}.selectAll()
|
"genres" to "Genres",
|
||||||
.where { ChapterTable.isDownloaded eq true }
|
"status" to "Status",
|
||||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
"languages" to "Languages",
|
||||||
.distinct()
|
).map { (id, title) ->
|
||||||
.map {
|
OpdsXmlModels.Entry(
|
||||||
SourceDataClass(
|
id = id,
|
||||||
id = it[SourceTable.id].value.toString(),
|
title = title,
|
||||||
name = it[SourceTable.name],
|
updated = formattedNow,
|
||||||
lang = it[SourceTable.lang],
|
link =
|
||||||
iconUrl = "",
|
listOf(
|
||||||
supportsLatest = false,
|
OpdsXmlModels.Link(
|
||||||
isConfigurable = false,
|
rel = "subsection",
|
||||||
isNsfw = it[SourceTable.isNsfw],
|
href = "$baseUrl/$id",
|
||||||
displayName = "",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return serialize(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMangasFeed(
|
||||||
|
criteria: SearchCriteria?,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val (mangas, total) =
|
||||||
|
transaction {
|
||||||
|
val query =
|
||||||
|
MangaTable
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where {
|
||||||
|
val baseCondition = ChapterTable.isDownloaded eq true
|
||||||
|
if (criteria == null) {
|
||||||
|
baseCondition
|
||||||
|
} else {
|
||||||
|
val conditions = mutableListOf<Op<Boolean>>()
|
||||||
|
criteria.query?.takeIf { it.isNotBlank() }?.let { q ->
|
||||||
|
conditions += (
|
||||||
|
(MangaTable.title like "%$q%") or
|
||||||
|
(MangaTable.author like "%$q%") or
|
||||||
|
(MangaTable.genre like "%$q%")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
criteria.author?.takeIf { it.isNotBlank() }?.let { author ->
|
||||||
|
conditions += (MangaTable.author like "%$author%")
|
||||||
|
}
|
||||||
|
criteria.title?.takeIf { it.isNotBlank() }?.let { title ->
|
||||||
|
conditions += (MangaTable.title like "%$title%")
|
||||||
|
}
|
||||||
|
baseCondition and (if (conditions.isEmpty()) Op.TRUE else conditions.reduce { acc, op -> acc and op })
|
||||||
|
}
|
||||||
|
}.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
val totalCount = query.count()
|
||||||
|
val mangas =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
Pair(mangas, totalCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
val feedId = if (criteria == null) "mangas" else "search"
|
||||||
|
val feedTitle = if (criteria == null) "All Manga" else "Search results"
|
||||||
|
val searchQuery = criteria?.query?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
return FeedBuilder(baseUrl, pageNum, feedId, feedTitle, searchQuery)
|
||||||
|
.apply {
|
||||||
|
totalResults = total
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSourcesFeed(
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val (sourceList, totalCount) =
|
||||||
|
transaction {
|
||||||
|
val query =
|
||||||
|
SourceTable
|
||||||
|
.join(MangaTable, JoinType.INNER) {
|
||||||
|
MangaTable.sourceReference eq SourceTable.id
|
||||||
|
}.join(ChapterTable, JoinType.INNER) {
|
||||||
|
ChapterTable.manga eq MangaTable.id
|
||||||
|
}.select(SourceTable.columns)
|
||||||
|
.where { ChapterTable.isDownloaded eq true }
|
||||||
|
.groupBy(SourceTable.id)
|
||||||
|
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||||
|
|
||||||
|
val totalCount = query.count()
|
||||||
|
val sources =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map {
|
||||||
|
SourceDataClass(
|
||||||
|
id = it[SourceTable.id].value.toString(),
|
||||||
|
name = it[SourceTable.name],
|
||||||
|
lang = it[SourceTable.lang],
|
||||||
|
iconUrl = "",
|
||||||
|
supportsLatest = false,
|
||||||
|
isConfigurable = false,
|
||||||
|
isNsfw = it[SourceTable.isNsfw],
|
||||||
|
displayName = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Pair(sources, totalCount)
|
||||||
|
}
|
||||||
|
|
||||||
return serialize(
|
return serialize(
|
||||||
OpdsDataClass(
|
OpdsXmlModels(
|
||||||
id = "opds",
|
id = "sources",
|
||||||
title = "Suwayomi OPDS Catalog",
|
title = "Sources",
|
||||||
icon = "/favicon",
|
|
||||||
updated = formattedNow,
|
updated = formattedNow,
|
||||||
author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"),
|
totalResults = totalCount,
|
||||||
|
itemsPerPage = ITEMS_PER_PAGE,
|
||||||
|
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
|
||||||
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
links =
|
links =
|
||||||
listOf(
|
listOfNotNull(
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "self",
|
rel = "self",
|
||||||
href = baseUrl,
|
href = "$baseUrl/sources?pageNumber=$pageNum",
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
),
|
),
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "start",
|
rel = "start",
|
||||||
href = baseUrl,
|
href = baseUrl,
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entries =
|
entries =
|
||||||
sources.map {
|
sourceList.map {
|
||||||
OpdsDataClass.Entry(
|
OpdsXmlModels.Entry(
|
||||||
updated = formattedNow,
|
updated = formattedNow,
|
||||||
id = it.id,
|
id = it.id,
|
||||||
title = it.name,
|
title = it.name,
|
||||||
link =
|
link =
|
||||||
listOf(
|
listOf(
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "subsection",
|
rel = "subsection",
|
||||||
href = "$baseUrl/source/${it.id}",
|
href = "$baseUrl/source/${it.id}",
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
@@ -95,95 +202,65 @@ object Opds {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSourceFeed(
|
fun getCategoriesFeed(
|
||||||
sourceId: Long,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int = 1,
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val formattedNow = opdsDateFormatter.format(Instant.now())
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
val (mangas, totalCount, sourceRow) =
|
val categoryList =
|
||||||
transaction {
|
transaction {
|
||||||
val sourceRow =
|
CategoryTable
|
||||||
SourceTable
|
.join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
|
||||||
.join(ExtensionTable, JoinType.INNER, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
|
||||||
.select(SourceTable.name, ExtensionTable.apkName)
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
.where { SourceTable.id eq sourceId }
|
.select(CategoryTable.id, CategoryTable.name)
|
||||||
.firstOrNull()
|
.where { ChapterTable.isDownloaded eq true }
|
||||||
|
.groupBy(CategoryTable.id)
|
||||||
val query =
|
.orderBy(CategoryTable.order to SortOrder.ASC)
|
||||||
MangaTable
|
.map { row ->
|
||||||
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
Pair(row[CategoryTable.id].value, row[CategoryTable.name])
|
||||||
.select(MangaTable.columns)
|
}
|
||||||
.where {
|
|
||||||
(MangaTable.sourceReference eq sourceId) and (ChapterTable.isDownloaded eq true)
|
|
||||||
}.groupBy(MangaTable.id)
|
|
||||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
|
||||||
|
|
||||||
val totalCount = query.count()
|
|
||||||
val paginatedResults =
|
|
||||||
query
|
|
||||||
.limit(ITEMS_PER_PAGE)
|
|
||||||
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
|
||||||
.map { MangaTable.toDataClass(it) }
|
|
||||||
|
|
||||||
Triple(paginatedResults, totalCount, sourceRow)
|
|
||||||
}
|
}
|
||||||
|
val totalCount = categoryList.size
|
||||||
val sourceName = sourceRow?.get(SourceTable.name) ?: sourceId.toString()
|
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
|
||||||
val iconUrl = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) }
|
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
|
||||||
|
val paginatedCategories = if (fromIndex < totalCount) categoryList.subList(fromIndex, toIndex) else emptyList()
|
||||||
|
|
||||||
return serialize(
|
return serialize(
|
||||||
OpdsDataClass(
|
OpdsXmlModels(
|
||||||
id = "source/$sourceId",
|
id = "categories",
|
||||||
title = sourceName,
|
title = "Categories",
|
||||||
updated = formattedNow,
|
updated = formattedNow,
|
||||||
totalResults = totalCount,
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
|
totalResults = totalCount.toLong(),
|
||||||
itemsPerPage = ITEMS_PER_PAGE,
|
itemsPerPage = ITEMS_PER_PAGE,
|
||||||
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
|
startIndex = fromIndex + 1,
|
||||||
icon = iconUrl,
|
|
||||||
author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"),
|
|
||||||
links =
|
links =
|
||||||
listOfNotNull(
|
listOf(
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "self",
|
rel = "self",
|
||||||
href = "$baseUrl/source/$sourceId?pageNumber=$pageNum",
|
href = "$baseUrl/categories?pageNumber=$pageNum",
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
),
|
),
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "start",
|
rel = "start",
|
||||||
href = baseUrl,
|
href = baseUrl,
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entries =
|
entries =
|
||||||
mangas.map { manga ->
|
paginatedCategories.map { (id, name) ->
|
||||||
OpdsDataClass.Entry(
|
OpdsXmlModels.Entry(
|
||||||
id = "manga/${manga.id}",
|
id = "category/$id",
|
||||||
title = manga.title,
|
title = name,
|
||||||
updated = formattedNow,
|
updated = formattedNow,
|
||||||
authors = manga.author?.let { listOf(OpdsDataClass.Author(name = it)) } ?: emptyList(),
|
|
||||||
categories =
|
|
||||||
manga.genre.map { genre ->
|
|
||||||
OpdsDataClass.Category(term = "", label = genre)
|
|
||||||
},
|
|
||||||
summary = manga.description?.let { OpdsDataClass.Summary(value = it) },
|
|
||||||
link =
|
link =
|
||||||
listOfNotNull(
|
listOf(
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "subsection",
|
rel = "subsection",
|
||||||
href = "$baseUrl/manga/${manga.id}",
|
href = "$baseUrl/category/$id?pageNumber=1",
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
),
|
),
|
||||||
OpdsDataClass.Link(
|
|
||||||
rel = "http://opds-spec.org/image",
|
|
||||||
href = proxyThumbnailUrl(manga.id),
|
|
||||||
type = "image/jpeg",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
content =
|
|
||||||
OpdsDataClass.Content(
|
|
||||||
type = "text",
|
|
||||||
value = manga.status,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -191,10 +268,134 @@ object Opds {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaFeed(
|
fun getGenresFeed(
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val genres =
|
||||||
|
transaction {
|
||||||
|
MangaTable
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.genre)
|
||||||
|
.where { ChapterTable.isDownloaded eq true }
|
||||||
|
.map { it[MangaTable.genre] }
|
||||||
|
.flatMap { it?.split(", ")?.filterNot { g -> g.isBlank() } ?: emptyList() }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.map { (genre, _) -> genre }
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalCount = genres.size
|
||||||
|
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
|
||||||
|
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
|
||||||
|
val paginatedGenres = if (fromIndex < totalCount) genres.subList(fromIndex, toIndex) else emptyList()
|
||||||
|
|
||||||
|
return serialize(
|
||||||
|
OpdsXmlModels(
|
||||||
|
id = "genres",
|
||||||
|
title = "Genres",
|
||||||
|
updated = formattedNow,
|
||||||
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
|
totalResults = totalCount.toLong(),
|
||||||
|
itemsPerPage = ITEMS_PER_PAGE,
|
||||||
|
startIndex = fromIndex + 1,
|
||||||
|
links =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "self",
|
||||||
|
href = "$baseUrl/genres?pageNumber=$pageNum",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "start",
|
||||||
|
href = baseUrl,
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
entries =
|
||||||
|
paginatedGenres.map { genre ->
|
||||||
|
OpdsXmlModels.Entry(
|
||||||
|
id = "genre/${genre.encodeURL()}",
|
||||||
|
title = genre,
|
||||||
|
updated = formattedNow,
|
||||||
|
link =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "subsection",
|
||||||
|
href = "$baseUrl/genre/${genre.encodeURL()}?pageNumber=1",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStatusFeed(
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
|
||||||
|
val statuses = MangaStatus.entries.sortedBy { it.value }
|
||||||
|
val totalCount = statuses.size
|
||||||
|
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
|
||||||
|
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
|
||||||
|
val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList()
|
||||||
|
|
||||||
|
return serialize(
|
||||||
|
OpdsXmlModels(
|
||||||
|
id = "status",
|
||||||
|
title = "Status",
|
||||||
|
updated = formattedNow,
|
||||||
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
|
totalResults = totalCount.toLong(),
|
||||||
|
itemsPerPage = ITEMS_PER_PAGE,
|
||||||
|
startIndex = fromIndex + 1,
|
||||||
|
links =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "self",
|
||||||
|
href = "$baseUrl/status?pageNumber=$pageNum",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "start",
|
||||||
|
href = baseUrl,
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
entries =
|
||||||
|
paginatedStatuses.map { status ->
|
||||||
|
OpdsXmlModels.Entry(
|
||||||
|
id = "status/${status.value}",
|
||||||
|
title =
|
||||||
|
status.name
|
||||||
|
.lowercase()
|
||||||
|
.replace('_', ' ')
|
||||||
|
.replaceFirstChar { it.uppercase() },
|
||||||
|
updated = formattedNow,
|
||||||
|
link =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "subsection",
|
||||||
|
href = "$baseUrl/status/${status.value}?pageNumber=1",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMangaFeed(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int = 1,
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val formattedNow = opdsDateFormatter.format(Instant.now())
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
val (manga, chapters, totalCount) =
|
val (manga, chapters, totalCount) =
|
||||||
@@ -223,13 +424,13 @@ object Opds {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return serialize(
|
return serialize(
|
||||||
OpdsDataClass(
|
OpdsXmlModels(
|
||||||
id = "manga/$mangaId",
|
id = "manga/$mangaId",
|
||||||
title = manga.title,
|
title = manga.title,
|
||||||
updated = formattedNow,
|
updated = formattedNow,
|
||||||
icon = manga.thumbnailUrl,
|
icon = manga.thumbnailUrl,
|
||||||
author =
|
author =
|
||||||
OpdsDataClass.Author(
|
OpdsXmlModels.Author(
|
||||||
name = "Suwayomi",
|
name = "Suwayomi",
|
||||||
uri = "https://suwayomi.org/",
|
uri = "https://suwayomi.org/",
|
||||||
),
|
),
|
||||||
@@ -238,35 +439,35 @@ object Opds {
|
|||||||
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
|
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
|
||||||
links =
|
links =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "self",
|
rel = "self",
|
||||||
href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum",
|
href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum",
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
),
|
),
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "start",
|
rel = "start",
|
||||||
href = baseUrl,
|
href = baseUrl,
|
||||||
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
),
|
),
|
||||||
manga.thumbnailUrl?.let { url ->
|
manga.thumbnailUrl?.let { url ->
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "http://opds-spec.org/image",
|
rel = "http://opds-spec.org/image",
|
||||||
href = url,
|
href = url,
|
||||||
type = "image/jpeg",
|
type = "image/jpeg",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
manga.thumbnailUrl?.let { url ->
|
manga.thumbnailUrl?.let { url ->
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "http://opds-spec.org/image/thumbnail",
|
rel = "http://opds-spec.org/image/thumbnail",
|
||||||
href = url,
|
href = url,
|
||||||
type = "image/jpeg",
|
type = "image/jpeg",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
// rel = "search",
|
rel = "search",
|
||||||
// type = "application/opensearchdescription+xml",
|
type = "application/opensearchdescription+xml",
|
||||||
// href = "$baseUrl/search"
|
href = "$baseUrl/search",
|
||||||
// ),
|
),
|
||||||
),
|
),
|
||||||
entries =
|
entries =
|
||||||
chapters.map { chapter ->
|
chapters.map { chapter ->
|
||||||
@@ -276,19 +477,72 @@ object Opds {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLanguagesFeed(baseUrl: String): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val languages =
|
||||||
|
transaction {
|
||||||
|
SourceTable
|
||||||
|
.join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference)
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(SourceTable.lang)
|
||||||
|
.where { ChapterTable.isDownloaded eq true }
|
||||||
|
.groupBy(SourceTable.lang)
|
||||||
|
.orderBy(SourceTable.lang to SortOrder.ASC)
|
||||||
|
.map { row -> row[SourceTable.lang] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialize(
|
||||||
|
OpdsXmlModels(
|
||||||
|
id = "languages",
|
||||||
|
title = "Languages",
|
||||||
|
updated = formattedNow,
|
||||||
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
|
links =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "self",
|
||||||
|
href = "$baseUrl/languages",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "start",
|
||||||
|
href = baseUrl,
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
entries =
|
||||||
|
languages.map { lang ->
|
||||||
|
OpdsXmlModels.Entry(
|
||||||
|
id = "language/$lang",
|
||||||
|
title = lang,
|
||||||
|
updated = formattedNow,
|
||||||
|
link =
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "subsection",
|
||||||
|
href = "$baseUrl/language/$lang",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createChapterEntry(
|
private fun createChapterEntry(
|
||||||
chapter: ChapterDataClass,
|
chapter: ChapterDataClass,
|
||||||
manga: MangaDataClass,
|
manga: MangaDataClass,
|
||||||
): OpdsDataClass.Entry {
|
): OpdsXmlModels.Entry {
|
||||||
val cbzFile = File(getChapterCbzPath(manga.id, chapter.id))
|
val cbzFile = File(getChapterCbzPath(manga.id, chapter.id))
|
||||||
val isCbzAvailable = cbzFile.exists()
|
val isCbzAvailable = cbzFile.exists()
|
||||||
|
|
||||||
return OpdsDataClass.Entry(
|
return OpdsXmlModels.Entry(
|
||||||
id = "chapter/${chapter.id}",
|
id = "chapter/${chapter.id}",
|
||||||
title = chapter.name,
|
title = chapter.name,
|
||||||
updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)),
|
updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)),
|
||||||
content = OpdsDataClass.Content(value = "${chapter.scanlator}"),
|
content = OpdsXmlModels.Content(value = "${chapter.scanlator}"),
|
||||||
summary = manga.description?.let { OpdsDataClass.Summary(value = it) },
|
summary = manga.description?.let { OpdsXmlModels.Summary(value = it) },
|
||||||
extent =
|
extent =
|
||||||
cbzFile.takeIf { it.exists() }?.let {
|
cbzFile.takeIf { it.exists() }?.let {
|
||||||
formatFileSize(it.length())
|
formatFileSize(it.length())
|
||||||
@@ -296,26 +550,26 @@ object Opds {
|
|||||||
format = cbzFile.takeIf { it.exists() }?.let { "CBZ" },
|
format = cbzFile.takeIf { it.exists() }?.let { "CBZ" },
|
||||||
authors =
|
authors =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
manga.author?.let { OpdsDataClass.Author(name = it) },
|
manga.author?.let { OpdsXmlModels.Author(name = it) },
|
||||||
manga.artist?.takeIf { it != manga.author }?.let { OpdsDataClass.Author(name = it) },
|
manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) },
|
||||||
),
|
),
|
||||||
link =
|
link =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
if (isCbzAvailable) {
|
if (isCbzAvailable) {
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "http://opds-spec.org/acquisition/open-access",
|
rel = "http://opds-spec.org/acquisition/open-access",
|
||||||
href = "/api/v1/chapter/${chapter.id}/download",
|
href = "/api/v1/chapter/${chapter.id}/download",
|
||||||
type = "application/vnd.comicbook+zip",
|
type = "application/vnd.comicbook+zip",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "http://vaemendis.net/opds-pse/stream",
|
rel = "http://vaemendis.net/opds-pse/stream",
|
||||||
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}",
|
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}",
|
||||||
type = "image/jpeg",
|
type = "image/jpeg",
|
||||||
pseCount = chapter.pageCount,
|
pseCount = chapter.pageCount,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
OpdsDataClass.Link(
|
OpdsXmlModels.Link(
|
||||||
rel = "http://opds-spec.org/image",
|
rel = "http://opds-spec.org/image",
|
||||||
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0",
|
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0",
|
||||||
type = "image/jpeg",
|
type = "image/jpeg",
|
||||||
@@ -324,6 +578,283 @@ object Opds {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSourceFeed(
|
||||||
|
sourceId: Long,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val (mangas, total, sourceRow) =
|
||||||
|
transaction {
|
||||||
|
val sourceRow =
|
||||||
|
SourceTable
|
||||||
|
.join(ExtensionTable, JoinType.INNER, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||||
|
.select(SourceTable.name, ExtensionTable.apkName)
|
||||||
|
.where { SourceTable.id eq sourceId }
|
||||||
|
.firstOrNull()
|
||||||
|
|
||||||
|
val query =
|
||||||
|
MangaTable
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where {
|
||||||
|
(MangaTable.sourceReference eq sourceId) and (ChapterTable.isDownloaded eq true)
|
||||||
|
}.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
|
||||||
|
val totalCount = query.count()
|
||||||
|
val paginatedResults =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
|
||||||
|
Triple(paginatedResults, totalCount, sourceRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FeedBuilder(baseUrl, pageNum, "source/$sourceId", sourceRow?.get(SourceTable.name) ?: "Source $sourceId")
|
||||||
|
.apply {
|
||||||
|
totalResults = total
|
||||||
|
icon = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) }
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCategoryFeed(
|
||||||
|
categoryId: Int,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val (mangas, total, categoryName) =
|
||||||
|
transaction {
|
||||||
|
val categoryRow = CategoryTable.selectAll().where { CategoryTable.id eq categoryId }.firstOrNull()
|
||||||
|
if (categoryRow == null) {
|
||||||
|
return@transaction Triple(emptyList<MangaDataClass>(), 0, "")
|
||||||
|
}
|
||||||
|
val categoryName = categoryRow[CategoryTable.name]
|
||||||
|
val query =
|
||||||
|
CategoryMangaTable
|
||||||
|
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where { (CategoryMangaTable.category eq categoryId) and (ChapterTable.isDownloaded eq true) }
|
||||||
|
.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
val totalCount = query.count()
|
||||||
|
val mangas =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
Triple(mangas, totalCount, categoryName)
|
||||||
|
}
|
||||||
|
return FeedBuilder(baseUrl, pageNum, "category/$categoryId", "Category: $categoryName")
|
||||||
|
.apply {
|
||||||
|
totalResults = total.toLong()
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGenreFeed(
|
||||||
|
genre: String,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val (mangas, total) =
|
||||||
|
transaction {
|
||||||
|
val query =
|
||||||
|
MangaTable
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where { (MangaTable.genre like "%$genre%") and (ChapterTable.isDownloaded eq true) }
|
||||||
|
.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
val totalCount = query.count()
|
||||||
|
val mangas =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
Pair(mangas, totalCount)
|
||||||
|
}
|
||||||
|
return FeedBuilder(baseUrl, pageNum, "genre/${genre.encodeURL()}", "Genre: $genre")
|
||||||
|
.apply {
|
||||||
|
totalResults = total
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStatusMangaFeed(
|
||||||
|
statusId: Long,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val statusName =
|
||||||
|
MangaStatus
|
||||||
|
.valueOf(statusId.toInt())
|
||||||
|
.name
|
||||||
|
.lowercase()
|
||||||
|
.replaceFirstChar { it.uppercase() }
|
||||||
|
val (mangas, total) =
|
||||||
|
transaction {
|
||||||
|
val query =
|
||||||
|
MangaTable
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where { (MangaTable.status eq statusId.toInt()) and (ChapterTable.isDownloaded eq true) }
|
||||||
|
.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
val totalCount = query.count()
|
||||||
|
val mangas =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
Pair(mangas, totalCount)
|
||||||
|
}
|
||||||
|
return FeedBuilder(baseUrl, pageNum, "status/$statusId", "Status: $statusName")
|
||||||
|
.apply {
|
||||||
|
totalResults = total
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLanguageFeed(
|
||||||
|
langCode: String,
|
||||||
|
baseUrl: String,
|
||||||
|
pageNum: Int,
|
||||||
|
): String {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
val (mangas, total) =
|
||||||
|
transaction {
|
||||||
|
val query =
|
||||||
|
SourceTable
|
||||||
|
.join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference)
|
||||||
|
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
|
||||||
|
.select(MangaTable.columns)
|
||||||
|
.where { (SourceTable.lang eq langCode) and (ChapterTable.isDownloaded eq true) }
|
||||||
|
.groupBy(MangaTable.id)
|
||||||
|
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
|
val totalCount = query.count()
|
||||||
|
val mangas =
|
||||||
|
query
|
||||||
|
.limit(ITEMS_PER_PAGE)
|
||||||
|
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
Pair(mangas, totalCount)
|
||||||
|
}
|
||||||
|
return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode")
|
||||||
|
.apply {
|
||||||
|
totalResults = total
|
||||||
|
entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) }
|
||||||
|
}.build()
|
||||||
|
.let(::serialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FeedBuilder(
|
||||||
|
val baseUrl: String,
|
||||||
|
val pageNum: Int,
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val searchQuery: String? = null,
|
||||||
|
) {
|
||||||
|
val formattedNow = opdsDateFormatter.format(Instant.now())
|
||||||
|
var totalResults: Long = 0
|
||||||
|
var icon: String? = null
|
||||||
|
val links = mutableListOf<OpdsXmlModels.Link>()
|
||||||
|
val entries = mutableListOf<OpdsXmlModels.Entry>()
|
||||||
|
|
||||||
|
fun build(): OpdsXmlModels =
|
||||||
|
OpdsXmlModels(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
updated = formattedNow,
|
||||||
|
icon = icon,
|
||||||
|
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
|
||||||
|
links =
|
||||||
|
links +
|
||||||
|
listOf(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "self",
|
||||||
|
href =
|
||||||
|
if (id == "opds") {
|
||||||
|
baseUrl
|
||||||
|
} else if (searchQuery != null) {
|
||||||
|
"$baseUrl/$id?query=$searchQuery"
|
||||||
|
} else {
|
||||||
|
"$baseUrl/$id?pageNumber=$pageNum"
|
||||||
|
},
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
),
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "start",
|
||||||
|
href = baseUrl,
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
|
||||||
|
),
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "search",
|
||||||
|
type = "application/opensearchdescription+xml",
|
||||||
|
href = "$baseUrl/search",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
entries = entries,
|
||||||
|
totalResults = totalResults,
|
||||||
|
itemsPerPage = ITEMS_PER_PAGE,
|
||||||
|
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaEntry(
|
||||||
|
manga: MangaDataClass,
|
||||||
|
baseUrl: String,
|
||||||
|
formattedNow: String,
|
||||||
|
): OpdsXmlModels.Entry {
|
||||||
|
val proxyThumb = manga.thumbnailUrl?.let { proxyThumbnailUrl(manga.id) }
|
||||||
|
|
||||||
|
return OpdsXmlModels.Entry(
|
||||||
|
id = "manga/${manga.id}",
|
||||||
|
title = manga.title,
|
||||||
|
updated = formattedNow,
|
||||||
|
authors = manga.author?.let { listOf(OpdsXmlModels.Author(name = it)) },
|
||||||
|
categories =
|
||||||
|
manga.genre.map {
|
||||||
|
OpdsXmlModels.Category(term = "", label = it)
|
||||||
|
},
|
||||||
|
summary = manga.description?.let { OpdsXmlModels.Summary(value = it) },
|
||||||
|
link =
|
||||||
|
listOfNotNull(
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "subsection",
|
||||||
|
href = "$baseUrl/manga/${manga.id}",
|
||||||
|
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
),
|
||||||
|
proxyThumb?.let {
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "http://opds-spec.org/image",
|
||||||
|
href = it,
|
||||||
|
type = "image/jpeg",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
proxyThumb?.let {
|
||||||
|
OpdsXmlModels.Link(
|
||||||
|
rel = "http://opds-spec.org/image/thumbnail",
|
||||||
|
href = it,
|
||||||
|
type = "image/jpeg",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.encodeURL(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString())
|
||||||
|
|
||||||
private val opdsDateFormatter =
|
private val opdsDateFormatter =
|
||||||
DateTimeFormatter
|
DateTimeFormatter
|
||||||
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||||
@@ -346,5 +877,5 @@ object Opds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serialize(feed: OpdsDataClass): String = xmlFormat.encodeToString(OpdsDataClass.serializer(), feed)
|
private fun serialize(feed: OpdsXmlModels): String = xmlFormat.encodeToString(OpdsXmlModels.serializer(), feed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package suwayomi.tachidesk.opds.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XmlElement
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XmlSerialName
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XmlValue
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("feed", "", "")
|
||||||
|
data class OpdsXmlModels(
|
||||||
|
@XmlElement(true)
|
||||||
|
val id: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val title: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val icon: String? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val updated: String, // ISO-8601
|
||||||
|
@XmlElement(true)
|
||||||
|
val author: Author? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val links: List<Link>,
|
||||||
|
@XmlElement(true)
|
||||||
|
val entries: List<Entry>,
|
||||||
|
@XmlSerialName("xmlns", "", "")
|
||||||
|
val xmlns: String = "http://www.w3.org/2005/Atom",
|
||||||
|
@XmlSerialName("xmlns:xsd", "", "")
|
||||||
|
val xmlnsXsd: String = "http://www.w3.org/2001/XMLSchema",
|
||||||
|
@XmlSerialName("xmlns:xsi", "", "")
|
||||||
|
val xmlnsXsi: String = "http://www.w3.org/2001/XMLSchema-instance",
|
||||||
|
@XmlSerialName("xmlns:opds", "", "")
|
||||||
|
val xmlnsOpds: String = "http://opds-spec.org/2010/catalog",
|
||||||
|
@XmlSerialName("xmlns:dcterms", "", "")
|
||||||
|
val xmlnsDublinCore: String = "http://purl.org/dc/terms/",
|
||||||
|
@XmlSerialName("xmlns:pse", "", "")
|
||||||
|
val xmlnsPse: String = "http://vaemendis.net/opds-pse/ns",
|
||||||
|
@XmlElement(true)
|
||||||
|
@XmlSerialName("totalResults", "http://a9.com/-/spec/opensearch/1.1/", "")
|
||||||
|
val totalResults: Long? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
@XmlSerialName("itemsPerPage", "http://a9.com/-/spec/opensearch/1.1/", "")
|
||||||
|
val itemsPerPage: Int? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
@XmlSerialName("startIndex", "http://a9.com/-/spec/opensearch/1.1/", "")
|
||||||
|
val startIndex: Int? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("author", "", "")
|
||||||
|
data class Author(
|
||||||
|
@XmlElement(true)
|
||||||
|
val name: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val uri: String? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val email: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("link", "", "")
|
||||||
|
data class Link(
|
||||||
|
val rel: String,
|
||||||
|
val href: String,
|
||||||
|
val type: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
@XmlSerialName("pse:count", "", "")
|
||||||
|
val pseCount: Int? = null,
|
||||||
|
@XmlSerialName("opds:facetGroup", "", "")
|
||||||
|
val facetGroup: String? = null,
|
||||||
|
@XmlSerialName("opds:activeFacet", "", "")
|
||||||
|
val activeFacet: Boolean? = null,
|
||||||
|
val indirectAcquisition: List<OpdsIndirectAcquisition>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("opds:indirectAcquisition", "", "")
|
||||||
|
data class OpdsIndirectAcquisition(
|
||||||
|
@XmlSerialName("type") val type: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("entry", "", "")
|
||||||
|
data class Entry(
|
||||||
|
@XmlElement(true)
|
||||||
|
val id: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val title: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val updated: String,
|
||||||
|
@XmlElement(true)
|
||||||
|
val summary: Summary? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val content: Content? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val link: List<Link>,
|
||||||
|
@XmlElement(true)
|
||||||
|
val authors: List<Author>? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
val categories: List<Category>? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
@XmlSerialName("extent", "http://purl.org/dc/terms/", "")
|
||||||
|
val extent: String? = null,
|
||||||
|
@XmlElement(true)
|
||||||
|
@XmlSerialName("format", "http://purl.org/dc/terms/format", "")
|
||||||
|
val format: String? = null,
|
||||||
|
@XmlSerialName("dc:language")
|
||||||
|
val language: String? = null,
|
||||||
|
@XmlSerialName("dc:publisher")
|
||||||
|
val publisher: String? = null,
|
||||||
|
@XmlSerialName("dc:issued")
|
||||||
|
val issued: String? = null,
|
||||||
|
@XmlSerialName("dc:identifier")
|
||||||
|
val identifier: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("summary", "", "")
|
||||||
|
data class Summary(
|
||||||
|
val type: String = "text",
|
||||||
|
@XmlValue(true) val value: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("content", "", "")
|
||||||
|
data class Content(
|
||||||
|
val type: String = "text",
|
||||||
|
@XmlValue(true) val value: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("category", "", "")
|
||||||
|
data class Category(
|
||||||
|
val scheme: String? = null,
|
||||||
|
val term: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
data class SearchCriteria(
|
||||||
|
val query: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user