feat: cover retry backfill + admin force-resync for manga & chapter (0.50.0)
Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose cover download failed on first attempt get retried — the metadata pass's early-stop optimisation otherwise prevents the walk from revisiting them. Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync that refetch metadata + cover (or chapter content with force_refetch) from the crawler source synchronously and return the refreshed row. Surfaced in the UI as "Force resync" buttons on the manga detail and reader pages, admin-only via session.user.is_admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
350
backend/tests/api_admin_resync.rs
Normal file
350
backend/tests/api_admin_resync.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! Integration tests for the admin force-resync endpoints.
|
||||
//!
|
||||
//! Real resync work requires Chromium, so these tests swap in a stub
|
||||
//! [`ResyncService`] to assert the handler-level contract: routing,
|
||||
//! admin gate, 503 when the daemon is disabled, 404 / 422 mapping for
|
||||
//! missing-resource / no-source cases, and the audit-log side effect.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use mangalord::crawler::resync::{
|
||||
ChapterResyncOutcome, MangaResyncOutcome, ResyncError, ResyncService,
|
||||
};
|
||||
use mangalord::repo;
|
||||
use mangalord::repo::crawler::UpsertStatus;
|
||||
|
||||
/// Stub that records call counts and returns a canned outcome.
|
||||
struct StubResync {
|
||||
manga_calls: AtomicUsize,
|
||||
chapter_calls: AtomicUsize,
|
||||
/// When true, returns NoMangaSource / NoChapterSource.
|
||||
no_source: bool,
|
||||
}
|
||||
|
||||
impl StubResync {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
manga_calls: AtomicUsize::new(0),
|
||||
chapter_calls: AtomicUsize::new(0),
|
||||
no_source: false,
|
||||
})
|
||||
}
|
||||
fn no_source() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
manga_calls: AtomicUsize::new(0),
|
||||
chapter_calls: AtomicUsize::new(0),
|
||||
no_source: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResyncService for StubResync {
|
||||
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
|
||||
self.manga_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.no_source {
|
||||
return Err(ResyncError::NoMangaSource.into());
|
||||
}
|
||||
Ok(MangaResyncOutcome {
|
||||
manga_id,
|
||||
metadata_status: UpsertStatus::Updated,
|
||||
cover_fetched: true,
|
||||
})
|
||||
}
|
||||
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
|
||||
self.chapter_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.no_source {
|
||||
return Err(ResyncError::NoChapterSource.into());
|
||||
}
|
||||
Ok(ChapterResyncOutcome::Fetched {
|
||||
chapter_id,
|
||||
pages: 7,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn promote_admin(pool: &PgPool, username: &str) {
|
||||
let u = repo::user::find_by_username(pool, username)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
repo::user::set_is_admin_unchecked(pool, u.id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn insert_manga(pool: &PgPool, title: &str) -> Uuid {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id",
|
||||
)
|
||||
.bind(title)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, pages: i32) -> Uuid {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id",
|
||||
)
|
||||
.bind(manga_id)
|
||||
.bind(number)
|
||||
.bind(pages)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
// ----- manga resync ---------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manga_resync_calls_service_and_returns_refreshed_detail(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "Hello").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Stub returned Updated + cover_fetched=true.
|
||||
assert_eq!(body["metadata_status"], "updated");
|
||||
assert_eq!(body["cover_fetched"], true);
|
||||
// Response includes the refreshed manga detail.
|
||||
assert_eq!(body["manga"]["id"], manga_id.to_string());
|
||||
assert_eq!(body["manga"]["title"], "Hello");
|
||||
|
||||
assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Audit row written.
|
||||
let (audit_count,): (i64,) =
|
||||
sqlx::query_as("SELECT count(*) FROM admin_audit WHERE action = 'manga_resync' AND target_id = $1")
|
||||
.bind(manga_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audit_count, 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manga_resync_returns_404_for_unknown_id(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{}/resync", Uuid::new_v4()),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
// Service must not have been called when the manga doesn't exist.
|
||||
assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manga_resync_maps_no_source_to_422(pool: PgPool) {
|
||||
let stub = StubResync::no_source();
|
||||
let h = common::harness_with_resync(pool.clone(), stub);
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "Manual upload, no crawler source").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["details"]["manga"], "no_source");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manga_resync_returns_503_when_daemon_disabled(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "Z").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "service_unavailable");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manga_resync_requires_admin(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub);
|
||||
// Non-admin user.
|
||||
let (_u, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = insert_manga(&pool, "M").await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{manga_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ----- chapter resync -------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapter_resync_calls_service_and_returns_refreshed_chapter(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "M").await;
|
||||
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["outcome"], "fetched");
|
||||
assert_eq!(body["pages"], 7);
|
||||
assert_eq!(body["chapter"]["id"], chapter_id.to_string());
|
||||
assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let (audit_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT count(*) FROM admin_audit WHERE action = 'chapter_resync' AND target_id = $1",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audit_count, 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapter_resync_returns_404_for_unknown_id(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/chapters/{}/resync", Uuid::new_v4()),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapter_resync_maps_no_source_to_422(pool: PgPool) {
|
||||
let stub = StubResync::no_source();
|
||||
let h = common::harness_with_resync(pool.clone(), stub);
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "M").await;
|
||||
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["details"]["chapter"], "no_source");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapter_resync_returns_503_when_daemon_disabled(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (username, cookie) = common::register_user(&h.app).await;
|
||||
promote_admin(&pool, &username).await;
|
||||
let manga_id = insert_manga(&pool, "M").await;
|
||||
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapter_resync_requires_admin(pool: PgPool) {
|
||||
let stub = StubResync::new();
|
||||
let h = common::harness_with_resync(pool.clone(), stub);
|
||||
let (_u, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = insert_manga(&pool, "M").await;
|
||||
let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/chapters/{chapter_id}/resync"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ fn admin_test_router(pool: PgPool) -> (Router, TempDir) {
|
||||
auth,
|
||||
upload: UploadConfig::default(),
|
||||
auth_limiter,
|
||||
resync: None,
|
||||
};
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", api::routes())
|
||||
|
||||
@@ -74,6 +74,10 @@ fn harness_with_auth_config(
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
// Default harness has no crawler daemon wired up; admin resync
|
||||
// handlers return 503 in this config. Tests that need a stub
|
||||
// resync service swap it in via `harness_with_resync`.
|
||||
resync: None,
|
||||
};
|
||||
Harness { app: router(state), _storage_dir: storage_dir }
|
||||
}
|
||||
@@ -124,6 +128,37 @@ pub fn harness_with_auth_rate_limit(
|
||||
harness_with_auth_config(pool, storage, storage_dir, auth)
|
||||
}
|
||||
|
||||
/// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub
|
||||
/// into `AppState.resync`. Used by the admin resync tests so the
|
||||
/// endpoint path is exercised without standing up a real Chromium.
|
||||
pub fn harness_with_resync(
|
||||
pool: PgPool,
|
||||
resync: Arc<dyn mangalord::crawler::resync::ResyncService>,
|
||||
) -> Harness {
|
||||
let storage_dir = tempfile::tempdir().expect("tempdir");
|
||||
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
||||
let auth = AuthConfig {
|
||||
cookie_secure: false,
|
||||
..AuthConfig::default()
|
||||
};
|
||||
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
storage,
|
||||
auth,
|
||||
upload: UploadConfig {
|
||||
max_request_bytes: 4 * 1024 * 1024,
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
resync: Some(resync),
|
||||
};
|
||||
Harness {
|
||||
app: router(state),
|
||||
_storage_dir: storage_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a real `Storage` and fails on the N-th `put` call so tests can
|
||||
/// assert that handlers roll their DB writes back when storage errors
|
||||
/// mid-upload. Reads and other operations delegate to `inner`.
|
||||
|
||||
@@ -829,6 +829,107 @@ async fn sync_tags_garbage_collects_orphan_user_attachments(pool: PgPool) {
|
||||
assert_eq!(orphan_rows, 0, "orphan user-attached tag should be reaped");
|
||||
}
|
||||
|
||||
// ---- list_missing_covers ---------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_only_returns_rows_without_cover(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let with_cover = sample_manga("with", "With Cover", "h1");
|
||||
let without_cover = sample_manga("without", "No Cover", "h2");
|
||||
let _w = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/with", &with_cover)
|
||||
.await
|
||||
.unwrap();
|
||||
let nc = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/without", &without_cover)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Manually set a cover for `with` only.
|
||||
sqlx::query("UPDATE mangas SET cover_image_path = 'mangas/x/cover.jpg' WHERE id = $1")
|
||||
.bind(_w.manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert_eq!(entries.len(), 1, "exactly the manga without a cover");
|
||||
assert_eq!(entries[0].manga_id, nc.manga_id);
|
||||
assert_eq!(entries[0].source_manga_key, "without");
|
||||
assert_eq!(entries[0].source_url, "https://x.example/without");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_skips_dropped_source_rows(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let m = sample_manga("foo", "Foo", "h1");
|
||||
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE manga_sources SET dropped_at = NOW() WHERE manga_id = $1")
|
||||
.bind(up.manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"dropped-source mangas must not be backfilled — no live source to fetch from"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_respects_limit(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
let key = format!("m{i}");
|
||||
let url = format!("https://x.example/{key}");
|
||||
let m = sample_manga(&key, &format!("M{i}"), &format!("h{i}"));
|
||||
let _ = crawler::upsert_manga_from_source(&pool, "target", &url, &m)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let entries = crawler::list_missing_covers(&pool, 3).await.unwrap();
|
||||
assert_eq!(entries.len(), 3, "limit caps the result set");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_deduplicates_per_manga(pool: PgPool) {
|
||||
// A manga surfaced by two sources should produce ONE backfill
|
||||
// entry, not two — otherwise the per-tick cap could be eaten by
|
||||
// duplicates and starve other mangas.
|
||||
crawler::ensure_source(&pool, "src-a", "A", "https://a.example")
|
||||
.await
|
||||
.unwrap();
|
||||
crawler::ensure_source(&pool, "src-b", "B", "https://b.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let m = sample_manga("foo", "Foo", "h1");
|
||||
let up = crawler::upsert_manga_from_source(&pool, "src-a", "https://a.example/foo", &m)
|
||||
.await
|
||||
.unwrap();
|
||||
// Second source attaches to the SAME manga row.
|
||||
sqlx::query(
|
||||
"INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind("src-b")
|
||||
.bind("foo-on-b")
|
||||
.bind(up.manga_id)
|
||||
.bind("https://b.example/foo")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert_eq!(entries.len(), 1, "DISTINCT ON (m.id) collapses duplicate source rows");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
|
||||
Reference in New Issue
Block a user