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:
Zeedif
2025-02-09 15:03:18 -06:00
committed by GitHub
parent 01c37cb0ba
commit c2f7cdd72e
6 changed files with 1248 additions and 221 deletions

View File

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