Compare commits

...

10 Commits

Author SHA1 Message Date
1dca1099cf fix(env): Sync .env.example with actual .env variables
- Added LIBRARIES_ROOT_PATH to .env.example (was commented out)
- Removed ADMIN_UI_LISTEN_ADDR (admin-ui no longer exists)
- Improved documentation for LIBRARIES_ROOT_PATH variable

Note: .env changes (removed ADMIN_UI_LISTEN_ADDR) are local only
due to .gitignore - users should update their .env manually.
2026-03-06 15:07:24 +01:00
ff34b2bbf4 chore: Remove admin-ui, improve .env.example, add comprehensive README
- Removed deprecated admin-ui Rust application
- Updated .env.example with better organization and comments
- Added comprehensive README.md with:
  - Architecture overview
  - Quick start guide
  - Development instructions
  - Feature documentation
  - Environment variable reference
  - API documentation link
2026-03-06 15:06:04 +01:00
a7fe565a1f fix(docker): Fix migrate service authentication
Add POSTGRES_PASSWORD environment variable to migrate service
so it can connect to postgres without interactive password prompt.
2026-03-06 15:04:03 +01:00
8a9a8634f8 fix(ui): Harmonize spacing in library sub-pages
- Removed space-y-6 container wrapper
- Added explicit mb-6 margins on breadcrumb, h1, Card, and h2 elements
- Consistent spacing approach across all library pages
2026-03-06 14:59:37 +01:00
5683fb8d25 fix(ui): Improve pagination spacing
- Increased top margin from mt-6 to mt-8
- Increased gaps between elements from gap-4 to gap-6
- Better spacing in page size selector
2026-03-06 14:52:45 +01:00
fa574586ed feat(ui): Add pagination to books pages and improve spacing
- Added CursorPagination component with page size selector (20/50/100)
- Updated /books page with pagination support
- Updated /libraries/[id]/books with pagination
- Improved layout margins (added pb-16 and responsive px)
- Series page uses improved layout spacing
2026-03-06 14:50:27 +01:00
c421f427b0 fix(ui): Progress bar height too small for label text
Changed from size=md (8px) to size=lg (32px) to properly display
the percentage label inside the progress bar.
2026-03-06 14:42:06 +01:00
5d7524f52e fix(indexer): Progress bar stuck at 95% max
- Fixed processed_count reset between libraries by using shared counter
- Set progress_percent to 100 when job completes successfully
- Progress now correctly tracks across all libraries in a job
2026-03-06 14:39:53 +01:00
762587dcb3 fix(indexer): Book deletion not working due to path mismatch
The existing HashMap stored local paths but seen HashMap stored
/libraries paths. This caused the deletion logic to never find
matching files. Now both use consistent local path format.
2026-03-06 14:28:57 +01:00
b6cd8a895d fix(indexer): File watcher not watching directories
The setup_watcher function was creating a watcher object but never
calling .watch() on the library directories. Now it properly watches
all directories recursively and detects file changes.
2026-03-06 14:23:17 +01:00
20 changed files with 962 additions and 419 deletions

View File

@@ -1,9 +1,50 @@
# Stripstream Librarian - Environment Configuration
# =============================================================================
# REQUIRED - Change these values in production!
# =============================================================================
# Master key for Meilisearch authentication (required)
MEILI_MASTER_KEY=change-me-in-production
# Bootstrap token for initial API admin access (required)
# Use this token for the first API calls before creating proper API tokens
API_BOOTSTRAP_TOKEN=change-me-in-production
# =============================================================================
# Services Configuration
# =============================================================================
# API Service
API_LISTEN_ADDR=0.0.0.0:8080
BACKOFFICE_PORT=8082
API_BASE_URL=http://api:8080
# Indexer Service
INDEXER_LISTEN_ADDR=0.0.0.0:8081
INDEXER_SCAN_INTERVAL_SECONDS=5
# Backoffice Web UI
BACKOFFICE_PORT=8082
# =============================================================================
# Database Configuration
# =============================================================================
# PostgreSQL connection string
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
# =============================================================================
# Search Configuration
# =============================================================================
# Meilisearch connection URL
MEILI_URL=http://meilisearch:7700
MEILI_MASTER_KEY=change-me
API_BOOTSTRAP_TOKEN=change-me-bootstrap-token
# =============================================================================
# Storage Configuration
# =============================================================================
# Path to libraries directory
# In Docker: leave as default /libraries
# For local dev: set to your local libraries folder path
LIBRARIES_ROOT_PATH=/libraries

151
README.md Normal file
View File

