mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-09 05:44:33 -05:00
* feat(opds): add option to skip chapter metadata feed Introduces a new server configuration `server.opdsSkipChapterMetadataFeed` (default: false). When enabled, the OPDS chapter feed generates direct acquisition (CBZ download) and streaming (OPDS-PSE) links within the chapter list entries, bypassing the intermediate metadata subsection. This streamlines the user experience and improves compatibility with OPDS clients like KOReader that rely on direct links for automated downloading features. * fix: lint * fix(opds): enrich chapter data and refine sync logic for skip-metadata mode Refines the `opdsSkipChapterMetadataFeed` implementation to ensure necessary data is available for direct links and handles synchronization logic appropriate for a list view. - **Refactor ChapterForDownload:** Extract `refreshChapterPageList` and `updateChapterPersistence` to allow reusing page count verification logic outside the download flow. - **Enrich Chapter Repository:** When skipping metadata, asynchronously verify page counts and calculate CBZ file sizes for chapters in the list. This ensures direct stream/download links are valid even if the chapter wasn't previously fully indexed. - **KoSync Logic:** Implement synchronization logic in `OpdsEntryBuilder`. Since the user cannot be prompted in the chapter list view, `PROMPT` conflicts are explicitly ignored (prioritizing local progress), while updates are applied if non-conflicting. - **OPDS Attributes:** Add `length` (file size) to acquisition links and ensure download links only appear for actually downloaded chapters. - **Documentation:** Update `server.conf` description to clarify KoSync behavior in this mode. * feat(download): improve chapter download filenames * feat(opds): append language to source names * feat(opds): handle empty chapter titles * fix import org.jetbrains.exposed.v1.core.inList * refactor(opds): reorganize API routes and update facet count calculations based on active filters - **API Routing & Controllers**: Reorganize OPDS v1.2 route paths into logical groups in `OpdsAPI`. Centralize request filter extraction into `OpdsMangaFilter.fromContext`. - **Facet Counting**: Extract `Query.applyOpdsMangaFilter` to apply active filters to facet and navigation queries. Pass the active filters to `NavigationRepository` and `MangaRepository` count queries (using `excludeField` to calculate sibling counts). This ensures category, source, language, status, and genre counts (`thr:count`) are accurately computed based on active selections. - **Pagination**: Add pagination support to computed navigation feeds in `NavigationRepository` ( statuses and content languages). - **Builders**: Standardize parameter ordering in `FeedBuilderInternal` and `OpdsEntryBuilder` constructors. Simplify pagination and facet link URL generation. * fix(opds): remove redundant filter logic to avoid duplicate HAVING clauses Resolve IllegalStateException crash caused by applying content filters twice in MangaRepository. Filtering is now handled exclusively by `applyOpdsMangaFilter`, allowing `applyMangaLibrarySort` to focus solely on ordering operations. * revert(download): restore original CBZ filename scheme * refactor(opds): simplify persistence updates and clean up chapter mapping - Simplify page count and download checks in ChapterForDownload - Clean up enriched chapter mapping in ChapterRepository to improve readability * fix(opds): retrieve chapter archive size without leaving stream open * perf(opds): avoid redundant DB query when refreshing chapter page list
869 lines
36 KiB
Kotlin
869 lines
36 KiB
Kotlin
package suwayomi.tachidesk.opds.impl
|
|
|
|
import dev.icerock.moko.resources.StringResource
|
|
import suwayomi.tachidesk.i18n.MR
|
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
|
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
|
import suwayomi.tachidesk.opds.constants.OpdsConstants
|
|
import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry
|
|
import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
|
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
|
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.OpdsLinkXml
|
|
import suwayomi.tachidesk.opds.model.OpdsSummaryXml
|
|
import suwayomi.tachidesk.opds.repository.MangaRepository
|
|
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
|
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatFileSizeForOpds
|
|
import suwayomi.tachidesk.server.serverConfig
|
|
import java.util.Locale
|
|
|
|
/**
|
|
* A builder class responsible for creating OPDS Entry XML objects from data transfer objects.
|
|
*/
|
|
object OpdsEntryBuilder {
|
|
private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds()
|
|
|
|
/**
|
|
* Builds a concise summary for a manga entry, including status, source, and language.
|
|
* @param entry The manga data object.
|
|
* @param locale The locale for localization.
|
|
* @return A formatted summary string.
|
|
*/
|
|
private fun buildMangaSummary(
|
|
entry: OpdsMangaAcqEntry,
|
|
locale: Locale,
|
|
): String {
|
|
val summaryParts = mutableListOf<String>()
|
|
val statusKey =
|
|
when (MangaStatus.valueOf(entry.status)) {
|
|
MangaStatus.ONGOING -> MR.strings.manga_status_ongoing
|
|
MangaStatus.COMPLETED -> MR.strings.manga_status_completed
|
|
MangaStatus.LICENSED -> MR.strings.manga_status_licensed
|
|
MangaStatus.PUBLISHING_FINISHED -> MR.strings.manga_status_publishing_finished
|
|
MangaStatus.CANCELLED -> MR.strings.manga_status_cancelled
|
|
MangaStatus.ON_HIATUS -> MR.strings.manga_status_on_hiatus
|
|
else -> MR.strings.manga_status_unknown
|
|
}
|
|
summaryParts.add(MR.strings.opds_manga_summary_status.localized(locale, statusKey.localized(locale)))
|
|
summaryParts.add(MR.strings.opds_manga_summary_source.localized(locale, entry.sourceName))
|
|
summaryParts.add(MR.strings.opds_manga_summary_language.localized(locale, entry.sourceLang))
|
|
return summaryParts.joinToString(" | ")
|
|
}
|
|
|
|
/**
|
|
* Adds a facet link to the feed builder.
|
|
* @param feedBuilder The feed builder to add the link to.
|
|
* @param href The URL for the facet link.
|
|
* @param title The title of the facet.
|
|
* @param group The group this facet belongs to.
|
|
* @param isActive Whether this facet is currently active.
|
|
* @param count The number of items in this facet.
|
|
*/
|
|
private fun addFacet(
|
|
feedBuilder: FeedBuilderInternal,
|
|
href: String,
|
|
title: String,
|
|
group: String,
|
|
isActive: Boolean,
|
|
count: Long?,
|
|
) {
|
|
feedBuilder.links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_FACET,
|
|
href,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
title,
|
|
facetGroup = group,
|
|
activeFacet = isActive,
|
|
thrCount = count?.toInt(),
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Converts a manga data object into a full OPDS acquisition entry.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param locale The locale for localization.
|
|
* @param entry The manga data object.
|
|
* @return An [OpdsEntryXml] object representing the manga.
|
|
*/
|
|
fun mangaAcqEntryToEntry(
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
entry: OpdsMangaAcqEntry,
|
|
): OpdsEntryXml {
|
|
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
|
|
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres"
|
|
|
|
val links = mutableListOf<OpdsLinkXml>()
|
|
links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/series/${entry.id}/chapters?lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.title,
|
|
),
|
|
)
|
|
entry.url?.let {
|
|
links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_ALTERNATE,
|
|
it,
|
|
"text/html",
|
|
MR.strings.opds_linktitle_view_on_web.localized(locale),
|
|
),
|
|
)
|
|
}
|
|
displayThumbnailUrl?.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))
|
|
}
|
|
|
|
val summaryText = buildMangaSummary(entry, locale)
|
|
return OpdsEntryXml(
|
|
id = "urn:suwayomi:manga:${entry.id}",
|
|
title = entry.title,
|
|
updated = OpdsDateUtil.formatEpochMillisForOpds(entry.lastFetchedAt * 1000),
|
|
authors = entry.author?.let { listOf(OpdsAuthorXml(name = it)) },
|
|
categories =
|
|
entry.genres.filter { it.isNotBlank() }.map { genre ->
|
|
OpdsCategoryXml(
|
|
term = genre.lowercase().replace(" ", "_"),
|
|
label = genre,
|
|
scheme = categoryScheme,
|
|
)
|
|
},
|
|
summary = OpdsSummaryXml(value = summaryText),
|
|
content = entry.description?.let { OpdsContentXml(type = "text", value = it) },
|
|
link = links,
|
|
publisher = entry.sourceName,
|
|
language = entry.sourceLang,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Resolves the display title for a chapter, ensuring an empty name falls back to
|
|
* a generic "Chapter X" or "Oneshot" to keep the OPDS feed clean and usable.
|
|
*/
|
|
private fun resolveChapterTitle(
|
|
chapterName: String,
|
|
chapterNumber: Float,
|
|
sourceOrder: Int,
|
|
totalChapters: Long,
|
|
locale: Locale,
|
|
): String {
|
|
val trimmedName = chapterName.trim()
|
|
if (trimmedName.isNotEmpty()) {
|
|
return trimmedName
|
|
}
|
|
return if (totalChapters <= 1L) {
|
|
MR.strings.opds_chapter_title_oneshot.localized(locale)
|
|
} else {
|
|
val formatNumber =
|
|
if (chapterNumber >= 0f) {
|
|
if (chapterNumber % 1 == 0f) chapterNumber.toInt().toString() else chapterNumber.toString()
|
|
} else {
|
|
sourceOrder.toString()
|
|
}
|
|
MR.strings.opds_chapter_title_fallback.localized(locale, formatNumber)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an OPDS entry for a chapter, including acquisition and streaming links.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param locale The locale for localization.
|
|
* @param chapter The chapter data object.
|
|
* @param manga The parent manga's details.
|
|
* @param addMangaTitle Whether to prepend the manga title to the entry title.
|
|
* @param skipMetadataFeed Whether to skip the metadata feed.
|
|
* @return An [OpdsEntryXml] object for the chapter.
|
|
*/
|
|
suspend fun createChapterListEntry(
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
chapter: OpdsChapterListAcqEntry,
|
|
manga: OpdsMangaDetails,
|
|
addMangaTitle: Boolean,
|
|
skipMetadataFeed: Boolean,
|
|
): OpdsEntryXml {
|
|
var effectiveLastPageRead = chapter.lastPageRead
|
|
var effectiveLastReadAt = chapter.lastReadAt
|
|
|
|
if (skipMetadataFeed) {
|
|
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
|
|
|
// If sync strategy dictates an update (e.g. KEEP_REMOTE), use remote data.
|
|
// If sync strategy is PROMPT (isConflict=true), we ignore it here (effectively KEEP_LOCAL/DISABLED)
|
|
// because we cannot show a prompt in the chapter list feed.
|
|
if (syncResult != null && syncResult.shouldUpdate) {
|
|
effectiveLastPageRead = syncResult.pageRead
|
|
effectiveLastReadAt = syncResult.timestamp
|
|
}
|
|
}
|
|
|
|
val statusKey =
|
|
when {
|
|
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
|
|
chapter.read -> MR.strings.opds_chapter_status_read
|
|
effectiveLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
|
|
else -> MR.strings.opds_chapter_status_unread
|
|
}
|
|
val titlePrefix = statusKey.localized(locale)
|
|
val chapterName = resolveChapterTitle(chapter.name, chapter.chapterNumber, chapter.sourceOrder, manga.totalChapters, locale)
|
|
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " $chapterName"
|
|
val details =
|
|
buildString {
|
|
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapterName))
|
|
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, effectiveLastPageRead, chapter.pageCount))
|
|
}
|
|
}
|
|
|
|
val links = mutableListOf<OpdsLinkXml>()
|
|
|
|
if (skipMetadataFeed) {
|
|
// Provide Acquisition Link (Download CBZ) if downloaded
|
|
if (chapter.downloaded) {
|
|
links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
|
|
"/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
|
|
serverConfig.opdsCbzMimetype.value.mediaType,
|
|
MR.strings.opds_linktitle_download_cbz.localized(locale),
|
|
length = chapter.cbzFileSize,
|
|
),
|
|
)
|
|
}
|
|
|
|
// Provide Stream Link (OPDS-PSE) if page count is known
|
|
if (chapter.pageCount > 0) {
|
|
val basePageHref =
|
|
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" +
|
|
"?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true"
|
|
|
|
val titleRes =
|
|
if (effectiveLastPageRead > 0) {
|
|
MR.strings.opds_linktitle_stream_pages_continue
|
|
} else {
|
|
MR.strings.opds_linktitle_stream_pages_start
|
|
}
|
|
|
|
links.add(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_PSE_STREAM,
|
|
href = basePageHref,
|
|
type = OpdsConstants.TYPE_IMAGE_JPEG,
|
|
title = titleRes.localized(locale),
|
|
pseCount = chapter.pageCount,
|
|
pseLastRead = effectiveLastPageRead.takeIf { it > 0 },
|
|
pseLastReadDate = effectiveLastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
|
|
),
|
|
)
|
|
|
|
// Page 0 Cover
|
|
links.add(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_IMAGE,
|
|
href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0",
|
|
type = OpdsConstants.TYPE_IMAGE_JPEG,
|
|
title = MR.strings.opds_linktitle_chapter_cover.localized(locale),
|
|
),
|
|
)
|
|
}
|
|
} else {
|
|
// Link to Metadata Feed
|
|
links.add(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_SUBSECTION,
|
|
href = "$baseUrl/series/${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),
|
|
),
|
|
)
|
|
}
|
|
|
|
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 = links,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally.
|
|
*
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param locale The locale for localization.
|
|
* @param chapter The chapter metadata object.
|
|
* @param manga The parent manga's details.
|
|
* @return A `Pair` where the first element is the primary entry (always present) and the
|
|
* second is an optional entry representing the remote progress in case of a conflict.
|
|
*/
|
|
suspend fun createChapterMetadataEntries(
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
chapter: OpdsChapterMetadataAcqEntry,
|
|
manga: OpdsMangaDetails,
|
|
): Pair<OpdsEntryXml, OpdsEntryXml?> {
|
|
// Check remote progress before building the entry
|
|
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
|
|
|
// Exists a conflict if the sync service reports a conflict and the page numbers differ.
|
|
val hasConflict = syncResult?.isConflict == true && syncResult.pageRead != chapter.lastPageRead
|
|
|
|
if (hasConflict) {
|
|
// Generate two entries: one for local progress and another for remote.
|
|
val localEntry =
|
|
buildSingleChapterMetadataEntry(
|
|
baseUrl,
|
|
locale,
|
|
chapter,
|
|
manga,
|
|
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
|
|
isConflict = true,
|
|
)
|
|
|
|
val remoteEntry =
|
|
buildSingleChapterMetadataEntry(
|
|
baseUrl,
|
|
locale,
|
|
chapter,
|
|
manga,
|
|
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
|
|
isConflict = true,
|
|
)
|
|
return Pair(localEntry, remoteEntry)
|
|
} else {
|
|
// No conflict, generate a single entry. Use remote progress if a silent update occurred.
|
|
val progressSource =
|
|
if (syncResult?.shouldUpdate == true) {
|
|
ProgressSource.Remote(syncResult.pageRead, syncResult.timestamp, syncResult.device)
|
|
} else {
|
|
ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt)
|
|
}
|
|
|
|
val mainEntry =
|
|
buildSingleChapterMetadataEntry(
|
|
baseUrl,
|
|
locale,
|
|
chapter,
|
|
manga,
|
|
progressSource = progressSource,
|
|
isConflict = false,
|
|
)
|
|
return Pair(mainEntry, null)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents the source of progress information for a chapter.
|
|
*/
|
|
private sealed class ProgressSource {
|
|
abstract val lastPageRead: Int
|
|
abstract val lastReadAt: Long
|
|
|
|
data class Local(
|
|
override val lastPageRead: Int,
|
|
override val lastReadAt: Long,
|
|
) : ProgressSource()
|
|
|
|
data class Remote(
|
|
override val lastPageRead: Int,
|
|
override val lastReadAt: Long,
|
|
val device: String,
|
|
) : ProgressSource()
|
|
}
|
|
|
|
/**
|
|
* Helper function to build a single OpdsEntryXml for a chapter.
|
|
*/
|
|
private suspend fun buildSingleChapterMetadataEntry(
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
chapter: OpdsChapterMetadataAcqEntry,
|
|
manga: OpdsMangaDetails,
|
|
progressSource: ProgressSource,
|
|
isConflict: Boolean,
|
|
): OpdsEntryXml {
|
|
val idSuffix: String
|
|
val titlePrefix: String
|
|
|
|
when (progressSource) {
|
|
is ProgressSource.Local -> {
|
|
idSuffix = "" // No suffix for the primary/local entry
|
|
val statusKey =
|
|
when {
|
|
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
|
|
chapter.read -> MR.strings.opds_chapter_status_read
|
|
progressSource.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
|
|
else -> MR.strings.opds_chapter_status_unread
|
|
}
|
|
titlePrefix = statusKey.localized(locale)
|
|
}
|
|
|
|
is ProgressSource.Remote -> {
|
|
idSuffix = ":remote"
|
|
titlePrefix = MR.strings.opds_chapter_status_synced.localized(locale, progressSource.device)
|
|
}
|
|
}
|
|
|
|
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, progressSource.lastPageRead, pageCountDisplay))
|
|
}
|
|
|
|
val entryTitle = "$titlePrefix ${chapter.name}"
|
|
val cbzFileSize = chapter.cbzFileSize
|
|
|
|
val links = mutableListOf<OpdsLinkXml>()
|
|
chapter.url?.let {
|
|
links.add(
|
|
OpdsLinkXml(OpdsConstants.LINK_REL_ALTERNATE, it, "text/html", MR.strings.opds_linktitle_view_on_web.localized(locale)),
|
|
)
|
|
}
|
|
if (chapter.downloaded) {
|
|
links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
|
|
"/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
|
|
serverConfig.opdsCbzMimetype.value.mediaType,
|
|
MR.strings.opds_linktitle_download_cbz.localized(locale),
|
|
length = cbzFileSize,
|
|
),
|
|
)
|
|
}
|
|
if (chapter.pageCount > 0) {
|
|
val basePageHref =
|
|
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" +
|
|
"?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true"
|
|
|
|
val title: String =
|
|
when {
|
|
!isConflict -> {
|
|
val titleRes =
|
|
if (progressSource.lastPageRead > 0) {
|
|
MR.strings.opds_linktitle_stream_pages_continue
|
|
} else {
|
|
MR.strings.opds_linktitle_stream_pages_start
|
|
}
|
|
titleRes.localized(locale)
|
|
}
|
|
|
|
progressSource is ProgressSource.Local -> {
|
|
val titleRes =
|
|
if (progressSource.lastPageRead > 0) {
|
|
MR.strings.opds_linktitle_stream_pages_continue_local
|
|
} else {
|
|
MR.strings.opds_linktitle_stream_pages_start_local
|
|
}
|
|
titleRes.localized(locale)
|
|
}
|
|
|
|
progressSource is ProgressSource.Remote -> {
|
|
val titleRes =
|
|
if (progressSource.lastPageRead > 0) {
|
|
MR.strings.opds_linktitle_stream_pages_continue_remote
|
|
} else {
|
|
MR.strings.opds_linktitle_stream_pages_start_remote
|
|
}
|
|
titleRes.localized(locale, progressSource.device)
|
|
}
|
|
|
|
else -> {
|
|
// Should not happen
|
|
""
|
|
}
|
|
}
|
|
|
|
links.add(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_PSE_STREAM,
|
|
href = basePageHref,
|
|
type = OpdsConstants.TYPE_IMAGE_JPEG,
|
|
title = title,
|
|
pseCount = chapter.pageCount,
|
|
pseLastRead = progressSource.lastPageRead.takeIf { it > 0 },
|
|
pseLastReadDate = progressSource.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
|
|
),
|
|
)
|
|
links.add(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_IMAGE,
|
|
href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0",
|
|
type = OpdsConstants.TYPE_IMAGE_JPEG,
|
|
title = MR.strings.opds_linktitle_chapter_cover.localized(locale),
|
|
),
|
|
)
|
|
}
|
|
|
|
return OpdsEntryXml(
|
|
id = "urn:suwayomi:chapter:${chapter.id}:metadata$idSuffix",
|
|
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,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Adds sorting facet links for an 'Explore Source' feed.
|
|
*/
|
|
fun addSourceSortFacets(
|
|
feedBuilder: FeedBuilderInternal,
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
currentSort: String,
|
|
) {
|
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
|
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
|
|
feedBuilder.links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_FACET,
|
|
href,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
titleKey.localized(locale),
|
|
facetGroup = sortGroup,
|
|
activeFacet = isActive,
|
|
),
|
|
)
|
|
}
|
|
addFacet("$baseUrl?sort=popular&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_popular, currentSort == "popular")
|
|
addFacet("$baseUrl?sort=latest&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_latest, currentSort == "latest")
|
|
}
|
|
|
|
/**
|
|
* Adds sorting and filtering facet links for a chapter feed.
|
|
*/
|
|
fun addChapterSortAndFilterFacets(
|
|
feedBuilder: FeedBuilderInternal,
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
currentSort: String,
|
|
currentFilter: String,
|
|
filterCounts: Map<String, Long>? = null,
|
|
) {
|
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
|
val filterGroup = MR.strings.opds_facetgroup_filter_read_status.localized(locale)
|
|
|
|
val addSortFacet = { href: String, titleKey: StringResource, group: String, isActive: Boolean ->
|
|
feedBuilder.links.add(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_FACET,
|
|
href,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
titleKey.localized(locale),
|
|
facetGroup = group,
|
|
activeFacet = isActive,
|
|
),
|
|
)
|
|
}
|
|
|
|
addSortFacet(
|
|
"$baseUrl?sort=number_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_sort_oldest_first,
|
|
sortGroup,
|
|
currentSort == "number_asc",
|
|
)
|
|
addSortFacet(
|
|
"$baseUrl?sort=number_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_sort_newest_first,
|
|
sortGroup,
|
|
currentSort == "number_desc",
|
|
)
|
|
addSortFacet(
|
|
"$baseUrl?sort=date_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_sort_date_asc,
|
|
sortGroup,
|
|
currentSort == "date_asc",
|
|
)
|
|
addSortFacet(
|
|
"$baseUrl?sort=date_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_sort_date_desc,
|
|
sortGroup,
|
|
currentSort == "date_desc",
|
|
)
|
|
|
|
addFacet(
|
|
feedBuilder,
|
|
"$baseUrl?filter=all&sort=$currentSort&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_filter_all_chapters.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "all",
|
|
filterCounts?.get("all"),
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
"$baseUrl?filter=unread&sort=$currentSort&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_filter_unread_only.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "unread",
|
|
filterCounts?.get("unread"),
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
"$baseUrl?filter=read&sort=$currentSort&lang=${locale.toLanguageTag()}",
|
|
MR.strings.opds_facet_filter_read_only.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "read",
|
|
filterCounts?.get("read"),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Adds a comprehensive set of facet links for library feeds, covering sorting, content filtering,
|
|
* and cross-filtering by source, category, status, language, and genre.
|
|
*/
|
|
fun addLibraryFacets(
|
|
feedBuilder: FeedBuilderInternal,
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
activeFilters: OpdsMangaFilter,
|
|
) {
|
|
val currentSort = activeFilters.sort ?: "alpha_asc"
|
|
val currentFilter = activeFilters.filter ?: "all"
|
|
|
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
|
val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale)
|
|
val filterCounts = MangaRepository.getLibraryFilterCounts(activeFilters)
|
|
|
|
val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
|
|
val crossFilterParams = newFilters.toCrossFilterQueryParameters()
|
|
val sortParam = "sort=$newSort"
|
|
val filterParam = "filter=$newFilter"
|
|
val langParam = "lang=${locale.toLanguageTag()}"
|
|
val allParams = listOfNotNull(crossFilterParams, sortParam, filterParam, langParam).filter { it.isNotEmpty() }
|
|
"$baseUrl/library/series?${allParams.joinToString("&")}"
|
|
}
|
|
|
|
// --- Sort Facets ---
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "alpha_asc", currentFilter),
|
|
MR.strings.opds_facet_sort_alpha_asc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "alpha_asc",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "alpha_desc", currentFilter),
|
|
MR.strings.opds_facet_sort_alpha_desc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "alpha_desc",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "last_read_desc", currentFilter),
|
|
MR.strings.opds_facet_sort_last_read_desc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "last_read_desc",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "latest_chapter_desc", currentFilter),
|
|
MR.strings.opds_facet_sort_latest_chapter_desc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "latest_chapter_desc",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "date_added_desc", currentFilter),
|
|
MR.strings.opds_facet_sort_date_added_desc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "date_added_desc",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, "unread_desc", currentFilter),
|
|
MR.strings.opds_facet_sort_unread_desc.localized(locale),
|
|
sortGroup,
|
|
currentSort == "unread_desc",
|
|
null,
|
|
)
|
|
|
|
// --- Filter Facets ---
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, currentSort, "all"),
|
|
MR.strings.opds_facet_filter_all.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "all",
|
|
null,
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, currentSort, "unread"),
|
|
MR.strings.opds_facet_filter_unread_only.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "unread",
|
|
filterCounts["unread"],
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, currentSort, "downloaded"),
|
|
MR.strings.opds_facet_filter_downloaded.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "downloaded",
|
|
filterCounts["downloaded"],
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, currentSort, "ongoing"),
|
|
MR.strings.opds_facet_filter_ongoing.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "ongoing",
|
|
filterCounts["ongoing"],
|
|
)
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters, currentSort, "completed"),
|
|
MR.strings.opds_facet_filter_completed.localized(locale),
|
|
filterGroup,
|
|
currentFilter == "completed",
|
|
filterCounts["completed"],
|
|
)
|
|
|
|
// --- Cross-Filter Facets ---
|
|
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
|
|
val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
|
|
MR.strings.opds_facet_all_sources.localized(locale),
|
|
MR.strings.opds_facetgroup_filter_source.localized(locale),
|
|
activeFilters.sourceId == null,
|
|
null,
|
|
)
|
|
sources.forEach {
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.with("source_id", it.id.toString()), currentSort, currentFilter),
|
|
it.name,
|
|
MR.strings.opds_facetgroup_filter_source.localized(locale),
|
|
activeFilters.sourceId == it.id,
|
|
it.mangaCount,
|
|
)
|
|
}
|
|
}
|
|
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
|
|
val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
|
|
MR.strings.opds_facet_all_categories.localized(locale),
|
|
MR.strings.opds_facetgroup_filter_category.localized(locale),
|
|
activeFilters.categoryId == null,
|
|
null,
|
|
)
|
|
categories.forEach {
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.with("category_id", it.id.toString()), currentSort, currentFilter),
|
|
it.name,
|
|
MR.strings.opds_facetgroup_filter_category.localized(locale),
|
|
activeFilters.categoryId == it.id,
|
|
it.mangaCount,
|
|
)
|
|
}
|
|
}
|
|
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
|
|
val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
|
|
MR.strings.opds_facet_all_statuses.localized(locale),
|
|
MR.strings.opds_facetgroup_filter_status.localized(locale),
|
|
activeFilters.statusId == null,
|
|
null,
|
|
)
|
|
statuses.forEach {
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.with("status_id", it.id.toString()), currentSort, currentFilter),
|
|
it.title,
|
|
MR.strings.opds_facetgroup_filter_status.localized(locale),
|
|
activeFilters.statusId == it.id,
|
|
it.mangaCount,
|
|
)
|
|
}
|
|
}
|
|
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
|
|
val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
|
|
MR.strings.opds_facet_all_languages.localized(locale),
|
|
MR.strings.opds_facetgroup_filter_language.localized(locale),
|
|
activeFilters.langCode == null,
|
|
null,
|
|
)
|
|
languages.forEach {
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.with("lang_code", it.id), currentSort, currentFilter),
|
|
it.title,
|
|
MR.strings.opds_facetgroup_filter_language.localized(locale),
|
|
activeFilters.langCode == it.id,
|
|
it.mangaCount,
|
|
)
|
|
}
|
|
}
|
|
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
|
|
val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.without("genre"), currentSort, currentFilter),
|
|
MR.strings.opds_facet_all_genres.localized(locale),
|
|
MR.strings.opds_facetgroup_filter_genre.localized(locale),
|
|
activeFilters.genre == null,
|
|
null,
|
|
)
|
|
genres.forEach {
|
|
addFacet(
|
|
feedBuilder,
|
|
buildUrl(activeFilters.with("genre", it.id), currentSort, currentFilter),
|
|
it.title,
|
|
MR.strings.opds_facetgroup_filter_genre.localized(locale),
|
|
activeFilters.genre == it.id,
|
|
it.mangaCount,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|