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:
MechaCat02
2026-06-13 19:03:26 +02:00
parent af870bd157
commit bd39476ac7
9 changed files with 205 additions and 7 deletions

View File

@@ -41,6 +41,12 @@ pub struct ListParams {
pub genre_id: Option<String>,
#[serde(default)]
pub tag_id: Option<String>,
/// Comma-separated content warnings the manga must carry (AND).
#[serde(default)]
pub cw_include: Option<String>,
/// Comma-separated content warnings the manga must NOT carry (any).
#[serde(default)]
pub cw_exclude: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
@@ -94,6 +100,8 @@ async fn list(
author_ids: parse_uuid_csv("author_id", params.author_id.as_deref())?,
genre_ids: parse_uuid_csv("genre_id", params.genre_id.as_deref())?,
tag_ids: parse_uuid_csv("tag_id", params.tag_id.as_deref())?,
cw_include: crate::api::page_tags::parse_warnings_csv(params.cw_include.as_deref())?,
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
limit,
offset,
sort: params.sort,

View File

@@ -292,7 +292,7 @@ fn parse_tags_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
/// Split + validate a comma-separated content-warning list against the
/// closed vocabulary, returning canonical lowercase names. Unknown values
/// are a 422 rather than a silent drop so a typo'd filter is visible.
fn parse_warnings_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
pub(crate) fn parse_warnings_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
let Some(raw) = raw else { return Ok(Vec::new()) };
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use super::author::AuthorRef;
use super::genre::GenreRef;
use super::page_analysis::ContentWarning;
use super::patch::Patch;
use super::tag::TagRef;
@@ -32,7 +33,8 @@ pub struct MangaCard {
}
/// Shape returned by `GET /mangas/:id`. Adds user-added tags on top of
/// the card fields.
/// the card fields, plus the deduped content warnings derived by the
/// analysis worker across all the manga's pages.
#[derive(Debug, Clone, Serialize)]
pub struct MangaDetail {
#[serde(flatten)]
@@ -40,6 +42,7 @@ pub struct MangaDetail {
pub authors: Vec<AuthorRef>,
pub genres: Vec<GenreRef>,
pub tags: Vec<TagRef>,
pub content_warnings: Vec<ContentWarning>,
}
#[derive(Debug, Clone, Deserialize, Default)]

View File

@@ -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

View File

@@ -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>(