Files
Mangalord/backend/src/storage/mod.rs
MechaCat02 790549636f
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
feat(storage): admin storage-usage stats + per-manga/chapter sizes
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>
2026-06-15 20:18:41 +02:00

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
}
}