diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt index a3ad2db5..7a936123 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt @@ -82,6 +82,7 @@ object MangaAPI { path("chapter") { post("batch", MangaController.anyChapterBatch) + get("{chapterId}/download", MangaController.downloadChapter) } path("category") { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt index 1eb6364c..d454ff0f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -11,6 +11,7 @@ import io.javalin.http.HttpStatus import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.impl.Chapter +import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.Page @@ -424,4 +425,29 @@ object MangaController { httpCode(HttpStatus.NOT_FOUND) }, ) + + val downloadChapter = + handler( + pathParam("chapterId"), + documentWith = { + withOperation { + summary("Download chapter as CBZ") + description("Get the CBZ file of the specified chapter") + } + }, + behaviorOf = { ctx, chapterId -> + ctx.future { + future { ChapterDownloadHelper.getCbzDownload(chapterId) } + .thenApply { (inputStream, contentType, fileName) -> + ctx.header("Content-Type", contentType) + ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"") + ctx.result(inputStream) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt index 5d064b34..64742975 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt @@ -1,14 +1,19 @@ package suwayomi.tachidesk.manga.impl import kotlinx.coroutines.CoroutineScope +import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.ArchiveProvider import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.FolderProvider import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath +import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.serverConfig import java.io.File +import java.io.IOException import java.io.InputStream object ChapterDownloadHelper { @@ -42,4 +47,27 @@ object ChapterDownloadHelper { if (!chapterFolder.exists() && serverConfig.downloadAsCbz.value) return ArchiveProvider(mangaId, chapterId) return FolderProvider(mangaId, chapterId) } + + suspend fun getCbzDownload(chapterId: Int): Triple { + val (chapterData, mangaTitle) = + transaction { + val row = + (ChapterTable innerJoin MangaTable) + .select(ChapterTable.columns + MangaTable.columns) + .where { ChapterTable.id eq chapterId } + .firstOrNull() ?: throw Exception("Chapter not found") + val chapter = ChapterTable.toDataClass(row) + val title = row[MangaTable.title] + Pair(chapter, title) + } + + val provider = provider(chapterData.mangaId, chapterData.id) + return if (provider is ArchiveProvider) { + val cbzFile = File(getChapterCbzPath(chapterData.mangaId, chapterData.id)) + val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz" + Triple(cbzFile.inputStream(), "application/vnd.comicbook+zip", fileName) + } else { + throw IOException("Chapter not available as CBZ") + } + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt new file mode 100644 index 00000000..1e464691 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt @@ -0,0 +1,117 @@ +package suwayomi.tachidesk.manga.model.dataclass + +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 OpdsDataClass( + @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, + @XmlElement(true) + val entries: List, + @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, + ) + + @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, + @XmlElement(true) + val authors: List? = null, + @XmlElement(true) + val categories: List? = null, + @XmlElement(true) + @XmlSerialName("language", "http://purl.org/dc/terms/", "dc") + val extent: String? = null, + @XmlElement(true) + @XmlSerialName("format", "http://purl.org/dc/terms/format", "dc") + val format: 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, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt new file mode 100644 index 00000000..cd3943f1 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt @@ -0,0 +1,22 @@ +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.path +import suwayomi.tachidesk.opds.controller.OpdsController + +object OpdsAPI { + fun defineEndpoints() { + path("opds/v1.2") { + get(OpdsController.rootFeed) + get("source/{sourceId}", OpdsController.sourceFeed) + get("manga/{mangaId}", OpdsController.mangaFeed) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt new file mode 100644 index 00000000..7f095df4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt @@ -0,0 +1,86 @@ +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("sourceId"), + queryParam("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("mangaId"), + queryParam("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) + }, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt new file mode 100644 index 00000000..a056b628 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt @@ -0,0 +1,350 @@ +package suwayomi.tachidesk.opds.impl + +import nl.adaptivity.xmlutil.XmlDeclMode +import nl.adaptivity.xmlutil.core.XmlVersion +import nl.adaptivity.xmlutil.serialization.XML +import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl +import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl +import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath +import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass +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.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ExtensionTable +import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.manga.model.table.toDataClass +import java.io.File +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +object Opds { + private const val ITEMS_PER_PAGE = 20 + + fun getRootFeed(baseUrl: String): String { + val formattedNow = opdsDateFormatter.format(Instant.now()) + val sources = + transaction { + SourceTable + .join(MangaTable, JoinType.INNER) { + MangaTable.sourceReference eq SourceTable.id + }.join(ChapterTable, JoinType.INNER) { + ChapterTable.manga eq MangaTable.id + }.selectAll() + .where { ChapterTable.isDownloaded eq true } + .orderBy(SourceTable.name to SortOrder.ASC) + .distinct() + .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 = "", + ) + } + } + + return serialize( + OpdsDataClass( + id = "opds", + title = "Suwayomi OPDS Catalog", + icon = "/favicon", + updated = formattedNow, + author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"), + links = + listOf( + OpdsDataClass.Link( + rel = "self", + href = baseUrl, + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + ), + OpdsDataClass.Link( + rel = "start", + href = baseUrl, + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + ), + ), + entries = + sources.map { + OpdsDataClass.Entry( + updated = formattedNow, + id = it.id, + title = it.name, + link = + listOf( + OpdsDataClass.Link( + rel = "subsection", + href = "$baseUrl/source/${it.id}", + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + ), + ), + ) + }, + ), + ) + } + + fun getSourceFeed( + sourceId: Long, + baseUrl: String, + pageNum: Int = 1, + ): String { + val formattedNow = opdsDateFormatter.format(Instant.now()) + val (mangas, totalCount, 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) + } + + val sourceName = sourceRow?.get(SourceTable.name) ?: sourceId.toString() + val iconUrl = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) } + + return serialize( + OpdsDataClass( + id = "source/$sourceId", + title = sourceName, + updated = formattedNow, + totalResults = totalCount, + itemsPerPage = ITEMS_PER_PAGE, + startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, + icon = iconUrl, + author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"), + links = + listOfNotNull( + OpdsDataClass.Link( + rel = "self", + href = "$baseUrl/source/$sourceId?pageNumber=$pageNum", + type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + ), + OpdsDataClass.Link( + rel = "start", + href = baseUrl, + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + ), + ), + entries = + mangas.map { manga -> + OpdsDataClass.Entry( + id = "manga/${manga.id}", + title = manga.title, + 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 = + listOfNotNull( + OpdsDataClass.Link( + rel = "subsection", + href = "$baseUrl/manga/${manga.id}", + 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, + ), + ) + }, + ), + ) + } + + suspend fun getMangaFeed( + mangaId: Int, + baseUrl: String, + pageNum: Int = 1, + ): String { + val formattedNow = opdsDateFormatter.format(Instant.now()) + val (manga, chapters, totalCount) = + transaction { + val mangaEntry = + MangaTable + .selectAll() + .where { MangaTable.id eq mangaId } + .first() + val mangaData = MangaTable.toDataClass(mangaEntry) + val chaptersQuery = + ChapterTable + .selectAll() + .where { + (ChapterTable.manga eq mangaId) and + (ChapterTable.isDownloaded eq true) and + (ChapterTable.pageCount greater 0) + }.orderBy(ChapterTable.sourceOrder to SortOrder.DESC) + val total = chaptersQuery.count() + val chaptersData = + chaptersQuery + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { ChapterTable.toDataClass(it) } + Triple(mangaData, chaptersData, total) + } + + return serialize( + OpdsDataClass( + id = "manga/$mangaId", + title = manga.title, + updated = formattedNow, + icon = manga.thumbnailUrl, + author = + OpdsDataClass.Author( + name = "Suwayomi", + uri = "https://suwayomi.org/", + ), + totalResults = totalCount, + itemsPerPage = ITEMS_PER_PAGE, + startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, + links = + listOfNotNull( + OpdsDataClass.Link( + rel = "self", + href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum", + type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + ), + OpdsDataClass.Link( + rel = "start", + href = baseUrl, + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + ), + manga.thumbnailUrl?.let { url -> + OpdsDataClass.Link( + rel = "http://opds-spec.org/image", + href = url, + type = "image/jpeg", + ) + }, + manga.thumbnailUrl?.let { url -> + OpdsDataClass.Link( + rel = "http://opds-spec.org/image/thumbnail", + href = url, + type = "image/jpeg", + ) + }, + // OpdsDataClass.Link( + // rel = "search", + // type = "application/opensearchdescription+xml", + // href = "$baseUrl/search" + // ), + ), + entries = + chapters.map { chapter -> + createChapterEntry(chapter, manga) + }, + ), + ) + } + + private fun createChapterEntry( + chapter: ChapterDataClass, + manga: MangaDataClass, + ): OpdsDataClass.Entry { + val cbzFile = File(getChapterCbzPath(manga.id, chapter.id)) + val isCbzAvailable = cbzFile.exists() + + return OpdsDataClass.Entry( + id = "chapter/${chapter.id}", + title = chapter.name, + updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), + content = OpdsDataClass.Content(value = "${chapter.scanlator}"), + summary = manga.description?.let { OpdsDataClass.Summary(value = it) }, + extent = + cbzFile.takeIf { it.exists() }?.let { + formatFileSize(it.length()) + }, + format = cbzFile.takeIf { it.exists() }?.let { "CBZ" }, + authors = + listOfNotNull( + manga.author?.let { OpdsDataClass.Author(name = it) }, + manga.artist?.takeIf { it != manga.author }?.let { OpdsDataClass.Author(name = it) }, + ), + link = + listOfNotNull( + if (isCbzAvailable) { + OpdsDataClass.Link( + rel = "http://opds-spec.org/acquisition/open-access", + href = "/api/v1/chapter/${chapter.id}/download", + type = "application/vnd.comicbook+zip", + ) + } else { + OpdsDataClass.Link( + rel = "http://vaemendis.net/opds-pse/stream", + href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}", + type = "image/jpeg", + pseCount = chapter.pageCount, + ) + }, + OpdsDataClass.Link( + rel = "http://opds-spec.org/image", + href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", + type = "image/jpeg", + ), + ), + ) + } + + private val opdsDateFormatter = + DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + + private fun formatFileSize(size: Long): String = + when { + size >= 1_000_000 -> "%.2f MB".format(size / 1_000_000.0) + size >= 1_000 -> "%.2f KB".format(size / 1_000.0) + else -> "$size bytes" + } + + private val xmlFormat = + XML { + indent = 2 + xmlVersion = XmlVersion.XML10 + xmlDeclMode = XmlDeclMode.Charset + defaultPolicy { + autoPolymorphic = true + } + } + + private fun serialize(feed: OpdsDataClass): String = xmlFormat.encodeToString(OpdsDataClass.serializer(), feed) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 1e4f7a11..01690061 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -22,6 +22,7 @@ import org.eclipse.jetty.server.ServerConnector import suwayomi.tachidesk.global.GlobalAPI import suwayomi.tachidesk.graphql.GraphQL import suwayomi.tachidesk.manga.MangaAPI +import suwayomi.tachidesk.opds.OpdsAPI import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.WebInterfaceManager import uy.kohesive.injekt.injectLazy @@ -102,6 +103,8 @@ object JavalinSetup { GlobalAPI.defineEndpoints() MangaAPI.defineEndpoints() } + + OpdsAPI.defineEndpoints() GraphQL.defineEndpoints() } }