feat(mangas): content-warning filter on search + deduped banner on detail
Surfaces the analysis worker's NSFW moderation at the manga level: - repo::manga FILTER_WHERE gains cw_include (AND, page-content-warning join to mangas) and cw_exclude (NONE) clauses; ListQuery + binds renumbered ($6/$7, LIMIT/OFFSET $8/$9). - repo::page_analysis::warnings_for_manga: deduped, alphabetical union across all the manga's pages. - domain::MangaDetail gains content_warnings; get_detail populates it. - api::mangas list accepts cw_include/cw_exclude (reusing the shared parse_warnings_csv validator). Tests: detail union (deduped/sorted) + empty case; list include/exclude filter; unknown-warning 422. Existing manga tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,11 @@ pub struct ListQuery {
|
||||
pub author_ids: Vec<Uuid>,
|
||||
pub genre_ids: Vec<Uuid>,
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
/// Content warnings (canonical lowercase names) the manga must carry on
|
||||
/// at least one page each — AND across the list.
|
||||
pub cw_include: Vec<String>,
|
||||
/// Content warnings the manga must NOT carry on any page.
|
||||
pub cw_exclude: Vec<String>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub sort: ListSort,
|
||||
@@ -107,6 +112,21 @@ const FILTER_WHERE: &str = r#"
|
||||
WHERE mt.manga_id = mangas.id AND mt.tag_id = req.id
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mangas.id AND pw.warning = req.w
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mangas.id AND pw.warning = ANY($7::text[])
|
||||
)
|
||||
"#;
|
||||
|
||||
/// Returns the page of mangas matching `query` plus the unfiltered total
|
||||
@@ -130,7 +150,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
||||
FROM mangas
|
||||
WHERE {FILTER_WHERE}
|
||||
ORDER BY {order_by}
|
||||
LIMIT $6 OFFSET $7
|
||||
LIMIT $8 OFFSET $9
|
||||
"#,
|
||||
cols = manga_cols(""),
|
||||
);
|
||||
@@ -141,6 +161,8 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
||||
.bind(&query.author_ids)
|
||||
.bind(&query.genre_ids)
|
||||
.bind(&query.tag_ids)
|
||||
.bind(&query.cw_include)
|
||||
.bind(&query.cw_exclude)
|
||||
.bind(query.limit)
|
||||
.bind(query.offset)
|
||||
.fetch_all(pool)
|
||||
@@ -158,6 +180,8 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
||||
.bind(&query.author_ids)
|
||||
.bind(&query.genre_ids)
|
||||
.bind(&query.tag_ids)
|
||||
.bind(&query.cw_include)
|
||||
.bind(&query.cw_exclude)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
@@ -266,7 +290,8 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult<MangaDetail> {
|
||||
let authors = repo::author::list_for_manga(pool, id).await?;
|
||||
let genres = repo::genre::list_for_manga(pool, id).await?;
|
||||
let tags = repo::tag::list_for_manga(pool, id).await?;
|
||||
Ok(MangaDetail { manga, authors, genres, tags })
|
||||
let content_warnings = repo::page_analysis::warnings_for_manga(pool, id).await?;
|
||||
Ok(MangaDetail { manga, authors, genres, tags, content_warnings })
|
||||
}
|
||||
|
||||
/// Insert just the manga row. Relations (authors, genres) are written
|
||||
|
||||
@@ -77,6 +77,28 @@ pub async fn enqueue_all_pages(pool: &PgPool, only_unanalyzed: bool) -> AppResul
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Deduplicated union of content warnings across all of a manga's pages,
|
||||
/// ordered alphabetically. Powers the manga-detail content-warning banner.
|
||||
pub async fn warnings_for_manga(
|
||||
pool: &PgPool,
|
||||
manga_id: uuid::Uuid,
|
||||
) -> AppResult<Vec<ContentWarning>> {
|
||||
let rows = sqlx::query_scalar::<_, ContentWarning>(
|
||||
r#"
|
||||
SELECT DISTINCT pw.warning
|
||||
FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = $1
|
||||
ORDER BY pw.warning
|
||||
"#,
|
||||
)
|
||||
.bind(manga_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Load a page's analysis row, if it has one.
|
||||
pub async fn load(pool: &PgPool, page_id: Uuid) -> AppResult<Option<PageAnalysis>> {
|
||||
let row = sqlx::query_as::<_, PageAnalysis>(
|
||||
|
||||
Reference in New Issue
Block a user