mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 17:34:39 -05:00
* 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>
1018 lines
42 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|
|
}
|