@@ -0,0 +1,151 @@
# Stripstream Librarian
A comprehensive comic book and e-book management system with automatic indexing, full-text search, and a modern web interface.
## Architecture
The project consists of the following components:
- **API** (`apps/api/`) - Rust-based REST API service
- **Indexer** (`apps/indexer/`) - Rust-based background indexing service
- **Backoffice** (`apps/backoffice/`) - Next.js web administration interface
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL and Meilisearch
## Quick Start
### Prerequisites
- Docker and Docker Compose
- (Optional) Rust toolchain for local development
- (Optional) Node.js 18+ for backoffice development
### Environment Setup
1. Copy the example environment file:
```bash
cp .env.example .env
```
2. Edit `.env` and set secure values for:
- `MEILI_MASTER_KEY` - Master key for Meilisearch
- `API_BOOTSTRAP_TOKEN` - Bootstrap token for initial API authentication
### Running with Docker
```bash
cd infra
docker compose up -d
```
This will start:
- PostgreSQL (port 5432)
- Meilisearch (port 7700)
- API service (port 8080)
- Indexer service (port 8081)
- Backoffice web UI (port 8082)
### Accessing the Application
- **Backoffice**: http://localhost:8082
- **API**: http://localhost:8080
- **Meilisearch**: http://localhost:7700
### Default Credentials
The default bootstrap token is configured in your `.env` file. Use this for initial API authentication.
## Development
### Local Development (without Docker)
#### API & Indexer (Rust)
```bash
# Start dependencies
cd infra
docker compose up -d postgres meilisearch
# Run API
cd apps/api
cargo run
# Run Indexer (in another terminal)
cd apps/indexer
cargo run
```
#### Backoffice (Next.js)
```bash
cd apps/backoffice
npm install
npm run dev
```
The backoffice will be available at http://localhost:3000
## Features
### Libraries Management
- Create and manage multiple libraries
- Configure automatic scanning schedules (hourly, daily, weekly)
- Real-time file watcher for instant indexing
- Full and incremental rebuild options
### Books Management
- Support for CBZ, CBR, and PDF formats
- Automatic metadata extraction
- Series and volume detection
- Full-text search with Meilisearch
### Jobs Monitoring
- Real-time job progress tracking
- Detailed statistics (scanned, indexed, removed, errors)
- Job history and logs
- Cancel pending jobs
### Search
- Full-text search across titles, authors, and series
- Library filtering
- Real-time suggestions
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `API_LISTEN_ADDR` | API service bind address | `0.0.0.0:8080` |
| `INDEXER_LISTEN_ADDR` | Indexer service bind address | `0.0.0.0:8081` |
| `BACKOFFICE_PORT` | Backoffice web UI port | `8082` |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://stripstream:stripstream@postgres:5432/stripstream` |
| `MEILI_URL` | Meilisearch connection URL | `http://meilisearch:7700` |
| `MEILI_MASTER_KEY` | Meilisearch master key (required) | - |
| `API_BOOTSTRAP_TOKEN` | Initial API admin token (required) | - |
| `INDEXER_SCAN_INTERVAL_SECONDS` | Watcher scan interval | `5` |
| `LIBRARIES_ROOT_PATH` | Path to libraries directory | `/libraries` |
## API Documentation
The API is documented with OpenAPI/Swagger. When running locally, access the docs at:
```
http://localhost:8080/api-docs
```
## Project Structure
```
stripstream-librarian/
├── apps/
│ ├── api/ # Rust REST API
│ ├── indexer/ # Rust background indexer
│ └── backoffice/ # Next.js web UI
├── infra/
│ ├── docker-compose.yml
│ └── migrations/ # SQL database migrations
├── libraries/ # Book storage (mounted volume)
└── .env # Environment configuration
```
## License
[Your License Here]

View File

@@ -1,17 +0,0 @@
[package]
name = "admin-ui"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
stripstream-core = { path = "../../crates/core" }
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true

View File

@@ -1,22 +0,0 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src
RUN cargo build --release -p admin-ui
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/admin-ui /usr/local/bin/admin-ui
EXPOSE 8082
CMD ["/usr/local/bin/admin-ui"]

View File

