feat: streaming files endpoint + reader pages + chapter pages metadata
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>
This commit is contained in:
@@ -17,19 +17,20 @@ use crate::api::mangas::{next_field, read_field_bytes};
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::Chapter;
|
||||
use crate::domain::chapter::NewChapter;
|
||||
use crate::domain::{Chapter, Page};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::upload::{parse_image, UploadedImage};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/mangas/:manga_id/chapters",
|
||||
get(list).post(create),
|
||||
)
|
||||
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
||||
.route("/mangas/:manga_id/chapters/:number", get(get_one))
|
||||
.route(
|
||||
"/mangas/:manga_id/chapters/:number/pages",
|
||||
get(list_pages),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -120,12 +121,14 @@ async fn create(
|
||||
.await?;
|
||||
|
||||
for (idx, page) in pages.iter().enumerate() {
|
||||
let nnnn = format!("{:04}", idx + 1);
|
||||
let page_number = (idx + 1) as i32;
|
||||
let nnnn = format!("{:04}", page_number);
|
||||
let key = format!(
|
||||
"mangas/{}/chapters/{}/pages/{}.{}",
|
||||
manga_id, chapter.id, nnnn, page.ext
|
||||
);
|
||||
state.storage.put(&key, &page.bytes).await?;
|
||||
repo::page::create(&state.db, chapter.id, page_number, &key, page.mime).await?;
|
||||
}
|
||||
|
||||
let page_count = pages.len() as i32;
|
||||
@@ -138,3 +141,20 @@ async fn create(
|
||||
|
||||
Ok((StatusCode::CREATED, Json(chapter)))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct PagesResponse {
|
||||
pages: Vec<Page>,
|
||||
}
|
||||
|
||||
async fn list_pages(
|
||||
State(state): State<AppState>,
|
||||
Path((manga_id, number)): Path<(Uuid, i32)>,
|
||||
) -> AppResult<Json<PagesResponse>> {
|
||||
repo::manga::get(&state.db, manga_id).await?;
|
||||
let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
|
||||
Ok(Json(PagesResponse { pages }))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Serves blobs from the `Storage` trait. Same endpoint serves manga
|
||||
//! covers and chapter pages; the key embedded in the URL is whatever
|
||||
//! the writer stored.
|
||||
//! Streams blobs from the `Storage` trait. Same endpoint serves manga
|
||||
//! covers and chapter pages; the key embedded in the URL is whatever the
|
||||
//! writer stored.
|
||||
//!
|
||||
//! The handler uses `Storage::get_stream` so a multi-MB page is piped to
|
||||
//! the client a chunk at a time instead of buffered server-side.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -17,13 +21,17 @@ pub fn routes() -> Router<AppState> {
|
||||
}
|
||||
|
||||
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
||||
let bytes = match state.storage.get(&key).await {
|
||||
Ok(b) => b,
|
||||
let file = match state.storage.get_stream(&key).await {
|
||||
Ok(f) => f,
|
||||
Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let ct = content_type_for(&key);
|
||||
Ok(([(header::CONTENT_TYPE, ct)], bytes).into_response())
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, ct.to_string()),
|
||||
(header::CONTENT_LENGTH, file.size_bytes.to_string()),
|
||||
];
|
||||
Ok((headers, Body::from_stream(file.stream)).into_response())
|
||||
}
|
||||
|
||||
fn content_type_for(key: &str) -> &'static str {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod api_token;
|
||||
pub mod bookmark;
|
||||
pub mod chapter;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
|
||||
@@ -9,5 +10,6 @@ pub use api_token::ApiToken;
|
||||
pub use bookmark::Bookmark;
|
||||
pub use chapter::Chapter;
|
||||
pub use manga::Manga;
|
||||
pub use page::Page;
|
||||
pub use session::Session;
|
||||
pub use user::User;
|
||||
|
||||
12
backend/src/domain/page.rs
Normal file
12
backend/src/domain/page.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use serde::Serialize;
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Page {
|
||||
pub id: Uuid,
|
||||
pub chapter_id: Uuid,
|
||||
pub page_number: i32,
|
||||
pub storage_key: String,
|
||||
pub content_type: String,
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_token;
|
||||
pub mod chapter;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
|
||||
45
backend/src/repo/page.rs
Normal file
45
backend/src/repo/page.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Per-page persistence. Mirrors the rows that `pages` holds.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::Page;
|
||||
use crate::error::AppResult;
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
chapter_id: Uuid,
|
||||
page_number: i32,
|
||||
storage_key: &str,
|
||||
content_type: &str,
|
||||
) -> AppResult<Page> {
|
||||
let row = sqlx::query_as::<_, Page>(
|
||||
r#"
|
||||
INSERT INTO pages (chapter_id, page_number, storage_key, content_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, chapter_id, page_number, storage_key, content_type
|
||||
"#,
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.bind(page_number)
|
||||
.bind(storage_key)
|
||||
.bind(content_type)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn list_for_chapter(pool: &PgPool, chapter_id: Uuid) -> AppResult<Vec<Page>> {
|
||||
let rows = sqlx::query_as::<_, Page>(
|
||||
r#"
|
||||
SELECT id, chapter_id, page_number, storage_key, content_type
|
||||
FROM pages
|
||||
WHERE chapter_id = $1
|
||||
ORDER BY page_number ASC
|
||||
"#,
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -2,8 +2,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::fs;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use super::{Storage, StorageError};
|
||||
use super::{Storage, StorageError, StreamingFile};
|
||||
|
||||
pub struct LocalStorage {
|
||||
root: PathBuf,
|
||||
@@ -46,6 +47,25 @@ impl Storage for LocalStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError> {
|
||||
let path = self.resolve(key)?;
|
||||
let file = match fs::File::open(&path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(StorageError::NotFound)
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let size_bytes = file.metadata().await?.len();
|
||||
// 64 KiB chunks: small enough that a few-MB page emits many frames
|
||||
// (so streaming is observable), large enough to keep syscalls cheap.
|
||||
let stream = ReaderStream::with_capacity(file, 64 * 1024);
|
||||
Ok(StreamingFile {
|
||||
stream: Box::pin(stream),
|
||||
size_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), StorageError> {
|
||||
let path = self.resolve(key)?;
|
||||
match fs::remove_file(&path).await {
|
||||
@@ -93,5 +113,33 @@ mod tests {
|
||||
let s = LocalStorage::new(dir.path());
|
||||
assert!(matches!(s.get("nope").await, Err(StorageError::NotFound)));
|
||||
assert!(matches!(s.delete("nope").await, Err(StorageError::NotFound)));
|
||||
assert!(matches!(
|
||||
s.get_stream("nope").await.err(),
|
||||
Some(StorageError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_stream_emits_multiple_chunks_for_large_files() {
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let s = LocalStorage::new(dir.path());
|
||||
// 256 KiB blob → at 64 KiB chunks should emit ~4 chunks.
|
||||
let big = vec![7u8; 256 * 1024];
|
||||
s.put("big.bin", &big).await.unwrap();
|
||||
|
||||
let StreamingFile { mut stream, size_bytes } = s.get_stream("big.bin").await.unwrap();
|
||||
assert_eq!(size_bytes, big.len() as u64);
|
||||
|
||||
let mut chunks = 0usize;
|
||||
let mut total = 0usize;
|
||||
while let Some(frame) = stream.next().await {
|
||||
let bytes = frame.unwrap();
|
||||
chunks += 1;
|
||||
total += bytes.len();
|
||||
}
|
||||
assert_eq!(total, big.len());
|
||||
assert!(chunks > 1, "expected >1 chunk, got {chunks}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
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;
|
||||
|
||||
@@ -22,10 +25,23 @@ pub enum StorageError {
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user