Persist blob sizes (covers + chapter pages) and surface upload storage usage to admins in two places: - Admin System tab: total/covers/chapters totals, ratio of disk, average image sizes, and top-5 largest mangas/chapters leaderboards, behind a new GET /api/v1/admin/storage endpoint (separate from /admin/system so the cheap DB aggregates aren't coupled to its 250ms CPU sample). - Manga detail page: total chapter-content size and per-chapter size, with an em-dash for uncrawled or not-yet-measured chapters. Sizes are captured at write time (crawler put_stream return, uploaded page/cover byte length) into nullable pages.size_bytes / mangas.cover_size_bytes columns; NULL means "not yet measured", distinct from a real 0. A one-shot, idempotent admin "Backfill sizes" action (POST /api/v1/admin/storage/backfill) stats pre-existing blobs in keyset-paginated, capped batches and reports more_remaining so a large legacy library can be drained over multiple runs. Aggregates and leaderboards treat NULL honestly (only fully-measured entities are ranked); a dashboard banner flags unmeasured rows so partial figures aren't read as complete. persist_pages now prunes stale page rows on a shrinking re-crawl so totals and page_count stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
4.0 KiB
Rust
102 lines
4.0 KiB
Rust
//! Pluggable blob storage.
|
|
//!
|
|
//! Handlers depend on the `Storage` trait, never on a concrete backend.
|
|
//! Add new backends (S3, GCS, …) as new impls in this module and wire
|
|
//! them up in `app::build` based on config.
|
|
|
|
mod local;
|
|
|
|
use std::io;
|
|
use std::pin::Pin;
|
|
|
|
use std::path::Path;
|
|
|
|
use async_trait::async_trait;
|
|
use bytes::Bytes;
|
|
use futures_core::Stream;
|
|
|
|
pub use local::LocalStorage;
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum StorageError {
|
|
#[error(transparent)]
|
|
Io(#[from] io::Error),
|
|
#[error("not found")]
|
|
NotFound,
|
|
#[error("invalid storage key")]
|
|
BadKey,
|
|
}
|
|
|
|
/// Boxed byte stream returned by `Storage::get_stream` so the trait stays
|
|
/// object-safe regardless of the concrete reader behind it.
|
|
pub type ByteStream = Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>;
|
|
|
|
/// Boxed byte stream accepted by `Storage::put_stream`. The item type
|
|
/// is fallible so a producer (e.g. an HTTP body) can surface a transport
|
|
/// error mid-stream without breaking the trait shape; the storage impl
|
|
/// is responsible for not installing a partial blob on such an error.
|
|
pub type PutByteStream<'a> =
|
|
Pin<Box<dyn Stream<Item = Result<Bytes, StorageError>> + Send + 'a>>;
|
|
|
|
pub struct StreamingFile {
|
|
pub stream: ByteStream,
|
|
pub size_bytes: u64,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait Storage: Send + Sync {
|
|
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
|
|
/// Stream a blob to storage without holding the entire body in
|
|
/// memory. The chapter-content download path uses this so peak
|
|
/// memory stays at one chunk per concurrent dispatch (not one full
|
|
/// page image). The contract is atomic: a stream that errors mid-way
|
|
/// must leave nothing visible at `key` — implementations should
|
|
/// write to a temp location and rename only on the successful
|
|
/// drain. Returns the total bytes written on success.
|
|
///
|
|
/// The default implementation buffers the stream into memory and
|
|
/// calls `put`, so backends without a native streaming write still
|
|
/// satisfy the contract (at the cost of peak memory). LocalStorage
|
|
/// overrides this to do a temp-file rename; a future S3Storage
|
|
/// would override with a multi-part upload.
|
|
async fn put_stream(
|
|
&self,
|
|
key: &str,
|
|
mut stream: PutByteStream<'_>,
|
|
) -> Result<u64, StorageError> {
|
|
use futures_util::StreamExt as _;
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk?;
|
|
buf.extend_from_slice(&chunk);
|
|
}
|
|
let len = buf.len() as u64;
|
|
self.put(key, &buf).await?;
|
|
Ok(len)
|
|
}
|
|
/// Reads the entire blob into memory. Convenient for small assets
|
|
/// (covers, thumbnails). For pages and other large blobs, use
|
|
/// `get_stream` so axum can pipe bytes straight to the client.
|
|
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
|
|
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError>;
|
|
async fn delete(&self, key: &str) -> Result<(), StorageError>;
|
|
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
|
|
|
|
/// Size in bytes of the blob at `key`, `NotFound` when it doesn't
|
|
/// exist. Cheap metadata lookup (local: `fs::metadata`; a future
|
|
/// `S3Storage`: HEAD object). Used by the cover-capture path and the
|
|
/// one-shot storage-size backfill — never on the hot read path, which
|
|
/// reads the stored `size_bytes` columns. Required (no default) so a
|
|
/// new backend can't silently skip it.
|
|
async fn size(&self, key: &str) -> Result<u64, StorageError>;
|
|
|
|
/// Filesystem path the backend is rooted at, when introspectable.
|
|
/// Returns `None` for backends that aren't a local filesystem (e.g.
|
|
/// a future `S3Storage`). The admin system endpoint uses this to
|
|
/// statvfs the data dir; backends that return `None` get a `disk:
|
|
/// null` payload instead of fabricated numbers.
|
|
fn local_root(&self) -> Option<&Path> {
|
|
None
|
|
}
|
|
}
|