Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
Zeedif 61f429896c feat(opds): implement full internationalization and refactor feed gen… (#1405)
* feat(opds): implement full internationalization and refactor feed generation

This commit introduces a comprehensive internationalization (i18n) framework
and significantly refactors the OPDS v1.2 implementation for improved
robustness, spec compliance, and localization.

Key changes:

Internationalization (`i18n`):
- Introduces `LocalizationService` to manage translations:
    - Loads localized strings from JSON files (e.g., `en.json`, `es.json`)
      stored in a new `i18n` data directory.
    - Default `en.json` and `es.json` files are bundled and copied from
      resources on first run if not present.
    - Supports template resolution with `$t()` cross-references, locale
      fallbacks (to "en" by default), and argument interpolation ({{placeholder}}).
- `ServerSetup` now initializes the `i18n` directory and `LocalizationService`.

OPDS Refactor & Enhancements:
- Replaces the previous `Opds.kt` and `OpdsDataClass.kt` with a new
  `OpdsFeedBuilder.kt` and a set of more granular, spec-aligned XML
  models (e.g., `OpdsFeedXml`, `OpdsEntryXml`, `OpdsLinkXml`).
- Integrates `LocalizationService` throughout all OPDS feeds:
    - All user-facing text (feed titles, entry titles, summaries,
      link titles, facet labels for sorting/filtering) is now localized.
    - Adds a `lang` query parameter to all OPDS endpoints to allow
      clients to request a specific UI language.
    - Uses the `Accept-Language` header as a fallback for language detection.
- The OpenSearch description (`/search` endpoint) is now localized and
  its template URL includes the determined language.
- Centralizes OPDS constants (namespaces, link relations, media types)
  in `OpdsConstants.kt`.
- Adds utility classes `OpdsDateUtil.kt`, `OpdsStringUtil.kt`, and
  `OpdsXmlUtil.kt` for common OPDS tasks.
- `MangaDataClass` now includes `sourceLang` to provide the content
  language of the manga in OPDS entries (`<dc:language>`).
- Updates OpenAPI documentation for OPDS endpoints with more detail
  and includes the new `lang` parameter.

Configuration:
- Adds `useBinaryFileSizes` server configuration option. File sizes in
  OPDS feeds now respect this setting (e.g., MiB vs MB), utilized via
  `OpdsStringUtil.formatFileSizeForOpds`.

This major refactor addresses the request for internationalization
originally mentioned in PR #1257 ("it would be great if messages were
adapted based on the user's language settings"). It builds upon the
foundational OPDS work in #1257 and subsequent enhancements in #1262,
#1263, #1278, and #1392, providing a more stable and extensible
OPDS implementation. Features like localized facet titles from #1392
are now fully integrated with the i18n system.

This resolves long-standing requests for better OPDS support (e.g., issue #769)
by making feeds more user-friendly, accessible, and standards-compliant,
also improving the robustness of features requested in #1390 (resolved by #1392)
and addressing underlying data needs for issues like #1265 (related to #1277, #1278).

* fix(opds): revert MIME type to application/xml for browser compatibility

* fix(opds): use chapter index for metadata feed and correct link relation

- Change `getChapterMetadataFeed` to use `chapterIndexFromPath` (sourceOrder)
  instead of `chapterIdFromPath` for fetching chapter data, ensuring
  consistency with how chapters are identified in manga feeds.
- Add error handling for cases where manga or chapter by index is not found.
- Correct OPDS link relation for chapter detail/fetch link in non-metadata
  chapter entries from `alternate` to `subsection` as per OPDS spec
  for navigation to more specific content or views.

* Use Moko-Resources

* Format

* Forgot the Languages.json

* refactor(opds)!: restructure OPDS feeds and introduce data repositories

This commit significantly refactors the OPDS v1.2 implementation by introducing dedicated repository classes for data fetching and by restructuring the feed generation logic for clarity and maintainability. The `chapterId` path parameter for chapter metadata feeds has been changed to `chapterIndex` (sourceOrder) to align with how chapters are identified in manga feeds.

BREAKING CHANGE: The OPDS endpoint for chapter metadata has changed from `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterId}/fetch` to `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterIndex}/fetch`. Clients will need to update to use the chapter's source order (index) instead of its database ID.

Key changes:
- Introduced `MangaRepository`, `ChapterRepository`, and `NavigationRepository` to encapsulate database queries and data transformation logic for OPDS feeds.
- Moved data fetching logic from `OpdsFeedBuilder` to these new repositories.
- `OpdsFeedBuilder` now primarily focuses on constructing the XML feed structure using DTOs provided by the repositories.
- Renamed `OpdsMangaAcqEntry.thumbnailUrl` to `rawThumbnailUrl` for clarity.
- Added various DTOs (e.g., `OpdsRootNavEntry`, `OpdsMangaDetails`, `OpdsChapterListAcqEntry`) to define clear data contracts between repositories and the feed builder.
- Simplified `OpdsV1Controller` by reorganizing feed endpoints into logical groups (Main Navigation, Filtered Acquisition, Item-Specific).
- Updated `OpdsAPI` to reflect the path parameter change for chapter metadata (`chapterIndex` instead of `chapterId`).
- Added `slugify()` utility to `OpdsStringUtil` for creating URL-friendly genre IDs.
- Standardized localization keys for root feed entry descriptions to use `*.entryContent` instead of `*.description`.
- Added `server.generated.BuildConfig` (likely from build process).

* style(opds): apply ktlint fixes

* Delete server/bin

* refactor(i18n): remove custom LocalizationService initialization

* refactor(i18n): remove unused imports from ServerSetup

* refactor(model): remove sourceLang from MangaDataClass

* refactor(opds): rename OPDS binary file size config property

- Rename `useBinaryFileSizes` to `opdsUseBinaryFileSizes` in code and config
- Update related condition check in formatFileSizeForOpds

BREAKING CHANGE: Existing server configurations using `server.useBinaryFileSizes` need to migrate to `server.opdsUseBinaryFileSizes`

* refactor(opds): improve OPDS endpoint structure and documentation

- Restructure endpoint paths for better resource hierarchy
- Add descriptive comments for each feed type and purpose
- Rename `/fetch` endpoint to `/metadata` for clarity
- Standardize feed naming conventions in route definitions

BREAKING CHANGE: Existing OPDS client integrations using old endpoint paths (`/manga/{mangaId}` and `/chapter/{chapterIndex}/fetch`) require updates to new paths (`/manga/{mangaId}/chapters` and `/chapter/{chapterIndex}/metadata`)

* fix(opds): Apply review suggestions for localization and comments

* Fix

* fix(opds): Update chapter links to include 'chapters' and 'metadata' in URLs

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
2025-05-26 20:46:14 -04:00

1018 lines
42 KiB
Kotlin

package suwayomi.tachidesk.opds.impl
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.opds.constants.OpdsConstants
import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry
import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry
import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry
import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
import suwayomi.tachidesk.opds.model.OpdsAuthorXml
import suwayomi.tachidesk.opds.model.OpdsCategoryXml
import suwayomi.tachidesk.opds.model.OpdsContentXml
import suwayomi.tachidesk.opds.model.OpdsEntryXml
import suwayomi.tachidesk.opds.model.OpdsFeedXml
import suwayomi.tachidesk.opds.model.OpdsLinkXml
import suwayomi.tachidesk.opds.model.OpdsSummaryXml
import suwayomi.tachidesk.opds.repository.ChapterRepository
import suwayomi.tachidesk.opds.repository.MangaRepository
import suwayomi.tachidesk.opds.repository.NavigationRepository
import suwayomi.tachidesk.opds.util.OpdsDateUtil
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatFileSizeForOpds
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
object OpdsFeedBuilder {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/")
private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds()
// --- Main Feed Generators ---
fun getRootFeed(
baseUrl: String,
locale: Locale,
): String {
val navItems = NavigationRepository.getRootNavigationItems(locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "root",
title = MR.strings.opds_feeds_root.localized(locale),
locale = locale,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = null, // Root is never paginated
).apply {
totalResults = navItems.size.toLong()
entries.addAll(
navItems.map { item: OpdsRootNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:root:${item.id}",
title = item.title,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_SUBSECTION,
href = "$baseUrl/${item.id}?lang=${locale.toLanguageTag()}",
type = item.linkType,
title = item.title,
),
),
content = OpdsContentXml(type = "text", value = item.description),
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getMangasFeed(
criteria: OpdsSearchCriteria?,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String =
if (criteria != null) {
getMangaSearchResultsFeed(criteria, baseUrl, locale)
} else {
getAllMangasFeed(baseUrl, pageNum, locale)
}
private fun getAllMangasFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.getAllManga(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "mangas",
title = MR.strings.opds_feeds_all_manga_title.localized(locale),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
private fun getMangaSearchResultsFeed(
criteria: OpdsSearchCriteria,
baseUrl: String,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria)
val queryParams = mutableListOf<String>()
criteria.query?.let { queryParams.add("query=${it.encodeForOpdsURL()}") }
criteria.author?.let { queryParams.add("author=${it.encodeForOpdsURL()}") }
criteria.title?.let { queryParams.add("title=${it.encodeForOpdsURL()}") }
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "mangas",
title = MR.strings.opds_feeds_search_results.localized(locale),
locale = locale,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
explicitQueryParams = queryParams.joinToString("&").ifEmpty { null },
pageNum = 1, // Search results always start at page 1 for this link
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getSourcesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (sourceNavEntries, total) = NavigationRepository.getSources(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "sources",
title = MR.strings.opds_feeds_sources_title.localized(locale),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
).apply {
totalResults = total
entries.addAll(
sourceNavEntries.map { entry: OpdsSourceNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:sources:${entry.id}",
title = entry.name,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/source/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.name,
),
),
// Consider adding icon as artwork link if needed
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getCategoriesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "categories",
title = MR.strings.opds_feeds_categories_title.localized(locale),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
).apply {
totalResults = total
entries.addAll(
categoryNavEntries.map { entry: OpdsCategoryNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:categories:${entry.id}",
title = entry.name,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/category/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.name,
),
),
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getGenresFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "genres",
title = MR.strings.opds_feeds_genres_title.localized(locale),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
).apply {
totalResults = total
entries.addAll(
genreNavEntries.map { entry: OpdsGenreNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:genres:${entry.id}", // Already encoded
title = entry.title,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/genre/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
),
),
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
// `pageNum` is ignored, always fetches all, and sets pageNum = null in builder.
fun getStatusFeed(
baseUrl: String,
@Suppress("UNUSED_PARAMETER") pageNum: Int,
locale: Locale,
): String {
val statuses = NavigationRepository.getStatuses(locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "status",
title = MR.strings.opds_feeds_status_title.localized(locale),
locale = locale,
pageNum = null, // Status feed is not paginated
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
).apply {
totalResults = statuses.size.toLong()
entries.addAll(
statuses.map { entry: OpdsStatusNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:status:${entry.id}",
title = entry.title,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/status/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
),
),
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getLanguagesFeed(
baseUrl: String,
uiLocale: Locale,
): String {
val languages = NavigationRepository.getContentLanguages(uiLocale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "languages",
title = MR.strings.opds_feeds_languages_title.localized(uiLocale),
locale = uiLocale,
pageNum = null, // Language feed is not paginated
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
).apply {
totalResults = languages.size.toLong()
entries.addAll(
languages.map { entry: OpdsLanguageNavEntry ->
OpdsEntryXml(
id = "urn:suwayomi:navigation:language:${entry.id}",
title = entry.title,
updated = currentFormattedTime(),
link =
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
),
),
)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
// --- Specific Acquisition Feed Generators ---
fun getMangaFeed(
mangaId: Int,
baseUrl: String,
pageNum: Int,
sortParam: String?,
filterParam: String?,
locale: Locale,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
"manga/$mangaId",
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
locale,
)
val (sortColumn, currentSortOrder) =
when (sortParam?.lowercase()) {
"asc", "number_asc" -> ChapterTable.sourceOrder to SortOrder.ASC
"desc", "number_desc" -> ChapterTable.sourceOrder to SortOrder.DESC
"date_asc" -> ChapterTable.date_upload to SortOrder.ASC
"date_desc" -> ChapterTable.date_upload to SortOrder.DESC
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value ?: SortOrder.ASC)
}
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
val (chapterEntries, totalChapters) =
ChapterRepository.getChaptersForManga(
mangaId,
pageNum,
sortColumn,
currentSortOrder,
currentFilter,
)
val actualSortParamForLinks =
sortParam ?: run {
val prefix = if (sortColumn == ChapterTable.sourceOrder) "number" else "date"
val suffix = if (currentSortOrder == SortOrder.ASC) "asc" else "desc"
"${prefix}_$suffix"
}
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "manga/$mangaId/chapters",
title = MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
currentSort = actualSortParamForLinks,
currentFilter = currentFilter,
).apply {
totalResults = totalChapters
icon = mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }
icon?.let {
links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG))
links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG))
}
addChapterSortAndFilterFacets(
this,
"$baseUrl/manga/$mangaId",
actualSortParamForLinks,
currentFilter,
locale,
sortColumn,
currentSortOrder,
)
entries.addAll(chapterEntries.map { chapter -> createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
suspend fun getChapterMetadataFeed(
mangaId: Int,
chapterSourceOrder: Int,
baseUrl: String,
locale: Locale,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
"manga/$mangaId/chapter/$chapterSourceOrder/metadata",
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
locale,
)
val chapterMetadata =
ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder)
?: return buildNotFoundFeed(
baseUrl,
"manga/$mangaId/chapter/$chapterSourceOrder/metadata",
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
locale,
)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
idPath = "manga/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
title = MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
locale = locale,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = null, // Metadata feed is single entry, not paginated
).apply {
totalResults = 1
icon = mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }
icon?.let {
links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG))
links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG))
}
entries.add(createChapterMetadataEntry(chapterMetadata, mangaDetails, locale))
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getSourceFeed(
sourceId: Long,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.getMangaBySource(sourceId, pageNum)
val sourceNavEntry = NavigationRepository.getSources(1).first.find { it.id == sourceId }
val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString()
val feedTitle =
MR.strings.opds_feeds_source_specific_title.localized(
locale,
sourceNameOrId,
)
val builder =
FeedBuilderInternal(
baseUrl,
"source/$sourceId",
feedTitle,
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
icon = sourceNavEntry?.iconUrl
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getCategoryFeed(
categoryId: Int,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.getMangaByCategory(categoryId, pageNum)
val categoryNavEntry = NavigationRepository.getCategories(1).first.find { it.id == categoryId }
val feedTitle = MR.strings.opds_feeds_category_specific_title.localized(locale, categoryNavEntry?.name ?: categoryId.toString())
val builder =
FeedBuilderInternal(
baseUrl,
"category/$categoryId",
feedTitle,
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getGenreFeed(
genre: String,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.getMangaByGenre(genre, pageNum)
val feedTitle = MR.strings.opds_feeds_genre_specific_title.localized(locale, genre)
val builder =
FeedBuilderInternal(
baseUrl,
"genre/${genre.encodeForOpdsURL()}",
feedTitle,
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getStatusMangaFeed(
statusDbId: Long,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val statusNavEntry = NavigationRepository.getStatuses(locale).find { it.id == statusDbId.toInt() }
val statusName = statusNavEntry?.title ?: statusDbId.toString()
val (mangaEntries, total) = MangaRepository.getMangaByStatus(statusDbId.toInt(), pageNum)
val feedTitle = MR.strings.opds_feeds_status_specific_title.localized(locale, statusName)
val builder =
FeedBuilderInternal(
baseUrl,
"status/$statusDbId",
feedTitle,
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getLanguageFeed(
contentLangCode: String,
baseUrl: String,
pageNum: Int,
uiLocale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.getMangaByContentLanguage(contentLangCode, pageNum)
val contentLanguageDisplayName = Locale.forLanguageTag(contentLangCode).getDisplayName(uiLocale)
val feedTitle = MR.strings.opds_feeds_language_specific_title.localized(uiLocale, contentLanguageDisplayName)
val builder =
FeedBuilderInternal(
baseUrl,
"language/$contentLangCode",
feedTitle,
locale = uiLocale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, uiLocale) })
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
fun getLibraryUpdatesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum)
val builder =
FeedBuilderInternal(
baseUrl,
"library-updates",
MR.strings.opds_feeds_library_updates_title.localized(locale),
locale = locale,
pageNum = pageNum,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
).apply {
totalResults = total
entries.addAll(
updateItems.map { item ->
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
},
)
}
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
// --- Entry Creation Helpers ---
private fun mangaAcqEntryToEntry(
entry: OpdsMangaAcqEntry,
baseUrl: String,
locale: Locale,
): OpdsEntryXml {
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
return OpdsEntryXml(
id = "urn:suwayomi:manga:${entry.id}",
title = entry.title,
updated = currentFormattedTime(),
authors = entry.author?.let { listOf(OpdsAuthorXml(name = it)) },
categories =
entry.genres.filter { it.isNotBlank() }.map { genre ->
OpdsCategoryXml(
term = genre.lowercase().replace(" ", "_"),
label = genre,
scheme = "$baseUrl/genres",
)
},
summary = entry.description?.let { OpdsSummaryXml(value = it) },
link =
listOfNotNull(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/manga/${entry.id}/chapters?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
),
displayThumbnailUrl?.let { OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG) },
displayThumbnailUrl?.let { OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG) },
),
language = entry.sourceLang,
)
}
private fun createChapterListEntry(
chapter: OpdsChapterListAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String,
addMangaTitle: Boolean,
locale: Locale,
): OpdsEntryXml {
val statusKey =
when {
chapter.read -> MR.strings.opds_chapter_status_read
chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
else -> MR.strings.opds_chapter_status_unread
}
val titlePrefix = statusKey.localized(locale)
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}"
val details =
buildString {
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
append(
MR.strings.opds_chapter_details_scanlator.localized(locale, it),
)
}
if (chapter.pageCount > 0) {
append(
MR.strings.opds_chapter_details_progress.localized(
locale,
chapter.lastPageRead,
chapter.pageCount,
),
)
}
}
return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}",
title = entryTitle,
updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate),
authors =
listOfNotNull(
manga.author?.let { OpdsAuthorXml(name = it) },
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
),
summary = OpdsSummaryXml(value = details),
link =
listOf(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_SUBSECTION,
href = "$baseUrl/manga/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}",
type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS,
title = MR.strings.opds_linktitle_view_chapter_details.localized(locale),
),
),
)
}
private suspend fun createChapterMetadataEntry(
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
locale: Locale,
): OpdsEntryXml {
val statusKey =
when {
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
chapter.read -> MR.strings.opds_chapter_status_read
chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
else -> MR.strings.opds_chapter_status_unread
}
val titlePrefix = statusKey.localized(locale)
val entryTitle = "$titlePrefix ${chapter.name}"
val details =
buildString {
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
append(
MR.strings.opds_chapter_details_scanlator.localized(locale, it),
)
}
val pageCountDisplay = chapter.pageCount.takeIf { it > 0 } ?: "?"
append(
MR.strings.opds_chapter_details_progress.localized(
locale,
chapter.lastPageRead,
pageCountDisplay,
),
)
}
val links = mutableListOf<OpdsLinkXml>()
var cbzFileSize: Long? = null
if (chapter.downloaded) {
val cbzStreamPair =
withContext(
Dispatchers.IO,
) { runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id) }.getOrNull() }
cbzFileSize = cbzStreamPair?.second
cbzStreamPair?.let {
links.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
"/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
OpdsConstants.TYPE_CBZ,
MR.strings.opds_linktitle_download_cbz.localized(locale),
),
)
}
}
if (chapter.pageCount > 0) {
links.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_PSE_STREAM,
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}",
OpdsConstants.TYPE_IMAGE_JPEG,
MR.strings.opds_linktitle_stream_pages.localized(locale),
pseCount = chapter.pageCount,
pseLastRead =
chapter.lastPageRead.takeIf {
it > 0
},
pseLastReadDate = chapter.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
),
)
links.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_IMAGE,
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0",
OpdsConstants.TYPE_IMAGE_JPEG,
MR.strings.opds_linktitle_chapter_cover.localized(locale),
),
)
}
return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}:metadata",
title = entryTitle,
updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate),
authors =
listOfNotNull(
manga.author?.let { OpdsAuthorXml(name = it) },
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
),
summary = OpdsSummaryXml(value = details),
link = links,
extent = cbzFileSize?.let { formatFileSizeForOpds(it) },
format = if (cbzFileSize != null) "CBZ" else null,
)
}
// --- Helpers & Internal Builder ---
private fun addChapterSortAndFilterFacets(
feedBuilder: FeedBuilderInternal,
baseMangaUrl: String,
currentSort: String,
currentFilter: String,
locale: Locale,
sortColumn: org.jetbrains.exposed.sql.Column<*>,
currentSortOrder: SortOrder,
) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val filterGroup = MR.strings.opds_facetgroup_read_status.localized(locale)
val addFacet = { rel: String, href: String, titleKey: StringResource, group: String, isActive: Boolean ->
feedBuilder.links.add(
OpdsLinkXml(
rel,
href,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
titleKey.localized(locale),
facetGroup = group,
activeFacet = isActive,
),
)
}
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?sort=number_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_sort_oldest_first,
sortGroup,
sortColumn == ChapterTable.sourceOrder && currentSortOrder == SortOrder.ASC,
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?sort=number_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_sort_newest_first,
sortGroup,
sortColumn == ChapterTable.sourceOrder && currentSortOrder == SortOrder.DESC,
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?sort=date_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_sort_date_asc,
sortGroup,
sortColumn == ChapterTable.date_upload && currentSortOrder == SortOrder.ASC,
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?sort=date_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_sort_date_desc,
sortGroup,
sortColumn == ChapterTable.date_upload && currentSortOrder == SortOrder.DESC,
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?filter=all&sort=$currentSort&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_filter_all_chapters,
filterGroup,
currentFilter == "all",
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?filter=unread&sort=$currentSort&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_filter_unread_only,
filterGroup,
currentFilter == "unread",
)
addFacet(
OpdsConstants.LINK_REL_FACET,
"$baseMangaUrl?filter=read&sort=$currentSort&lang=${locale.toLanguageTag()}",
MR.strings.opds_facet_filter_read_only,
filterGroup,
currentFilter == "read",
)
}
private fun buildNotFoundFeed(
baseUrl: String,
idPath: String,
title: String,
locale: Locale,
): String =
FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null)
.apply { totalResults = 0L }
.build()
.let(OpdsXmlUtil::serializeFeedToString)
private class FeedBuilderInternal(
val baseUrl: String,
val idPath: String,
val title: String,
val locale: Locale,
val feedType: String,
var pageNum: Int? = 1, // Nullable, default to 1 if needed, null means no pagination
var explicitQueryParams: String? = null,
val currentSort: String? = null,
val currentFilter: String? = null,
) {
val feedGeneratedAt: String = currentFormattedTime()
var totalResults: Long = 0
var icon: String? = null
val links = mutableListOf<OpdsLinkXml>()
val entries = mutableListOf<OpdsEntryXml>()
private fun buildUrlWithParams(
baseHrefPath: String,
page: Int?,
): String {
val sb = StringBuilder("$baseUrl/$baseHrefPath")
val queryParamsList = mutableListOf<String>()
explicitQueryParams?.takeIf { it.isNotBlank() }?.let {
queryParamsList.add(it)
}
// Only add pageNumber if pagination is active (pageNum is not null)
page?.let {
queryParamsList.add("pageNumber=$it")
}
currentSort?.let { queryParamsList.add("sort=$it") }
currentFilter?.let { queryParamsList.add("filter=$it") }
queryParamsList.add("lang=${locale.toLanguageTag()}")
if (queryParamsList.isNotEmpty()) {
sb.append("?").append(queryParamsList.joinToString("&"))
}
return sb.toString()
}
fun build(): OpdsFeedXml {
val actualPageNum = pageNum ?: 1
// val needsPagination = pageNum != null && totalResults > opdsItemsPerPageBounded
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null)
val feedLinks = mutableListOf<OpdsLinkXml>()
feedLinks.addAll(this.links)
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_SELF,
selfLinkHref,
feedType,
MR.strings.opds_linktitle_self_feed.localized(locale),
),
)
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_START,
"$baseUrl?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
MR.strings.opds_linktitle_catalog_root.localized(locale),
),
)
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_SEARCH,
"$baseUrl/search?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION,
MR.strings.opds_linktitle_search_catalog.localized(locale),
),
)
if (pageNum != null) { // Only add pagination links if pageNum was provided (meaning it's paginatable)
if (actualPageNum > 1) {
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_PREV,
buildUrlWithParams(idPath, actualPageNum - 1),
feedType,
MR.strings.opds_linktitle_previous_page.localized(locale),
),
)
}
if (totalResults > actualPageNum * opdsItemsPerPageBounded) {
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_NEXT,
buildUrlWithParams(idPath, actualPageNum + 1),
feedType,
MR.strings.opds_linktitle_next_page.localized(locale),
),
)
}
}
val urnParams = mutableListOf<String>()
urnParams.add(locale.toLanguageTag())
pageNum?.let { urnParams.add("page$it") }
explicitQueryParams?.let { urnParams.add(it.replace("&", ":").replace("=", "_")) }
currentSort?.let { urnParams.add("sort_$it") }
currentFilter?.let { urnParams.add("filter_$it") }
val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else ""
val showPaginationFields = pageNum != null && totalResults > 0
return OpdsFeedXml(
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
title = title,
updated = feedGeneratedAt,
icon = icon,
author = feedAuthor,
links = feedLinks,
entries = entries,
totalResults = totalResults.takeIf { showPaginationFields },
itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null,
startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null,
)
}
}
}