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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.69.0"
|
version = "0.70.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.69.0"
|
version = "0.70.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ pub struct ListParams {
|
|||||||
pub genre_id: Option<String>,
|
pub genre_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tag_id: Option<String>,
|
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")]
|
#[serde(default = "default_limit")]
|
||||||
pub limit: i64,
|
pub limit: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -94,6 +100,8 @@ async fn list(
|
|||||||
author_ids: parse_uuid_csv("author_id", params.author_id.as_deref())?,
|
author_ids: parse_uuid_csv("author_id", params.author_id.as_deref())?,
|
||||||
genre_ids: parse_uuid_csv("genre_id", params.genre_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())?,
|
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,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ fn parse_tags_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
|
|||||||
/// Split + validate a comma-separated content-warning list against the
|
/// Split + validate a comma-separated content-warning list against the
|
||||||
/// closed vocabulary, returning canonical lowercase names. Unknown values
|
/// closed vocabulary, returning canonical lowercase names. Unknown values
|
||||||
/// are a 422 rather than a silent drop so a typo'd filter is visible.
|
/// 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 Some(raw) = raw else { return Ok(Vec::new()) };
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use super::author::AuthorRef;
|
use super::author::AuthorRef;
|
||||||
use super::genre::GenreRef;
|
use super::genre::GenreRef;
|
||||||
|
use super::page_analysis::ContentWarning;
|
||||||
use super::patch::Patch;
|
use super::patch::Patch;
|
||||||
use super::tag::TagRef;
|
use super::tag::TagRef;
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ pub struct MangaCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shape returned by `GET /mangas/:id`. Adds user-added tags on top of
|
/// 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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct MangaDetail {
|
pub struct MangaDetail {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -40,6 +42,7 @@ pub struct MangaDetail {
|
|||||||
pub authors: Vec<AuthorRef>,
|
pub authors: Vec<AuthorRef>,
|
||||||
pub genres: Vec<GenreRef>,
|
pub genres: Vec<GenreRef>,
|
||||||
pub tags: Vec<TagRef>,
|
pub tags: Vec<TagRef>,
|
||||||
|
pub content_warnings: Vec<ContentWarning>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ pub struct ListQuery {
|
|||||||
pub author_ids: Vec<Uuid>,
|
pub author_ids: Vec<Uuid>,
|
||||||
pub genre_ids: Vec<Uuid>,
|
pub genre_ids: Vec<Uuid>,
|
||||||
pub tag_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 limit: i64,
|
||||||
pub offset: i64,
|
pub offset: i64,
|
||||||
pub sort: ListSort,
|
pub sort: ListSort,
|
||||||
@@ -107,6 +112,21 @@ const FILTER_WHERE: &str = r#"
|
|||||||
WHERE mt.manga_id = mangas.id AND mt.tag_id = req.id
|
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
|
/// 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
|
FROM mangas
|
||||||
WHERE {FILTER_WHERE}
|
WHERE {FILTER_WHERE}
|
||||||
ORDER BY {order_by}
|
ORDER BY {order_by}
|
||||||
LIMIT $6 OFFSET $7
|
LIMIT $8 OFFSET $9
|
||||||
"#,
|
"#,
|
||||||
cols = manga_cols(""),
|
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.author_ids)
|
||||||
.bind(&query.genre_ids)
|
.bind(&query.genre_ids)
|
||||||
.bind(&query.tag_ids)
|
.bind(&query.tag_ids)
|
||||||
|
.bind(&query.cw_include)
|
||||||
|
.bind(&query.cw_exclude)
|
||||||
.bind(query.limit)
|
.bind(query.limit)
|
||||||
.bind(query.offset)
|
.bind(query.offset)
|
||||||
.fetch_all(pool)
|
.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.author_ids)
|
||||||
.bind(&query.genre_ids)
|
.bind(&query.genre_ids)
|
||||||
.bind(&query.tag_ids)
|
.bind(&query.tag_ids)
|
||||||
|
.bind(&query.cw_include)
|
||||||
|
.bind(&query.cw_exclude)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.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 authors = repo::author::list_for_manga(pool, id).await?;
|
||||||
let genres = repo::genre::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?;
|
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
|
/// 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())
|
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.
|
/// Load a page's analysis row, if it has one.
|
||||||
pub async fn load(pool: &PgPool, page_id: Uuid) -> AppResult<Option<PageAnalysis>> {
|
pub async fn load(pool: &PgPool, page_id: Uuid) -> AppResult<Option<PageAnalysis>> {
|
||||||
let row = sqlx::query_as::<_, PageAnalysis>(
|
let row = sqlx::query_as::<_, PageAnalysis>(
|
||||||
|
|||||||
140
backend/tests/api_manga_warnings.rs
Normal file
140
backend/tests/api_manga_warnings.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
//! Integration tests for manga-level content warnings: the deduped union
|
||||||
|
//! on the detail endpoint and the include/exclude filters on the list.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Router;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};
|
||||||
|
use mangalord::repo;
|
||||||
|
|
||||||
|
/// Seed a manga whose pages carry the given per-page warning sets, and
|
||||||
|
/// return the manga id.
|
||||||
|
async fn seed_manga(pool: &PgPool, title: &str, pages: &[&[&str]]) -> Uuid {
|
||||||
|
let manga_id: Uuid =
|
||||||
|
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
||||||
|
.bind(title)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let chapter_id: Uuid =
|
||||||
|
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
|
||||||
|
.bind(manga_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (i, warnings) in pages.iter().enumerate() {
|
||||||
|
let page_id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
||||||
|
VALUES ($1, $2, $3, 'image/png') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.bind((i + 1) as i32)
|
||||||
|
.bind(format!("k/{i}.png"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let analysis = VisionAnalysis {
|
||||||
|
ocr_results: vec![],
|
||||||
|
tagging_results: vec![],
|
||||||
|
scene_description: String::new(),
|
||||||
|
safety_flag: SafetyFlag {
|
||||||
|
is_nsfw: !warnings.is_empty(),
|
||||||
|
content_type: warnings.iter().map(|w| w.to_string()).collect(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
repo::page_analysis::persist_analysis(pool, page_id, &analysis, "m")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
manga_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_ids(app: &Router, query: &str) -> Vec<String> {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get(&format!("/api/v1/mangas?{query}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
body["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|m| m["id"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn detail_returns_deduped_warning_union(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
// Two pages: one sexual+gore, one gore — union is [gore, sexual].
|
||||||
|
let manga_id = seed_manga(&pool, "M", &[&["sexual", "gore"], &["gore"]]).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
let warnings: Vec<String> = body["content_warnings"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(warnings, vec!["gore", "sexual"], "deduped + alphabetical");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn detail_has_empty_warnings_when_unflagged(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let manga_id = seed_manga(&pool, "Clean", &[&[]]).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["content_warnings"].as_array().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_filters_by_content_warning(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let gory = seed_manga(&pool, "Gory", &[&["gore"]]).await;
|
||||||
|
let sexy = seed_manga(&pool, "Sexy", &[&["sexual"]]).await;
|
||||||
|
let clean = seed_manga(&pool, "Clean", &[&[]]).await;
|
||||||
|
|
||||||
|
// include=gore → only the gory manga.
|
||||||
|
let ids = list_ids(&h.app, "cw_include=gore").await;
|
||||||
|
assert_eq!(ids, vec![gory.to_string()]);
|
||||||
|
|
||||||
|
// exclude=gore → the others (sexy + clean), not gory.
|
||||||
|
let mut ids = list_ids(&h.app, "cw_exclude=gore").await;
|
||||||
|
ids.sort();
|
||||||
|
let mut want = vec![sexy.to_string(), clean.to_string()];
|
||||||
|
want.sort();
|
||||||
|
assert_eq!(ids, want);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_rejects_unknown_warning(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get("/api/v1/mangas?cw_include=spicy"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.69.0",
|
"version": "0.70.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user