mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-22 12:13:34 -05:00
Overhaul OPDS feeds for discovery, filtering, and enhanced UX (#1543)
* fix: correct chapter facets URL to include /chapters endpoint
Update addChapterSortAndFilterFacets to use the correct URL path
from `/manga/{id}` to `/manga/{id}/chapters` for proper routing.
* feat(opds): restructure feeds and add exploration capabilities
This commit completely refactors the OPDS v1.2 implementation to align it more closely with the WebUI experience, separating "Library" browsing from "Explore" functionality.
Key changes include:
- The root feed is now a navigation feed directing to distinct "Library" and "Explore" sections.
- A new "History" feed has been added to the root to show recently read chapters.
- The "Explore" section now allows browsing all available sources, not just those with manga in the library.
- Feeds for exploring a source now support faceting by "Popular" and "Latest", mirroring the WebUI.
- The "Library" section retains all previous browsing methods (by category, genre, status, etc.).
- Facet link generation has been corrected to use the proper base URL, fixing broken navigation in chapter lists.
- The `OpdsFeedBuilder.kt` file has been refactorized into smaller, more manageable helper files (`OpdsEntryBuilder.kt`, `OpdsFeedHelper.kt`) to resolve a `java.lang.OutOfMemoryError: GC overhead limit exceeded` error during compilation.
- All OPDS-related strings (`strings.xml`) have been updated to reflect the new structure and improve clarity.
This new structure provides a much more intuitive and powerful browsing experience for OPDS clients, enabling content discovery in addition to library management.
* feat(opds)!: implement advanced filtering and sorting for library feeds
This commit significantly enhances the OPDS library feeds by introducing advanced sorting and filtering capabilities, mirroring the features available in the WebUI. It also standardizes the terminology from "manga" to "series" across all user-facing OPDS feeds for better clarity and consistency.
Key Features & Changes:
- **Library Facets:** All library feeds (All Series, By Source, By Category, By Genre, etc.) now include OPDS facets for:
- **Sorting:** By title (A-Z, Z-A), last read, latest chapter, date added, and total unread chapters.
- **Filtering:** By content status including unread, downloaded, ongoing, and completed.
- **Terminology Update:** The term "manga" has been replaced with "series" in all user-facing OPDS titles, descriptions, and endpoints to align with the frontend terminology.
- **Code Refactoring:**
- `MangaRepository` has been updated with the correct Exposed SQL syntax (`Case`/`sum` for conditional counts, `having` clause for filtering on aggregates) to support the new facets.
- `OpdsEntryBuilder` now includes a new function `addLibraryMangaSortAndFilterFacets` to generate the facet links.
- `OpdsV1Controller` and `OpdsFeedBuilder` have been updated to handle the new `sort` and `filter` parameters and to call the new facet generation logic.
BREAKING CHANGE: The API endpoints for manga have been renamed to use 'series'. Any client implementation will need to update its routes.
For example, `/api/opds/v1.2/manga/{id}/chapters` is now `/api/opds/v1.2/series/{id}/chapters`.
* feat(opds): add item counts (thr:count) to navigation and facet links
This change enhances the OPDS feeds by including the number of items for various navigation links and filter facets, adhering to the OPDS 1.2 specification.
The `thr:count` attribute provides a hint to clients about the number of entries in a linked feed, significantly improving the user experience by showing counts upfront.
- Navigation Feeds (Categories, Sources, Genres, Statuses, Languages) now display the total number of manga for each entry in their respective links.
- Acquisition Feeds for the library and chapters now include counts for their filter facets (e.g., Unread, Downloaded, Completed).
This required updating DTOs to carry count data, modifying repository queries to calculate these counts efficiently, and adjusting the feed builders to include the `thr:count` attribute in the generated XML.
* refactor(opds)!: simplify root feed by removing library sub-level
The OPDS feed navigation was previously nested, requiring users to first select "Library" and then navigate to a subsection like "All Series" or "Categories". This extra step is cumbersome for OPDS clients and complicates the user experience.
This change elevates all library-related navigation entries directly to the root feed, flattening the hierarchy and making content more accessible.
As part of this refactoring:
- The `getLibraryFeed` builder and its corresponding controller/API endpoints have been removed.
- Unused string resources for the "Library" entry have been deleted.
BREAKING CHANGE: The `/api/opds/v1.2/library` endpoint has been removed. Clients should now discover library sections directly from the root feed at `/api/opds/v1.2`.
* feat(opds): enhance feeds with comprehensive manga and chapter details
This commit significantly enriches the OPDS feeds to provide a more detailed and compliant user experience.
- Refactored `OpdsMangaAcqEntry` and `OpdsChapterMetadataAcqEntry` to include additional fields such as status, source information, author, description, and web URLs.
- The OPDS entry builder (`OpdsEntryBuilder`) now populates entries with this richer metadata, including summaries, content descriptions, authors, and categories, aligning more closely with the OPDS Catalog specification.
- Added OPDS constants for 'popular' and 'new' sort relations to align with the specification.
- Included "alternate" links for both manga and chapters, allowing clients to open the item on its source website ("View on web").
- Updated internationalization strings and constants to support the new features and metadata.
* fix(opds): fetch chapters for non-library manga in feed
Previously, when accessing the OPDS chapter feed for a manga discovered via the "Explore" feature (and thus not yet in the library), the feed would be empty. This was because the feed generation logic only queried the local database, which had no chapter entries for these manga.
This commit resolves the issue by modifying `getSeriesChaptersFeed` to be a suspend function. It now implements a fallback mechanism:
- It first attempts to load chapters from the local database.
- If no chapters are found, it triggers an online fetch from the source to populate the database.
- It then re-queries the local data to build the complete chapter feed.
This ensures that chapter lists are correctly displayed for all manga, whether they are in the library or being explored for the first time.
Additionally, this commit includes a minor correction to the URN identifier for the root feed to better align with its path.
* feat(opds): provide direct stream and acquisition links when page count is known
Previously, the OPDS chapter feed always provided a single link to a separate metadata feed for each chapter. This was done to defer the costly operation of fetching the page count for undownloaded chapters, ensuring the main chapter list loaded quickly.
This commit introduces a more efficient, conditional approach. If a chapter's page count is already known (e.g., because it's downloaded or has been previously fetched), the chapter feed entry now includes direct links for:
- OPDS-PSE page streaming (`pse:stream`).
- CBZ file acquisition (`acquisition/open-access`).
- Chapter cover image (`image`).
If the page count is not known, the entry falls back to the previous behavior, linking to the metadata feed to perform the page count lookup on-demand.
This significantly improves the user experience for OPDS clients by reducing the number of requests needed to start reading or downloading chapters that are already available on the server, making navigation faster and more fluid.
* fix(opds): resolve suspend calls and add missing lastReadAt for OPDS feeds
The OPDS feed generation was failing to compile due to two main issues:
1. The `OpdsChapterListAcqEntry` DTO was missing the `lastReadAt` property, which is required for the OPDS-PSE `lastReadDate` attribute.
2. Several functions in `OpdsFeedBuilder` were attempting to call the `suspend` function `createChapterListEntry` from a non-coroutine context.
This commit resolves these issues by:
- Adding the `lastReadAt` field to `OpdsChapterListAcqEntry` and populating it correctly from the database in the `ChapterRepository`.
- Refactoring `getHistoryFeed`, `getLibraryUpdatesFeed`, and `getSeriesChaptersFeed` in `OpdsFeedBuilder` to be `suspend` functions.
- Wrapping the entry creation logic in `withContext(Dispatchers.IO)` to provide the necessary coroutine scope for the suspend call and to perform the mapping on a background thread.
* refactor(opds): standardize library feed generation and enhance facets
This commit refactors the OPDS v1.2 feed generation logic to improve code structure, correctness, and feature capability.
The primary changes include:
- A new private `getLibraryFeed` helper function in `OpdsV1Controller` has been introduced to centralize and DRY up the logic for creating library-specific acquisition feeds.
- A new `OpdsMangaFilter` DTO now encapsulates all filtering, sorting, and pagination parameters, simplifying the controller handlers and making them more maintainable.
- URL generation for category, genre, status, and language feeds has been corrected. Links now correctly point to root-level paths (e.g., `/opds/v1.2/genre/{name}`) instead of being incorrectly nested under `/library/`.
- The OPDS facet system is enhanced with more specific facet groups and "All" links for a better user experience when clearing filters.
Associated changes:
- i18n strings in `strings.xml` have been reorganized with comments and new strings have been added to support the enhanced facet groups.
- The route for the publication status feed has been renamed from `/status/{id}` to `/statuses` for consistency.
- KDoc comments have been added and improved throughout the affected files for better code documentation.
* fix(opds): revert direct acquisition links in chapter feeds to improve performance
This reverts commit 33cdc0d534292760a3225cee18e274df542f0778.
The previous change introduced direct stream and download links in chapter list feeds when the page count was known. While convenient, this caused a significant performance degradation on feeds with many chapters, as it required checking for the existence of a CBZ file for every single entry.
This commit restores the original behavior where chapter list entries always link to a dedicated metadata feed. This approach defers expensive I/O operations until a user explicitly requests a single chapter's details, ensuring that chapter list feeds load quickly and efficiently. Direct acquisition and streaming links are now exclusively generated within the metadata feed.
This commit is contained in:
@@ -6,7 +6,9 @@ import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
@@ -14,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry
|
||||
import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry
|
||||
import suwayomi.tachidesk.opds.dto.OpdsHistoryAcqEntry
|
||||
import suwayomi.tachidesk.opds.dto.OpdsLibraryUpdateAcqEntry
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
|
||||
@@ -31,6 +34,7 @@ object ChapterRepository {
|
||||
scanlator = this[ChapterTable.scanlator],
|
||||
read = this[ChapterTable.isRead],
|
||||
lastPageRead = this[ChapterTable.lastPageRead],
|
||||
lastReadAt = this[ChapterTable.lastReadAt],
|
||||
sourceOrder = this[ChapterTable.sourceOrder],
|
||||
pageCount = this[ChapterTable.pageCount],
|
||||
)
|
||||
@@ -49,7 +53,6 @@ object ChapterRepository {
|
||||
when (filter) {
|
||||
"unread" -> conditions.add(ChapterTable.isRead eq false)
|
||||
"read" -> conditions.add(ChapterTable.isRead eq true)
|
||||
// "all" -> no additional condition
|
||||
}
|
||||
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
|
||||
conditions.add(ChapterTable.isDownloaded eq true)
|
||||
@@ -92,6 +95,7 @@ object ChapterRepository {
|
||||
sourceOrder = chapterDataClass.index,
|
||||
downloaded = chapterDataClass.downloaded,
|
||||
pageCount = chapterDataClass.pageCount,
|
||||
url = chapterDataClass.realUrl,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -117,7 +121,7 @@ object ChapterRepository {
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map {
|
||||
OpdsLibraryUpdateAcqEntry(
|
||||
chapter = it.toOpdsChapterListAcqEntry(), // This will work if ChapterTable columns do not collide
|
||||
chapter = it.toOpdsChapterListAcqEntry(),
|
||||
mangaTitle = it[MangaTable.title],
|
||||
mangaAuthor = it[MangaTable.author],
|
||||
mangaId = it[MangaTable.id].value,
|
||||
@@ -127,4 +131,49 @@ object ChapterRepository {
|
||||
}
|
||||
Pair(items, totalCount)
|
||||
}
|
||||
|
||||
fun getHistory(pageNum: Int): Pair<List<OpdsHistoryAcqEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
ChapterTable
|
||||
.join(MangaTable, JoinType.INNER, ChapterTable.manga, MangaTable.id)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(
|
||||
ChapterTable.columns + MangaTable.title + MangaTable.author + MangaTable.thumbnail_url + MangaTable.id +
|
||||
SourceTable.lang,
|
||||
).where { ChapterTable.lastReadAt greater 0L }
|
||||
|
||||
val totalCount = query.count()
|
||||
|
||||
val items =
|
||||
query
|
||||
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map {
|
||||
OpdsHistoryAcqEntry(
|
||||
chapter = it.toOpdsChapterListAcqEntry(),
|
||||
mangaTitle = it[MangaTable.title],
|
||||
mangaAuthor = it[MangaTable.author],
|
||||
mangaId = it[MangaTable.id].value,
|
||||
mangaSourceLang = it[SourceTable.lang],
|
||||
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
|
||||
)
|
||||
}
|
||||
Pair(items, totalCount)
|
||||
}
|
||||
|
||||
fun getChapterFilterCounts(mangaId: Int): Map<String, Long> =
|
||||
transaction {
|
||||
val baseQuery = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId }
|
||||
val readCount = baseQuery.copy().andWhere { ChapterTable.isRead eq true }.count()
|
||||
val unreadCount = baseQuery.copy().andWhere { ChapterTable.isRead eq false }.count()
|
||||
val allCount = baseQuery.copy().count()
|
||||
|
||||
mapOf(
|
||||
"read" to readCount,
|
||||
"unread" to unreadCount,
|
||||
"all" to allCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
package suwayomi.tachidesk.opds.repository
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import org.jetbrains.exposed.sql.Case
|
||||
import org.jetbrains.exposed.sql.JoinType
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.Query
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
|
||||
import org.jetbrains.exposed.sql.alias
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.andWhere
|
||||
import org.jetbrains.exposed.sql.intLiteral
|
||||
import org.jetbrains.exposed.sql.lowerCase
|
||||
import org.jetbrains.exposed.sql.max
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.sum
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import suwayomi.tachidesk.opds.dto.OpdsLibraryFeedResult
|
||||
import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry
|
||||
import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
|
||||
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
||||
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
|
||||
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
|
||||
/**
|
||||
* Repository for fetching manga data tailored for OPDS feeds.
|
||||
*/
|
||||
object MangaRepository {
|
||||
private val opdsItemsPerPageBounded: Int
|
||||
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
|
||||
|
||||
/**
|
||||
* Maps a database [ResultRow] to an [OpdsMangaAcqEntry] data transfer object.
|
||||
* @return The mapped [OpdsMangaAcqEntry].
|
||||
*/
|
||||
private fun ResultRow.toOpdsMangaAcqEntry(): OpdsMangaAcqEntry =
|
||||
OpdsMangaAcqEntry(
|
||||
id = this[MangaTable.id].value,
|
||||
@@ -32,20 +54,83 @@ object MangaRepository {
|
||||
genres = this[MangaTable.genre].toGenreList(),
|
||||
description = this[MangaTable.description],
|
||||
thumbnailUrl = this[MangaTable.thumbnail_url],
|
||||
sourceLang = this.getOrNull(SourceTable.lang),
|
||||
sourceLang = this[SourceTable.lang],
|
||||
inLibrary = this[MangaTable.inLibrary],
|
||||
status = this[MangaTable.status],
|
||||
sourceName = this[SourceTable.name],
|
||||
lastFetchedAt = this[MangaTable.lastFetchedAt],
|
||||
url = this[MangaTable.realUrl],
|
||||
)
|
||||
|
||||
fun getAllManga(pageNum: Int): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
/**
|
||||
* Centralized function to retrieve paginated, sorted, and filtered manga from the library.
|
||||
* @param pageNum The page number for pagination.
|
||||
* @param sort The sorting parameter.
|
||||
* @param filter The filtering parameter.
|
||||
* @param criteria Additional filtering criteria for categories, sources, etc.
|
||||
* @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name.
|
||||
*/
|
||||
fun getLibraryManga(
|
||||
pageNum: Int,
|
||||
sort: String?,
|
||||
filter: String?,
|
||||
criteria: OpdsMangaFilter,
|
||||
): OpdsLibraryFeedResult =
|
||||
transaction {
|
||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val unreadCount = unreadCountExpr.alias("unread_count")
|
||||
|
||||
// Base query with necessary joins for filtering and sorting
|
||||
val query =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
|
||||
|
||||
// Apply specific filters from criteria
|
||||
criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } }
|
||||
criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } }
|
||||
criteria.statusId?.let { query.andWhere { MangaTable.status eq it } }
|
||||
criteria.langCode?.let { query.andWhere { SourceTable.lang eq it } }
|
||||
criteria.genre?.let { genre ->
|
||||
val genreTrimmed = genre.trim()
|
||||
val genreCondition =
|
||||
(MangaTable.genre like "%, $genreTrimmed, %") or
|
||||
(MangaTable.genre like "$genreTrimmed, %") or
|
||||
(MangaTable.genre like "%, $genreTrimmed") or
|
||||
(MangaTable.genre eq genreTrimmed)
|
||||
query.andWhere { genreCondition }
|
||||
}
|
||||
|
||||
// Efficiently get the name of the primary filter item
|
||||
val specificFilterName =
|
||||
when (criteria.primaryFilter) {
|
||||
PrimaryFilterType.SOURCE ->
|
||||
criteria.sourceId?.let {
|
||||
SourceTable
|
||||
.select(SourceTable.name)
|
||||
.where { SourceTable.id eq it }
|
||||
.firstOrNull()
|
||||
?.get(SourceTable.name)
|
||||
}
|
||||
PrimaryFilterType.CATEGORY ->
|
||||
criteria.categoryId?.let {
|
||||
CategoryTable
|
||||
.select(CategoryTable.name)
|
||||
.where { CategoryTable.id eq it }
|
||||
.firstOrNull()
|
||||
?.get(CategoryTable.name)
|
||||
}
|
||||
PrimaryFilterType.GENRE -> criteria.genre
|
||||
PrimaryFilterType.STATUS -> criteria.statusId.toString() // Controller will map this to a localized string
|
||||
PrimaryFilterType.LANGUAGE -> criteria.langCode // Controller will map this to a display name
|
||||
else -> null
|
||||
}
|
||||
|
||||
applyMangaLibrarySortAndFilter(query, sort, filter)
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
@@ -53,9 +138,48 @@ object MangaRepository {
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
|
||||
OpdsLibraryFeedResult(mangas, totalCount, specificFilterName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a paginated list of manga from a specific source (for exploration).
|
||||
* @param sourceId The ID of the source.
|
||||
* @param pageNum The page number for pagination.
|
||||
* @param sort The sorting parameter ('popular' or 'latest').
|
||||
* @return A pair containing the list of [OpdsMangaAcqEntry] and a boolean indicating if there's a next page.
|
||||
*/
|
||||
suspend fun getMangaBySource(
|
||||
sourceId: Long,
|
||||
pageNum: Int,
|
||||
sort: String,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Boolean> {
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(sourceId)
|
||||
val mangasPage: MangasPage =
|
||||
if (sort == "latest" && source.supportsLatest) {
|
||||
source.getLatestUpdates(pageNum)
|
||||
} else {
|
||||
source.getPopularManga(pageNum)
|
||||
}
|
||||
|
||||
val mangaIds = mangasPage.insertOrUpdate(sourceId)
|
||||
val mangaEntries =
|
||||
transaction {
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.name + SourceTable.lang)
|
||||
.where { MangaTable.id inList mangaIds }
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
}.sortedBy { manga -> mangaIds.indexOf(manga.id) }
|
||||
|
||||
return Pair(mangaEntries, mangasPage.hasNextPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds manga in the library based on search criteria (query, author, title).
|
||||
* @param criteria The search criteria.
|
||||
* @return A pair containing the list of matching [OpdsMangaAcqEntry] and the total count.
|
||||
*/
|
||||
fun findMangaByCriteria(criteria: OpdsSearchCriteria): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
transaction {
|
||||
val conditions = mutableListOf<Op<Boolean>>()
|
||||
@@ -76,15 +200,14 @@ object MangaRepository {
|
||||
conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%")
|
||||
}
|
||||
|
||||
val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
|
||||
val finalCondition = conditions.reduce { acc, op -> acc and op }
|
||||
|
||||
val query =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.select(MangaTable.columns + SourceTable.name + SourceTable.lang)
|
||||
.where(finalCondition)
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.groupBy(MangaTable.id, SourceTable.name, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
|
||||
val totalCount = query.count()
|
||||
@@ -95,6 +218,11 @@ object MangaRepository {
|
||||
Pair(mangas, totalCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves basic details for a single manga, used for populating chapter feed metadata.
|
||||
* @param mangaId The ID of the manga.
|
||||
* @return An [OpdsMangaDetails] object or null if not found.
|
||||
*/
|
||||
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
|
||||
transaction {
|
||||
MangaTable
|
||||
@@ -111,126 +239,70 @@ object MangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMangaBySource(
|
||||
sourceId: Long,
|
||||
pageNum: Int,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.where { MangaTable.sourceReference eq sourceId }
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
/**
|
||||
* Applies sorting and filtering logic to a manga library query.
|
||||
* @param query The Exposed SQL query to modify.
|
||||
* @param sort The sorting parameter.
|
||||
* @param filter The filtering parameter.
|
||||
*/
|
||||
private fun applyMangaLibrarySortAndFilter(
|
||||
query: Query,
|
||||
sort: String?,
|
||||
filter: String?,
|
||||
) {
|
||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val lastReadAtExpr = ChapterTable.lastReadAt.max()
|
||||
val latestChapterDateExpr = ChapterTable.date_upload.max()
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
// Apply filtering using HAVING for aggregate functions or WHERE for direct columns
|
||||
when (filter) {
|
||||
"unread" -> query.having { unreadCountExpr greater 0 }
|
||||
"downloaded" -> query.having { downloadedCountExpr greater 0 }
|
||||
"ongoing" -> query.andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
|
||||
"completed" -> query.andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
|
||||
}
|
||||
|
||||
fun getMangaByCategory(
|
||||
categoryId: Int,
|
||||
pageNum: Int,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
MangaTable
|
||||
.join(CategoryMangaTable, JoinType.INNER, MangaTable.id, CategoryMangaTable.manga)
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.where { CategoryMangaTable.category eq categoryId }
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
// Apply sorting
|
||||
when (sort) {
|
||||
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
"alpha_desc" -> query.orderBy(MangaTable.title to SortOrder.DESC)
|
||||
"last_read_desc" -> query.orderBy(lastReadAtExpr to SortOrder.DESC_NULLS_LAST)
|
||||
"latest_chapter_desc" -> query.orderBy(latestChapterDateExpr to SortOrder.DESC_NULLS_LAST)
|
||||
"date_added_desc" -> query.orderBy(MangaTable.inLibraryAt to SortOrder.DESC)
|
||||
"unread_desc" -> query.orderBy(unreadCountExpr to SortOrder.DESC)
|
||||
else -> query.orderBy(MangaTable.title to SortOrder.ASC) // Default sort
|
||||
}
|
||||
}
|
||||
|
||||
fun getMangaByGenre(
|
||||
genre: String,
|
||||
pageNum: Int,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
/**
|
||||
* Calculates the count of manga for various library filter facets.
|
||||
* @return A map where keys are filter names and values are the counts.
|
||||
*/
|
||||
fun getLibraryFilterCounts(): Map<String, Long> =
|
||||
transaction {
|
||||
val genreTrimmed = genre.trim()
|
||||
val query =
|
||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||
|
||||
val baseQuery =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.where {
|
||||
(
|
||||
(MangaTable.genre like "%, $genreTrimmed, %") or
|
||||
(MangaTable.genre like "$genreTrimmed, %") or
|
||||
(MangaTable.genre like "%, $genreTrimmed") or
|
||||
(MangaTable.genre eq genreTrimmed)
|
||||
) and (MangaTable.inLibrary eq true)
|
||||
}.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||
.select(MangaTable.id)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(MangaTable.id)
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
}
|
||||
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
|
||||
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count()
|
||||
|
||||
fun getMangaByStatus(
|
||||
statusId: Int,
|
||||
pageNum: Int,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.where { MangaTable.status eq statusId }
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
val statusBaseQuery = MangaTable.select(MangaTable.id).where { MangaTable.inLibrary eq true }
|
||||
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
|
||||
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
}
|
||||
|
||||
fun getMangaByContentLanguage(
|
||||
langCode: String,
|
||||
pageNum: Int,
|
||||
): Pair<List<OpdsMangaAcqEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
MangaTable
|
||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.select(MangaTable.columns + SourceTable.lang)
|
||||
.where { SourceTable.lang eq langCode }
|
||||
.groupBy(MangaTable.id, SourceTable.lang)
|
||||
.orderBy(MangaTable.title to SortOrder.ASC)
|
||||
|
||||
val totalCount = query.count()
|
||||
val mangas =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map { it.toOpdsMangaAcqEntry() }
|
||||
Pair(mangas, totalCount)
|
||||
mapOf(
|
||||
"unread" to unreadCount,
|
||||
"downloaded" to downloadedCount,
|
||||
"ongoing" to ongoingCount,
|
||||
"completed" to completedCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package suwayomi.tachidesk.opds.repository
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import org.jetbrains.exposed.sql.JoinType
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.alias
|
||||
import org.jetbrains.exposed.sql.count
|
||||
import org.jetbrains.exposed.sql.countDistinct
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.i18n.MR
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
@@ -28,20 +30,41 @@ object NavigationRepository {
|
||||
private val opdsItemsPerPageBounded: Int
|
||||
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
|
||||
|
||||
// Mapping of section IDs to their StringResources for title and description
|
||||
private val rootSectionDetails: Map<String, Triple<String, StringResource, StringResource>> =
|
||||
mapOf(
|
||||
"mangas" to
|
||||
"explore" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||
MR.strings.opds_feeds_explore_title,
|
||||
MR.strings.opds_feeds_explore_entry_content,
|
||||
),
|
||||
"library-updates" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||
MR.strings.opds_feeds_all_manga_title,
|
||||
MR.strings.opds_feeds_all_manga_entry_content,
|
||||
MR.strings.opds_feeds_library_updates_title,
|
||||
MR.strings.opds_feeds_library_updates_entry_content,
|
||||
),
|
||||
"history" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||
MR.strings.opds_feeds_history_title,
|
||||
MR.strings.opds_feeds_history_entry_content,
|
||||
),
|
||||
)
|
||||
|
||||
val librarySectionDetails: Map<String, Triple<String, StringResource, StringResource>> =
|
||||
mapOf(
|
||||
"series" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||
MR.strings.opds_feeds_all_series_in_library_title,
|
||||
MR.strings.opds_feeds_all_series_in_library_entry_content,
|
||||
),
|
||||
"sources" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||
MR.strings.opds_feeds_sources_title,
|
||||
MR.strings.opds_feeds_sources_entry_content,
|
||||
MR.strings.opds_feeds_library_sources_title,
|
||||
MR.strings.opds_feeds_library_sources_entry_content,
|
||||
),
|
||||
"categories" to
|
||||
Triple(
|
||||
@@ -55,7 +78,7 @@ object NavigationRepository {
|
||||
MR.strings.opds_feeds_genres_title,
|
||||
MR.strings.opds_feeds_genres_entry_content,
|
||||
),
|
||||
"status" to
|
||||
"statuses" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||
MR.strings.opds_feeds_status_title,
|
||||
@@ -67,16 +90,36 @@ object NavigationRepository {
|
||||
MR.strings.opds_feeds_languages_title,
|
||||
MR.strings.opds_feeds_languages_entry_content,
|
||||
),
|
||||
"library-updates" to
|
||||
Triple(
|
||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||
MR.strings.opds_feeds_library_updates_title,
|
||||
MR.strings.opds_feeds_library_updates_entry_content,
|
||||
),
|
||||
)
|
||||
|
||||
fun getRootNavigationItems(locale: Locale): List<OpdsRootNavEntry> =
|
||||
rootSectionDetails.map { (id, details) ->
|
||||
fun getRootNavigationItems(locale: Locale): List<OpdsRootNavEntry> {
|
||||
val libraryItems =
|
||||
librarySectionDetails.map { (id, details) ->
|
||||
val (linkType, titleRes, descriptionRes) = details
|
||||
OpdsRootNavEntry(
|
||||
id = "library/$id",
|
||||
title = titleRes.localized(locale),
|
||||
description = descriptionRes.localized(locale),
|
||||
linkType = linkType,
|
||||
)
|
||||
}
|
||||
|
||||
val otherRootItems =
|
||||
rootSectionDetails.map { (id, details) ->
|
||||
val (linkType, titleRes, descriptionRes) = details
|
||||
OpdsRootNavEntry(
|
||||
id = id,
|
||||
title = titleRes.localized(locale),
|
||||
description = descriptionRes.localized(locale),
|
||||
linkType = linkType,
|
||||
)
|
||||
}
|
||||
|
||||
return libraryItems + otherRootItems
|
||||
}
|
||||
|
||||
fun getLibraryNavigationItems(locale: Locale): List<OpdsRootNavEntry> =
|
||||
librarySectionDetails.map { (id, details) ->
|
||||
val (linkType, titleRes, descriptionRes) = details
|
||||
OpdsRootNavEntry(
|
||||
id = id,
|
||||
@@ -86,14 +129,14 @@ object NavigationRepository {
|
||||
)
|
||||
}
|
||||
|
||||
fun getSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
||||
// ... (El resto del archivo permanece sin cambios)
|
||||
fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
||||
transaction {
|
||||
val query =
|
||||
SourceTable
|
||||
.join(MangaTable, JoinType.INNER) { MangaTable.sourceReference eq SourceTable.id }
|
||||
.join(ChapterTable, JoinType.INNER) { ChapterTable.manga eq MangaTable.id }
|
||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
||||
.where { ExtensionTable.isInstalled eq true }
|
||||
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||
|
||||
@@ -107,6 +150,36 @@ object NavigationRepository {
|
||||
id = it[SourceTable.id].value,
|
||||
name = it[SourceTable.name],
|
||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
||||
mangaCount = null,
|
||||
)
|
||||
}
|
||||
Pair(sources, totalCount)
|
||||
}
|
||||
|
||||
fun getLibrarySources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
||||
transaction {
|
||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||
|
||||
val query =
|
||||
SourceTable
|
||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||
|
||||
val totalCount = query.count()
|
||||
val sources =
|
||||
query
|
||||
.limit(opdsItemsPerPageBounded)
|
||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||
.map {
|
||||
OpdsSourceNavEntry(
|
||||
id = it[SourceTable.id].value,
|
||||
name = it[SourceTable.name],
|
||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
||||
mangaCount = it[mangaCount],
|
||||
)
|
||||
}
|
||||
Pair(sources, totalCount)
|
||||
@@ -114,12 +187,14 @@ object NavigationRepository {
|
||||
|
||||
fun getCategories(pageNum: Int): Pair<List<OpdsCategoryNavEntry>, Long> =
|
||||
transaction {
|
||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||
|
||||
val query =
|
||||
CategoryTable
|
||||
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
|
||||
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.select(CategoryTable.id, CategoryTable.name)
|
||||
.select(CategoryTable.id, CategoryTable.name, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(CategoryTable.id, CategoryTable.name)
|
||||
.orderBy(CategoryTable.order to SortOrder.ASC)
|
||||
|
||||
@@ -132,6 +207,7 @@ object NavigationRepository {
|
||||
OpdsCategoryNavEntry(
|
||||
id = it[CategoryTable.id].value,
|
||||
name = it[CategoryTable.name],
|
||||
mangaCount = it[mangaCount],
|
||||
)
|
||||
}
|
||||
Pair(categories, totalCount)
|
||||
@@ -142,31 +218,32 @@ object NavigationRepository {
|
||||
locale: Locale,
|
||||
): Pair<List<OpdsGenreNavEntry>, Long> =
|
||||
transaction {
|
||||
val genres =
|
||||
val allGenres =
|
||||
MangaTable
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.select(MangaTable.genre)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.mapNotNull { it[MangaTable.genre] }
|
||||
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
val totalCount = genres.size.toLong()
|
||||
val genreCounts = allGenres.groupingBy { it }.eachCount()
|
||||
val distinctGenres = genreCounts.keys.sorted()
|
||||
|
||||
val totalCount = distinctGenres.size.toLong()
|
||||
val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
|
||||
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, genres.size)
|
||||
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
|
||||
val paginatedGenres =
|
||||
(if (fromIndex < genres.size) genres.subList(fromIndex, toIndex) else emptyList())
|
||||
(if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList())
|
||||
.map { genreName ->
|
||||
OpdsGenreNavEntry(
|
||||
id = genreName.encodeForOpdsURL(),
|
||||
title = genreName,
|
||||
mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
|
||||
)
|
||||
}
|
||||
Pair(paginatedGenres, totalCount)
|
||||
}
|
||||
|
||||
fun getStatuses(locale: Locale): List<OpdsStatusNavEntry> {
|
||||
// Mapping of MangaStatus to its StringResources
|
||||
val statusStringResources: Map<MangaStatus, StringResource> =
|
||||
mapOf(
|
||||
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
|
||||
@@ -178,32 +255,44 @@ object NavigationRepository {
|
||||
MangaStatus.ON_HIATUS to MR.strings.manga_status_on_hiatus,
|
||||
)
|
||||
|
||||
val statusCounts =
|
||||
transaction {
|
||||
MangaTable
|
||||
.select(MangaTable.status, MangaTable.id.count())
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(MangaTable.status)
|
||||
.associate { it[MangaTable.status] to it[MangaTable.id.count()] }
|
||||
}
|
||||
|
||||
return MangaStatus.entries
|
||||
.map { mangaStatus ->
|
||||
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
|
||||
OpdsStatusNavEntry(
|
||||
id = mangaStatus.value,
|
||||
title = titleRes.localized(locale),
|
||||
mangaCount = statusCounts[mangaStatus.value] ?: 0L,
|
||||
)
|
||||
}.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun getContentLanguages(uiLocale: Locale): List<OpdsLanguageNavEntry> =
|
||||
transaction {
|
||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||
SourceTable
|
||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||
.join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga)
|
||||
.select(SourceTable.lang)
|
||||
.select(SourceTable.lang, mangaCount)
|
||||
.where { MangaTable.inLibrary eq true }
|
||||
.groupBy(SourceTable.lang)
|
||||
.map { it[SourceTable.lang] }
|
||||
.sorted()
|
||||
.map { langCode ->
|
||||
.orderBy(SourceTable.lang to SortOrder.ASC)
|
||||
.map {
|
||||
val langCode = it[SourceTable.lang]
|
||||
OpdsLanguageNavEntry(
|
||||
id = langCode,
|
||||
title =
|
||||
Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(uiLocale) else it.toString()
|
||||
Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString()
|
||||
},
|
||||
mangaCount = it[mangaCount],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user