diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1ea07f2..edb5ca0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.69.0" +version = "0.70.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9a040f2..3330664 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.69.0" +version = "0.70.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index befe6d0..6a9a2d9 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -41,6 +41,12 @@ pub struct ListParams { pub genre_id: Option, #[serde(default)] pub tag_id: Option, + /// Comma-separated content warnings the manga must carry (AND). + #[serde(default)] + pub cw_include: Option, + /// Comma-separated content warnings the manga must NOT carry (any). + #[serde(default)] + pub cw_exclude: Option, #[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, diff --git a/backend/src/api/page_tags.rs b/backend/src/api/page_tags.rs index 9edfc7d..8beabe6 100644 --- a/backend/src/api/page_tags.rs +++ b/backend/src/api/page_tags.rs @@ -292,7 +292,7 @@ fn parse_tags_csv(raw: Option<&str>) -> AppResult> { /// 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> { +pub(crate) fn parse_warnings_csv(raw: Option<&str>) -> AppResult> { let Some(raw) = raw else { return Ok(Vec::new()) }; let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); diff --git a/backend/src/domain/manga.rs b/backend/src/domain/manga.rs index a863a52..4b8ff18 100644 --- a/backend/src/domain/manga.rs +++ b/backend/src/domain/manga.rs @@ -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, pub genres: Vec, pub tags: Vec, + pub content_warnings: Vec, } #[derive(Debug, Clone, Deserialize, Default)] diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 2213ae6..e0e72f2 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -35,6 +35,11 @@ pub struct ListQuery { pub author_ids: Vec, pub genre_ids: Vec, pub tag_ids: Vec, + /// Content warnings (canonical lowercase names) the manga must carry on + /// at least one page each — AND across the list. + pub cw_include: Vec, + /// Content warnings the manga must NOT carry on any page. + pub cw_exclude: Vec, 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, 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, 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, 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 { 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 diff --git a/backend/src/repo/page_analysis.rs b/backend/src/repo/page_analysis.rs index f989def..161f85c 100644 --- a/backend/src/repo/page_analysis.rs +++ b/backend/src/repo/page_analysis.rs @@ -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> { + 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> { let row = sqlx::query_as::<_, PageAnalysis>( diff --git a/backend/tests/api_manga_warnings.rs b/backend/tests/api_manga_warnings.rs new file mode 100644 index 0000000..79266df --- /dev/null +++ b/backend/tests/api_manga_warnings.rs @@ -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 { + 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 = 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); +} diff --git a/frontend/package.json b/frontend/package.json index f468ff3..185a94c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.69.0", + "version": "0.70.0", "private": true, "type": "module", "scripts": {