feat(api): admin manga/chapter overview with derived sync state (0.39.0)
Adds GET /api/v1/admin/mangas and /admin/mangas/:id/chapters guarded by RequireAdmin. Sync state is computed at query time from the existing crawler signals (manga_sources / chapter_sources / crawler_jobs) — no new state column is persisted, so the crawler stays the single writer of these signals. Per-manga priority: InProgress (in-flight sync_manga or sync_chapter_list job) > Dropped (all source rows soft-dropped) > Synced (default; covers user-uploaded mangas with zero source rows). Per-chapter priority: Downloading (in-flight sync_chapter_content) > Dropped (all source rows soft-dropped) > Failed (most-recent terminal job is dead) > NotDownloaded (page_count = 0) > Synced. The Failed check sits ABOVE NotDownloaded so the more informative "we tried and it died" state wins over "we never got around to it" — see the priority comment in repo/admin_view.rs. Migration 0020 adds a partial index on crawler_jobs((payload->>'source_manga_key')) for the one job kind (sync_manga) whose payload doesn't carry manga_id directly — without it the in-flight detection for a manga falls back to a seqscan over the job table.
This commit is contained in:
82
backend/src/api/admin/mangas.rs
Normal file
82
backend/src/api/admin/mangas.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Admin manga/chapter overview with derived sync state.
|
||||
//!
|
||||
//! Sync state comes from `repo::admin_view`, which joins the manga /
|
||||
//! chapter tables with the crawler signals at query time — there is no
|
||||
//! persisted sync_state column. See [`repo::admin_view`] for the
|
||||
//! derivation priority order.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::domain::MangaSyncState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::repo::admin_view::{AdminChapterRow, AdminMangaRow};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/mangas", get(list_mangas))
|
||||
.route("/admin/mangas/:id/chapters", get(list_chapters))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListMangasParams {
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
/// `in_progress` | `dropped` | `synced`. Unrecognised values are a 400.
|
||||
#[serde(default)]
|
||||
pub sync_state: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
async fn list_mangas(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Query(params): Query<ListMangasParams>,
|
||||
) -> AppResult<Json<PagedResponse<AdminMangaRow>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
|
||||
let sync_state = match params.sync_state.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some("in_progress") => Some(MangaSyncState::InProgress),
|
||||
Some("dropped") => Some(MangaSyncState::Dropped),
|
||||
Some("synced") => Some(MangaSyncState::Synced),
|
||||
Some(other) => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"sync_state must be one of in_progress|dropped|synced (got {other:?})"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let q = repo::admin_view::ListAdminMangasQuery {
|
||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||
sync_state,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (items, total) = repo::admin_view::list_mangas_with_sync_state(&state.db, &q).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_chapters(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<AdminChapterRow>>> {
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&state.db, manga_id).await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
Reference in New Issue
Block a user