@@ -1,337 +0,0 @@
use axum::{
extract::{Form, State},
response::{Html, Redirect},
routing::{get, post},
Router,
};
use reqwest::Client;
use serde::Deserialize;
use stripstream_core::config::AdminUiConfig;
use tracing::info;
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
api_base_url: String,
api_token: String,
client: Client,
}
#[derive(Deserialize)]
struct LibraryDto {
id: Uuid,
name: String,
root_path: String,
enabled: bool,
}
#[derive(Deserialize)]
struct IndexJobDto {
id: Uuid,
#[serde(rename = "type")]
kind: String,
status: String,
created_at: String,
}
#[derive(Deserialize)]
struct TokenDto {
id: Uuid,
name: String,
scope: String,
prefix: String,
revoked_at: Option<String>,
}
#[derive(Deserialize)]
struct CreatedToken {
token: String,
}
#[derive(Deserialize)]
struct AddLibraryForm {
name: String,
root_path: String,
}
#[derive(Deserialize)]
struct DeleteLibraryForm {
id: Uuid,
}
#[derive(Deserialize)]
struct RebuildForm {
library_id: String,
}
#[derive(Deserialize)]
struct CreateTokenForm {
name: String,
scope: String,
}
#[derive(Deserialize)]
struct RevokeTokenForm {
id: Uuid,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "admin_ui=info,axum=info".to_string()),
)
.init();
let config = AdminUiConfig::from_env()?;
let state = AppState {
api_base_url: config.api_base_url,
api_token: config.api_token,
client: Client::new(),
};
let app = Router::new()
.route("/health", get(health))
.route("/", get(index))
.route("/libraries", get(libraries_page))
.route("/libraries/add", post(add_library))
.route("/libraries/delete", post(delete_library))
.route("/jobs", get(jobs_page))
.route("/jobs/rebuild", post(rebuild_jobs))
.route("/tokens", get(tokens_page))
.route("/tokens/create", post(create_token))
.route("/tokens/revoke", post(revoke_token))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "admin ui listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}
async fn index() -> Html<String> {
Html(layout(
"Dashboard",
"<h1>Stripstream Admin</h1><p>Gestion des librairies, jobs d'indexation et tokens API.</p><p><a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></p>",
))
}
async fn libraries_page(State(state): State<AppState>) -> Html<String> {
let libraries = fetch_libraries(&state).await.unwrap_or_default();
let mut rows = String::new();
for lib in libraries {
rows.push_str(&format!(
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/libraries/delete'><input type='hidden' name='id' value='{}'/><button type='submit'>Delete</button></form></td></tr>",
html_escape(&lib.name),
html_escape(&lib.root_path),
if lib.enabled { "yes" } else { "no" },
lib.id
));
}
let content = format!(
"<h1>Libraries</h1>
<form method='post' action='/libraries/add'>
<input name='name' placeholder='Name' required />
<input name='root_path' placeholder='/libraries/demo' required />
<button type='submit'>Add</button>
</form>
<table border='1' cellpadding='6'>
<tr><th>Name</th><th>Root Path</th><th>Enabled</th><th>Actions</th></tr>
{}
</table>",
rows
);
Html(layout("Libraries", &content))
}
async fn add_library(State(state): State<AppState>, Form(form): Form<AddLibraryForm>) -> Redirect {
let _ = state
.client
.post(format!("{}/libraries", state.api_base_url))
.bearer_auth(&state.api_token)
.json(&serde_json::json!({"name": form.name, "root_path": form.root_path}))
.send()
.await;
Redirect::to("/libraries")
}
async fn delete_library(State(state): State<AppState>, Form(form): Form<DeleteLibraryForm>) -> Redirect {
let _ = state
.client
.delete(format!("{}/libraries/{}", state.api_base_url, form.id))
.bearer_auth(&state.api_token)
.send()
.await;
Redirect::to("/libraries")
}
async fn jobs_page(State(state): State<AppState>) -> Html<String> {
let jobs = fetch_jobs(&state).await.unwrap_or_default();
let mut rows = String::new();
for job in jobs {
rows.push_str(&format!(
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
job.id,
html_escape(&job.kind),
html_escape(&job.status),
html_escape(&job.created_at)
));
}
let content = format!(
"<h1>Index Jobs</h1>
<form method='post' action='/jobs/rebuild'>
<input name='library_id' placeholder='optional library UUID' />
<button type='submit'>Queue Rebuild</button>
</form>
<table border='1' cellpadding='6'>
<tr><th>ID</th><th>Type</th><th>Status</th><th>Created</th></tr>
{}
</table>",
rows
);
Html(layout("Jobs", &content))
}
async fn rebuild_jobs(State(state): State<AppState>, Form(form): Form<RebuildForm>) -> Redirect {
let body = if form.library_id.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::json!({"library_id": form.library_id.trim()})
};
let _ = state
.client
.post(format!("{}/index/rebuild", state.api_base_url))
.bearer_auth(&state.api_token)
.json(&body)
.send()
.await;
Redirect::to("/jobs")
}
async fn tokens_page(State(state): State<AppState>) -> Html<String> {
let tokens = fetch_tokens(&state).await.unwrap_or_default();
let mut rows = String::new();
for token in tokens {
rows.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/tokens/revoke'><input type='hidden' name='id' value='{}'/><button type='submit'>Revoke</button></form></td></tr>",
html_escape(&token.name),
html_escape(&token.scope),
html_escape(&token.prefix),
if token.revoked_at.is_some() { "yes" } else { "no" },
token.id
));
}
let content = format!(
"<h1>API Tokens</h1>
<form method='post' action='/tokens/create'>
<input name='name' placeholder='token name' required />
<select name='scope'>
<option value='read'>read</option>
<option value='admin'>admin</option>
</select>
<button type='submit'>Create Token</button>
</form>
<table border='1' cellpadding='6'>
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Revoked</th><th>Actions</th></tr>
{}
</table>",
rows
);
Html(layout("Tokens", &content))
}
async fn create_token(State(state): State<AppState>, Form(form): Form<CreateTokenForm>) -> Html<String> {
let response = state
.client
.post(format!("{}/admin/tokens", state.api_base_url))
.bearer_auth(&state.api_token)
.json(&serde_json::json!({"name": form.name, "scope": form.scope}))
.send()
.await;
match response {
Ok(resp) if resp.status().is_success() => {
let created: Result<CreatedToken, _> = resp.json().await;
match created {
Ok(token) => Html(layout(
"Token Created",
&format!(
"<h1>Token Created</h1><p>Copie ce token maintenant (il ne sera plus affiche):</p><pre>{}</pre><p><a href='/tokens'>Back to tokens</a></p>",
html_escape(&token.token)
),
)),
Err(_) => Html(layout("Error", "<p>Token created but response parse failed.</p><p><a href='/tokens'>Back</a></p>")),
}
}
_ => Html(layout("Error", "<p>Token creation failed.</p><p><a href='/tokens'>Back</a></p>")),
}
}
async fn revoke_token(State(state): State<AppState>, Form(form): Form<RevokeTokenForm>) -> Redirect {
let _ = state
.client
.delete(format!("{}/admin/tokens/{}", state.api_base_url, form.id))
.bearer_auth(&state.api_token)
.send()
.await;
Redirect::to("/tokens")
}
async fn fetch_libraries(state: &AppState) -> Result<Vec<LibraryDto>, reqwest::Error> {
state
.client
.get(format!("{}/libraries", state.api_base_url))
.bearer_auth(&state.api_token)
.send()
.await?
.json::<Vec<LibraryDto>>()
.await
}
async fn fetch_jobs(state: &AppState) -> Result<Vec<IndexJobDto>, reqwest::Error> {
state
.client
.get(format!("{}/index/status", state.api_base_url))
.bearer_auth(&state.api_token)
.send()
.await?
.json::<Vec<IndexJobDto>>()
.await
}
async fn fetch_tokens(state: &AppState) -> Result<Vec<TokenDto>, reqwest::Error> {
state
.client
.get(format!("{}/admin/tokens", state.api_base_url))
.bearer_auth(&state.api_token)
.send()
.await?
.json::<Vec<TokenDto>>()
.await
}
fn layout(title: &str, content: &str) -> String {
format!(
"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{}</title><style>body{{font-family:ui-sans-serif,system-ui;margin:2rem;line-height:1.4}}table{{width:100%;border-collapse:collapse;margin-top:1rem}}th,td{{text-align:left}}input,select,button{{padding:.45rem;margin:.25rem}}</style></head><body><nav><a href='/'>Home</a> | <a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></nav><hr />{}</body></html>",
html_escape(title),
content
)
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

View File

@@ -1,6 +1,6 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { Card, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";
@@ -13,6 +13,8 @@ export default async function BooksPage({
const searchParamsAwaited = await searchParams;
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [libraries] = await Promise.all([
fetchLibraries().catch(() => [] as LibraryDto[])
@@ -25,7 +27,7 @@ export default async function BooksPage({
if (searchQuery) {
// Mode recherche
const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null);
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
if (searchResponse) {
searchResults = searchResponse.hits.map(hit => ({
id: hit.id,
@@ -45,10 +47,15 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
// Mode liste
const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null }));
// Mode liste avec pagination
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
items: [] as BookDto[],
next_cursor: null,
prev_cursor: null
}));
books = booksPage.items;
nextCursor = booksPage.next_cursor;
// Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI
}
const displayBooks = (searchResults || books).map(book => ({
@@ -56,6 +63,9 @@ export default async function BooksPage({
coverUrl: getBookCoverUrl(book.id)
}));
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
@@ -110,19 +120,14 @@ export default async function BooksPage({
<BooksGrid books={displayBooks} />
{/* Pagination */}
{!searchQuery && nextCursor && (
<div className="flex justify-center mt-8">
<form>
<input type="hidden" name="library" value={libraryId || ""} />
<input type="hidden" name="cursor" value={nextCursor} />
<button
type="submit"
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
>
📥 Load more
</button>
</form>
</div>
{!searchQuery && (
<CursorPagination
hasNextPage={hasNextPage}
hasPrevPage={hasPrevPage}
pageSize={limit}
currentCount={displayBooks.length}
nextCursor={nextCursor}
/>
)}
</>
) : (

View File

@@ -97,7 +97,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
)}
</div>
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
<span>{processed} / {total} files</span>

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { JobProgress } from "./JobProgress";
import { StatusBadge, Button } from "./ui";
import { StatusBadge, Button, MiniProgressBar } from "./ui";
interface JobRowProps {
job: {
@@ -12,14 +12,27 @@ interface JobRowProps {
type: string;
status: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
error_opt: string | null;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
};
libraryName: string | undefined;
highlighted?: boolean;
onCancel: (id: string) => void;
formatDate: (date: string) => string;
formatDuration: (start: string, end: string | null) => string;
}
export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) {
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const [showProgress, setShowProgress] = useState(
highlighted || job.status === "running" || job.status === "pending"
);
@@ -29,6 +42,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
window.location.reload();
};
// Calculate duration
const duration = job.started_at
? formatDuration(job.started_at, job.finished_at)
: "-";
// Get file stats
const scanned = job.stats_json?.scanned_files ?? 0;
const indexed = job.stats_json?.indexed_files ?? 0;
const removed = job.stats_json?.removed_files ?? 0;
const errors = job.stats_json?.errors ?? 0;
// Format files display
const filesDisplay = job.status === "running" && job.total_files
? `${job.processed_files || 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
: "-";
return (
<>
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
@@ -65,8 +96,30 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{filesDisplay}</span>
{job.status === "running" && job.total_files && (
<MiniProgressBar
value={job.processed_files || 0}
max={job.total_files}
className="w-24"
/>
)}
{job.status === "success" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-success"> {indexed}</span>
{removed > 0 && <span className="text-warning"> {removed}</span>}
{errors > 0 && <span className="text-error"> {errors}</span>}
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted">
{new Date(job.created_at).toLocaleString()}
{duration}
</td>
<td className="px-4 py-3 text-sm text-muted">
{formatDate(job.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@@ -90,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
</tr>
{showProgress && (job.status === "running" || job.status === "pending") && (
<tr>
<td colSpan={6} className="px-4 py-3 bg-muted/5">
<td colSpan={8} className="px-4 py-3 bg-muted/5">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { JobRow } from "./JobRow";
import { MiniProgressBar } from "./ui";
interface Job {
id: string;
@@ -9,7 +10,18 @@ interface Job {
type: string;
status: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
error_opt: string | null;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
}
interface JobsListProps {
@@ -18,6 +30,36 @@ interface JobsListProps {
highlightJobId?: string;
}
function formatDuration(start: string, end: string | null): string {
const startDate = new Date(start);
const endDate = end ? new Date(end) : new Date();
const diff = endDate.getTime() - startDate.getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`;
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than 1 hour: show relative
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
}
// Less than 24 hours: show hours
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
// Otherwise: show date
return date.toLocaleDateString();
}
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
const [jobs, setJobs] = useState(initialJobs);
@@ -53,7 +95,6 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
});
if (response.ok) {
// Update local state to reflect cancellation
setJobs(jobs.map(job =>
job.id === id ? { ...job, status: "cancelled" } : job
));
@@ -73,6 +114,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
</tr>
@@ -85,6 +128,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId}
onCancel={handleCancel}
formatDate={formatDate}
formatDuration={formatDuration}
/>
))}
</tbody>

View File

@@ -0,0 +1,225 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "./Button";
interface CursorPaginationProps {
hasNextPage: boolean;
hasPrevPage: boolean;
pageSize: number;
currentCount: number;
pageSizeOptions?: number[];
nextCursor?: string | null;
}
export function CursorPagination({
hasNextPage,
hasPrevPage,
pageSize,
currentCount,
pageSizeOptions = [20, 50, 100],
nextCursor,
}: CursorPaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const goToNext = () => {
if (!nextCursor) return;
const params = new URLSearchParams(searchParams);
params.set("cursor", nextCursor);
router.push(`?${params.toString()}`);
};
const goToFirst = () => {
const params = new URLSearchParams(searchParams);
params.delete("cursor");
router.push(`?${params.toString()}`);
};
const changePageSize = (size: number) => {
const params = new URLSearchParams(searchParams);
params.set("limit", size.toString());
params.delete("cursor");
router.push(`?${params.toString()}`);
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted">
Showing {currentCount} items
</div>
{/* Navigation */}
<div className="flex items-center gap-3">
{hasPrevPage && (
<Button
variant="secondary"
size="sm"
onClick={goToFirst}
>
First
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={!hasNextPage}
>
Next
</Button>
</div>
</div>
);
}
interface OffsetPaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
totalItems: number;
pageSizeOptions?: number[];
}
export function OffsetPagination({
currentPage,
totalPages,
pageSize,
totalItems,
pageSizeOptions = [20, 50, 100],
}: OffsetPaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const goToPage = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
router.push(`?${params.toString()}`);
};
const changePageSize = (size: number) => {
const params = new URLSearchParams(searchParams);
params.set("limit", size.toString());
params.set("page", "1");
router.push(`?${params.toString()}`);
};
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted">
{startItem}-{endItem} of {totalItems}
</div>
{/* Page navigation */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
>
</Button>
{getPageNumbers().map((page, index) => (
<span key={index}>
{page === "..." ? (
<span className="px-3 py-2 text-sm text-muted">...</span>
) : (
<Button
variant={currentPage === page ? "primary" : "ghost"}
size="sm"
onClick={() => goToPage(page as number)}
>
{page}
</Button>
)}
</span>
))}
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
>
</Button>
</div>
</div>
);
}

View File

@@ -6,3 +6,4 @@ export { Button } from "./Button";
export { Input, Select } from "./Input";
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
export { PageIcon, NavIcon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination";

View File

@@ -31,6 +31,12 @@
--color-primary: hsl(194 76% 62%);
--color-primary-soft: hsl(210 34% 24%);
--color-muted: hsl(218 17% 72%);
--color-success: hsl(142 70% 55%);
--color-success-soft: hsl(142 30% 20%);
--color-warning: hsl(45 90% 55%);
--color-warning-soft: hsl(45 30% 20%);
--color-error: hsl(2 80% 65%);
--color-error-soft: hsl(2 30% 20%);
}
/* Base styles */

View File

@@ -65,7 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</nav>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</ThemeProvider>

View File

@@ -0,0 +1,95 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { Card, Badge, Button, CursorPagination } from "../../../components/ui";
import Link from "next/link";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function LibraryBooksPage({
params,
searchParams
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id } = await params;
const searchParamsAwaited = await searchParams;
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [library, booksPage] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchBooks(id, series, cursor, limit).catch(() => ({
items: [] as BookDto[],
next_cursor: null
}))
]);
if (!library) {
notFound();
}
const books = booksPage.items.map(book => ({
...book,
coverUrl: getBookCoverUrl(book.id)
}));
const nextCursor = booksPage.next_cursor;
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor;
return (
<>
<div className="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<div className="flex items-center gap-4 mb-6">
<h2 className="text-xl font-semibold text-foreground">
{series ? `Books in "${seriesDisplayName}"` : "All Books"}
</h2>
{series && (
<Link href={`/libraries/${id}/books`} className="text-sm text-primary hover:text-primary/80">
View all
</Link>
)}
</div>
{books.length > 0 ? (
<>
<BooksGrid books={books} />
<CursorPagination
hasNextPage={hasNextPage}
hasPrevPage={hasPrevPage}
pageSize={limit}
currentCount={books.length}
nextCursor={nextCursor}
/>
</>
) : (
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
)}
</>
);
}

