Backend:
- Migration 0003_pages.sql adds a `pages` table (id, chapter_id,
page_number, storage_key, content_type) with a unique (chapter_id,
page_number). New table because chapter pages can have different MIME
types per page; reconstructing keys from a single template would
break the moment a chapter mixes png and jpg pages.
- `domain::Page` + `repo::page` (create + list_for_chapter).
- The chapter upload handler now inserts one page row per part as it
writes the bytes to storage.
- GET /api/v1/mangas/{id}/chapters/{n}/pages returns `{pages: [...]}`
with the storage_key clients need to construct image URLs. 404 if
the manga or chapter doesn't exist; reads are public.
Storage trait grows `get_stream(&str) -> StreamingFile` returning a
`Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>` + size. The
local backend implements via `tokio::fs::File` + `tokio_util::io::
ReaderStream` with a 64 KiB chunk size. GET /api/v1/files/*key now
streams via `axum::body::Body::from_stream` instead of buffering — the
test asserts a 200 KiB file emits >1 frame end-to-end through the
router.
Frontend:
- lib/api/client.ts gains `fileUrl(key)` so components don't
reconstruct the `/api/v1/files/...` path manually.
- lib/api/chapters.ts gains `ChapterPage` type + `getChapterPages` (the
type is named ChapterPage to avoid colliding with `Page` from
client.ts, which is the pagination envelope).
- /manga/[id]/+page.svelte: overview with cover, title, author,
description, chapter list, and a disabled bookmark control (real
bookmarking lands in feat/bookmarks). Responsive at 640 px.
- /manga/[id]/chapter/[n]/+page.svelte: paginated reader. Current page
loads eagerly; next page is preloaded in a hidden img so navigation
feels instant. Keyboard handler maps ArrowRight/j/Space → next,
ArrowLeft/k → prev, Home/End → first/last; skips when the user is
typing in an input. Focus ring on the prev/next buttons.
- SSR is disabled on both routes via `export const ssr = false` so the
client-only fetch flow doesn't need to be replicated server-side; the
routes are interactive features, not SEO surfaces.
- E2E (e2e/reader.spec.ts): overview shows the title/cover/chapter
list; reader pages through three pages via ArrowRight, j, k, and
ArrowLeft, and the preload img holds the page-2 src on initial load.
Lockstep version bump to 0.6.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48 lines
1.5 KiB
Rust
48 lines
1.5 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 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>>;
|
|
|
|
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>;
|
|
/// 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>;
|
|
}
|