View File

@@ -0,0 +1,87 @@
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } from "../../../../lib/api";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, Badge } from "../../../components/ui";
export const dynamic = "force-dynamic";
export default async function LibrarySeriesPage({
params
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [library, series] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchSeries(id).catch(() => [] as SeriesDto[])
]);
if (!library) {
notFound();
}
return (
<>
<div className="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<h2 className="text-xl font-semibold text-foreground mb-6">Series ({series.length})</h2>
{series.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden hover:shadow-card transition-shadow">
<div className="aspect-[2/3] relative bg-muted/10">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
fill
className="object-cover"
unoptimized
/>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
</h3>
<p className="text-xs text-muted mt-1">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-12 text-muted">
<p>No series found in this library</p>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,181 @@
import { revalidatePath } from "next/cache";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
import { LibraryActions } from "../components/LibraryActions";
import { Card, CardHeader, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
export const dynamic = "force-dynamic";
function formatNextScan(nextScanAt: string | null): string {
if (!nextScanAt) return "-";
const date = new Date(nextScanAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return "Due now";
if (diff < 60000) return "< 1 min";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return `${Math.floor(diff / 86400000)}d`;
}
export default async function LibrariesPage() {
const [libraries, folders] = await Promise.all([
fetchLibraries().catch(() => [] as LibraryDto[]),
listFolders().catch(() => [] as FolderItem[])
]);
const seriesCounts = await Promise.all(
libraries.map(async (lib) => {
try {
const series = await fetchSeries(lib.id);
return { id: lib.id, count: series.length };
} catch {
return { id: lib.id, count: 0 };
}
})
);
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
async function addLibrary(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const rootPath = formData.get("root_path") as string;
if (name && rootPath) {
await createLibrary(name, rootPath);
revalidatePath("/libraries");
}
}
async function removeLibrary(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteLibrary(id);
revalidatePath("/libraries");
}
async function scanLibraryAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await scanLibrary(id);
revalidatePath("/libraries");
revalidatePath("/jobs");
}
async function scanLibraryFullAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await scanLibrary(id, true);
revalidatePath("/libraries");
revalidatePath("/jobs");
}
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
Libraries
</h1>
{/* Add Library Form */}
<Card className="mb-6">
<CardHeader title="Add New Library" />
<form action={addLibrary}>
<FormRow>
<FormField>
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField>
<FormSelect name="root_path" required defaultValue="">
<option value="" disabled>Select folder...</option>
{folders.map((folder) => (
<option key={folder.path} value={folder.path}>
{folder.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit"> Add Library</Button>
</FormRow>
</form>
</Card>
{/* Libraries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{libraries.map((lib) => {
const seriesCount = seriesCountMap.get(lib.id) || 0;
return (
<Card key={lib.id} className="flex flex-col">
{/* Header with settings */}
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-foreground">{lib.name}</h3>
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
</div>
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
/>
</div>
{/* Path */}
<code className="text-xs font-mono text-muted mb-4 break-all">{lib.root_path}</code>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
<Link href={`/libraries/${lib.id}/books`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
<span className="text-xs text-muted">Books</span>
</Link>
<Link href={`/libraries/${lib.id}/series`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
<span className="text-xs text-muted">Series</span>
</Link>
</div>
{/* Status */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted'}`}>
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
</span>
{lib.watcher_enabled && (
<span className="text-warning" title="File watcher active"></span>
)}
{lib.monitor_enabled && lib.next_scan_at && (
<span className="text-xs text-muted ml-auto">
Next: {formatNextScan(lib.next_scan_at)}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-auto">
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="primary" size="sm" className="w-full" formAction={scanLibraryAction}>
🔄 Index
</Button>
</form>
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
🔁 Full
</Button>
</form>
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="danger" size="sm" formAction={removeLibrary}>
🗑
</Button>
</form>
</div>
</Card>
);
})}
</div>
</>
);
}

View File

@@ -19,6 +19,15 @@ export type IndexJobDto = {
finished_at: string | null;
error_opt: string | null;
created_at: string;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
};
export type TokenDto = {

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -250,12 +250,14 @@ fn setup_watcher(
libraries: HashMap<Uuid, String>,
tx: mpsc::Sender<(Uuid, String)>,
) -> anyhow::Result<RecommendedWatcher> {
let watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
let libraries_for_closure = libraries.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
match res {
Ok(event) => {
if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() {
for path in event.paths {
if let Some((library_id, _)) = libraries.iter().find(|(_, root)| {
if let Some((library_id, _)) = libraries_for_closure.iter().find(|(_, root)| {
path.starts_with(root)
}) {
let path_str = path.to_string_lossy().to_string();
@@ -270,6 +272,12 @@ fn setup_watcher(
}
})?;
// Actually watch the library directories
for (_, root_path) in &libraries {
info!("[WATCHER] Watching directory: {}", root_path);
watcher.watch(std::path::Path::new(root_path), RecursiveMode::Recursive)?;
}
Ok(watcher)
}
@@ -437,12 +445,15 @@ async fn process_job(state: &AppState, job_id: Uuid, target_library_id: Option<U
removed_files: 0,
errors: 0,
};
// Track processed files across all libraries for accurate progress
let mut total_processed_count = 0i32;
for library in libraries {
let library_id: Uuid = library.get("id");
let root_path: String = library.get("root_path");
let root_path = remap_libraries_path(&root_path);
match scan_library(state, job_id, library_id, Path::new(&root_path), &mut stats, total_files, is_full_rebuild).await {
match scan_library(state, job_id, library_id, Path::new(&root_path), &mut stats, &mut total_processed_count, total_files, is_full_rebuild).await {
Ok(()) => {}
Err(err) => {
stats.errors += 1;
@@ -453,9 +464,10 @@ async fn process_job(state: &AppState, job_id: Uuid, target_library_id: Option<U
sync_meili(&state.pool, &state.meili_url, &state.meili_master_key).await?;
sqlx::query("UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, current_file = NULL WHERE id = $1")
sqlx::query("UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, current_file = NULL, progress_percent = 100, processed_files = $3 WHERE id = $1")
.bind(job_id)
.bind(serde_json::to_value(&stats)?)
.bind(total_processed_count)
.execute(&state.pool)
.await?;
@@ -477,6 +489,7 @@ async fn scan_library(
library_id: Uuid,
root: &Path,
stats: &mut JobStats,
total_processed_count: &mut i32,
total_files: usize,
is_full_rebuild: bool,
) -> anyhow::Result<()> {
@@ -507,7 +520,7 @@ async fn scan_library(
}
let mut seen: HashMap<String, bool> = HashMap::new();
let mut processed_count = 0i32;
let mut library_processed_count = 0i32;
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
@@ -520,7 +533,8 @@ async fn scan_library(
};
stats.scanned_files += 1;
processed_count += 1;
library_processed_count += 1;
*total_processed_count += 1;
let abs_path_local = path.to_string_lossy().to_string();
// Convert local path to /libraries format for DB storage
let abs_path = unmap_libraries_path(&abs_path_local);
@@ -528,12 +542,12 @@ async fn scan_library(
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| abs_path.clone());
info!("[SCAN] Job {} processing file {}/{}: {}", job_id, processed_count, total_files, file_name);
info!("[SCAN] Job {} processing file {}/{} (library: {}): {}", job_id, total_processed_count, total_files, library_processed_count, file_name);
let start_time = std::time::Instant::now();
// Update progress in DB
// Update progress in DB using the global processed count
let progress_percent = if total_files > 0 {
((processed_count as f64 / total_files as f64) * 100.0) as i32
((*total_processed_count as f64 / total_files as f64) * 100.0) as i32
} else {
0
};
@@ -544,7 +558,7 @@ async fn scan_library(
)
.bind(job_id)
.bind(&file_name)
.bind(processed_count)
.bind(*total_processed_count)
.bind(progress_percent)
.execute(&state.pool)
.await
@@ -554,7 +568,9 @@ async fn scan_library(
})?;
info!("[BDD] Progress update took {:?}", db_start.elapsed());
seen.insert(abs_path.clone(), true);
// Use local path for seen tracking to match existing keys
let seen_key = remap_libraries_path(&abs_path);
seen.insert(seen_key, true);
let meta_start = std::time::Instant::now();
let metadata = std::fs::metadata(path)
@@ -566,7 +582,9 @@ async fn scan_library(
let fingerprint = compute_fingerprint(path, metadata.len(), &mtime)?;
info!("[META] Metadata+fingerprint took {:?}", meta_start.elapsed());
if let Some((file_id, book_id, old_fingerprint)) = existing.get(&abs_path).cloned() {
// Use local path to lookup in existing (which has local paths as keys)
let lookup_path = remap_libraries_path(&abs_path);
if let Some((file_id, book_id, old_fingerprint)) = existing.get(&lookup_path).cloned() {
// Skip fingerprint check for full rebuilds - always reindex
if !is_full_rebuild && old_fingerprint == fingerprint {
info!("[SKIP] File unchanged, skipping: {} (total time: {:?})", file_name, start_time.elapsed());

View File

@@ -34,15 +34,17 @@ services:
depends_on:
postgres:
condition: service_healthy
env_file:
- ../.env
environment:
POSTGRES_USER: stripstream
POSTGRES_PASSWORD: stripstream
POSTGRES_DB: stripstream
volumes:
- ./migrations:/migrations:ro
command:
[
"sh",
"-c",
"for f in /migrations/*.sql; do echo \"Applying migration: $f\"; psql -h postgres -U \"$${POSTGRES_USER:-stripstream}\" -d \"$${POSTGRES_DB:-stripstream}\" -f \"$f\" || exit 1; done",
"export PGPASSWORD=$$POSTGRES_PASSWORD; for f in /migrations/*.sql; do echo \"Applying migration: $$f\"; psql -h postgres -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -f \"$$f\" || exit 1; done",
]
api: