Compare commits

...

28 Commits

Author SHA1 Message Date
766e3a01b2 chore: bump version to 1.23.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 45s
2026-03-21 17:43:11 +01:00
626e2e035d feat: send book thumbnails in Telegram notifications
Use Telegram sendPhoto API for conversion and metadata-approved events
when a book thumbnail is available on disk. Falls back to text message
if photo upload fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:43:01 +01:00
cfd2321db2 chore: bump version to 1.22.0 2026-03-21 17:40:22 +01:00
1b715033ce fix: add missing Next.js route handler for Telegram test endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:39:46 +01:00
81d1586501 feat: add Telegram notification system with granular event toggles
Add notifications crate shared between API and indexer to send Telegram
messages on scan/thumbnail/conversion completion/failure, metadata linking,
batch and refresh events. Configurable via a new Notifications tab in the
backoffice settings with per-event toggle switches grouped by category.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:24:43 +01:00
bd74c9e3e3 docs: add comprehensive features list to README and docs/FEATURES.md
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
Replace the minimal README features section with a concise categorized
summary and link to a detailed docs/FEATURES.md covering all features,
business rules, API endpoints, and integrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:34:36 +01:00
41228430cf chore: bump version to 1.21.2 2026-03-21 14:34:32 +01:00
6a4ba06fac fix: include series_metadata authors in authors listing and detail pages
Authors were only sourced from books.authors/books.author fields which are
often empty. Now also aggregates authors from series_metadata.authors
(populated by metadata providers like bedetheque). Adds author filter to
/series endpoint and updates the author detail page to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:34:11 +01:00
e5c3542d3f refactor: split books.rs into books+series, reorganize OpenAPI tags and fix access control
- Extract series code from books.rs into dedicated series.rs module
- Reorganize OpenAPI tags: split overloaded "books" tag into books, series, search, stats
- Add missing endpoints to OpenAPI: metadata_batch, metadata_refresh, komga, update_metadata_provider
- Add missing schemas: MissingVolumeInput, Komga/Batch/Refresh DTOs
- Fix access control: move GET /libraries and POST /libraries/:id/scan to read routes
  so non-admin tokens can list libraries and trigger scans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:23:19 +01:00
24516f1069 chore: bump version to 1.21.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
2026-03-21 13:42:17 +01:00
5383cdef60 feat: allow batch metadata and refresh metadata on all libraries
When no specific library is selected, iterate over all libraries and
trigger a job for each one, skipping libraries with metadata disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:42:08 +01:00
be5c3f7a34 fix: pass explicit locale to date formatting to prevent hydration mismatch
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
Server and client could use different default locales for
toLocaleDateString/toLocaleString, causing React hydration errors.
Pass the user locale explicitly in JobsList and SettingsPage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:36:35 +01:00
caa9922ff9 chore: bump version to 1.21.0 2026-03-21 13:34:47 +01:00
135f000c71 refactor: switch JobsIndicator from polling to SSE and fix stream endpoint
Replace fetch polling in JobsIndicator with EventSource connected to
/api/jobs/stream. Fix the SSE route to return all jobs (via
/index/status) instead of only active ones, since JobsList also
consumes this stream for the full job history. JobsIndicator now
filters active jobs client-side. SSE server-side uses adaptive
interval (2s active, 15s idle) and only sends when data changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:33:58 +01:00
d9e50a4235 chore: bump version to 1.20.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m13s
2026-03-21 13:13:39 +01:00
5f6eb5a5cb perf: add selective fetch caching for stable API endpoints
Make apiFetch support Next.js revalidate option instead of
hardcoding cache: no-store on every request. Stable endpoints
(libraries, settings, stats, series statuses) now use time-based
revalidation while dynamic data (books, search, jobs) stays uncached.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:13:28 +01:00
41c77fca2e chore: bump version to 1.20.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m15s
2026-03-21 13:06:28 +01:00
49621f3fb1 perf: wrap BookCard and BookImage with React.memo
Prevent unnecessary re-renders of book grid items when parent
components update without changing book data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:03:24 +01:00
6df743b2e6 perf: lazy-load heavy modal components with next/dynamic
Dynamic import EditBookForm, EditSeriesForm, MetadataSearchModal, and
ProwlarrSearchModal so their code is split into separate chunks and
only fetched when the user interacts with them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:02:10 +01:00
edfefc0128 perf: optimize JobsIndicator polling with visibility API and adaptive interval
Pause polling when the tab is hidden, refetch immediately when it
becomes visible again, and use a 30s interval when no jobs are active
instead of polling every 2s unconditionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:59:06 +01:00
b0185abefe perf: enable Next.js image optimization across backoffice
Remove `unoptimized` flag from all thumbnail/cover Image components
and add proper responsive `sizes` props. Convert raw `<img>` tags on
the libraries page to next/image. Add 24h minimumCacheTTL for
optimized images. BookPreview keeps `unoptimized` since the API
already returns optimized WebP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:57:10 +01:00
b9e54cbfd8 chore: bump version to 1.19.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 54s
2026-03-21 12:47:31 +01:00
3f0bd783cd feat: include series_count and thumbnail_book_ids in libraries API response
Eliminates N+1 sequential fetchSeries calls on the libraries page by
returning series count and up to 5 thumbnail book IDs (one per series)
directly from GET /libraries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:47:10 +01:00
fc8856c83f chore: bump version to 1.19.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m19s
2026-03-21 08:12:19 +01:00
bd09f3d943 feat: persist filter state in localStorage across pages
Save/restore filter values in LiveSearchForm using localStorage keyed
by basePath (e.g. filters:/books, filters:/series). Filters are restored
on mount when the URL has no active filters, and cleared when the user
clicks the Clear button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:12:10 +01:00
1f434c3d67 feat: add format and metadata filters to books page
Add two new filters to the books listing page:
- Format filter (CBZ/CBR/PDF/EPUB) using existing API support
- Metadata linked/unlinked filter with new API support via
  LEFT JOIN on external_metadata_links (using DISTINCT ON CTE
  matching the series endpoint pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:09:37 +01:00
4972a403df chore: bump version to 1.18.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-21 07:47:52 +01:00
629708cdd0 feat: redesign libraries page UI with fan thumbnails and modal settings
- Replace thumbnail mosaic with fan/arc layout using series covers as background
- Move library settings from dropdown to full-page portal modal with sections
- Move FolderPicker modal to portal for proper z-index stacking
- Add descriptions to each setting for better clarity
- Move delete button to card header, compact config tags
- Add i18n keys for new labels and descriptions (en/fr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:47:36 +01:00
50 changed files with 3392 additions and 1428 deletions

25
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "1.17.0"
version = "1.23.0"
dependencies = [
"anyhow",
"argon2",
@@ -76,6 +76,7 @@ dependencies = [
"image",
"jpeg-decoder",
"lru",
"notifications",
"parsers",
"rand 0.8.5",
"regex",
@@ -1232,7 +1233,7 @@ dependencies = [
[[package]]
name = "indexer"
version = "1.17.0"
version = "1.23.0"
dependencies = [
"anyhow",
"axum",
@@ -1240,6 +1241,7 @@ dependencies = [
"futures",
"image",
"jpeg-decoder",
"notifications",
"num_cpus",
"parsers",
"reqwest",
@@ -1663,6 +1665,19 @@ dependencies = [
"nom",
]
[[package]]
name = "notifications"
version = "1.23.0"
dependencies = [
"anyhow",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1771,7 +1786,7 @@ dependencies = [
[[package]]
name = "parsers"
version = "1.17.0"
version = "1.23.0"
dependencies = [
"anyhow",
"flate2",
@@ -2270,6 +2285,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -2278,6 +2294,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -2906,7 +2923,7 @@ dependencies = [
[[package]]
name = "stripstream-core"
version = "1.17.0"
version = "1.23.0"
dependencies = [
"anyhow",
"serde",

View File

@@ -3,13 +3,14 @@ members = [
"apps/api",
"apps/indexer",
"crates/core",
"crates/notifications",
"crates/parsers",
]
resolver = "2"
[workspace.package]
edition = "2021"
version = "1.17.0"
version = "1.23.0"
license = "MIT"
[workspace.dependencies]
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
jpeg-decoder = "0.3"
lru = "0.12"
rayon = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -81,28 +81,58 @@ The backoffice will be available at http://localhost:7082
## 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
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
### Books Management
- Support for CBZ, CBR, and PDF formats
- Automatic metadata extraction
- Series and volume detection
- Full-text search powered by PostgreSQL
### Libraries
- Multi-library management with per-library configuration
- Incremental and full scanning, real-time filesystem watcher
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
### Jobs Monitoring
- Real-time job progress tracking
- Detailed statistics (scanned, indexed, removed, errors)
- Job history and logs
- Cancel pending jobs
### Books & Series
- **Formats**: CBZ, CBR, PDF, EPUB
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
- Series aggregation with missing volume detection
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
- CBR → CBZ conversion
### Search
- Full-text search across titles, authors, and series
- Library filtering
- Real-time suggestions
### Reading Progress
- Per-book tracking: unread / reading / read with current page
- Series-level aggregated reading status
- Bulk mark-as-read for series
### Search & Discovery
- Full-text search across titles, authors, and series (PostgreSQL `pg_trgm`)
- Author listing with book/series counts
- Filtering by reading status, series status, format, metadata provider
### External Metadata
- Search, match, approve/reject workflow with confidence scoring
- Batch auto-matching and scheduled metadata refresh
- Field locking to protect manual edits from sync
### External Integrations
- **Komga**: import reading progress
- **Prowlarr**: search for missing volumes
- **qBittorrent**: add torrents directly from search results
### Background Jobs
- Rebuild, rescan, thumbnail generation, metadata batch, CBR conversion
- Real-time progress via Server-Sent Events (SSE)
- Job history, error tracking, cancellation
### Page Rendering
- On-demand page extraction from all formats
- Image processing (format, quality, max width, resampling filter)
- LRU in-memory + disk cache
### Security
- Token-based auth (`admin` / `read` scopes) with Argon2 hashing
- Rate limiting, token expiration and revocation
### Web UI (Backoffice)
- Dashboard with statistics, charts, and reading progress
- Library, book, series, author management
- Live job monitoring, metadata search modals, settings panel
## Environment Variables

View File

@@ -15,6 +15,7 @@ futures = "0.3"
image.workspace = true
jpeg-decoder.workspace = true
lru.workspace = true
notifications = { path = "../../crates/notifications" }
stripstream-core = { path = "../../crates/core" }
parsers = { path = "../../crates/parsers" }
rand.workspace = true

View File

@@ -6,13 +6,15 @@ COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
echo "fn main() {}" > apps/api/src/main.rs && \
echo "fn main() {}" > apps/indexer/src/main.rs && \
echo "" > apps/indexer/src/lib.rs && \
echo "" > crates/core/src/lib.rs && \
echo "" > crates/notifications/src/lib.rs && \
echo "" > crates/parsers/src/lib.rs
# Build dependencies only (cached as long as Cargo.toml files don't change)
@@ -26,12 +28,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY crates/core/src crates/core/src
COPY crates/notifications/src crates/notifications/src
COPY crates/parsers/src crates/parsers/src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
touch apps/api/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
cargo build --release -p api && \
cp /app/target/release/api /usr/local/bin/api

View File

@@ -68,7 +68,7 @@ pub async fn list_authors(
.filter(|s| !s.trim().is_empty())
.map(|s| format!("%{s}%"));
// Aggregate unique authors from books.authors + books.author
// Aggregate unique authors from books.authors + books.author + series_metadata.authors
let sql = format!(
r#"
WITH all_authors AS (
@@ -79,18 +79,21 @@ pub async fn list_authors(
)
) AS name
FROM books
UNION
SELECT DISTINCT UNNEST(authors) AS name
FROM series_metadata
WHERE authors != '{{}}'
),
filtered AS (
SELECT name FROM all_authors
WHERE ($1::text IS NULL OR name ILIKE $1)
),
counted AS (
book_counts AS (
SELECT
f.name,
COUNT(DISTINCT b.id) AS book_count,
COUNT(DISTINCT NULLIF(b.series, '')) AS series_count
f.name AS author_name,
COUNT(DISTINCT b.id) AS book_count
FROM filtered f
JOIN books b ON (
LEFT JOIN books b ON (
f.name = ANY(
COALESCE(
NULLIF(b.authors, '{{}}'),
@@ -99,9 +102,24 @@ pub async fn list_authors(
)
)
GROUP BY f.name
),
series_counts AS (
SELECT
f.name AS author_name,
COUNT(DISTINCT (sm.library_id, sm.name)) AS series_count
FROM filtered f
LEFT JOIN series_metadata sm ON (
f.name = ANY(sm.authors) AND sm.authors != '{{}}'
)
GROUP BY f.name
)
SELECT name, book_count, series_count
FROM counted
SELECT
f.name,
COALESCE(bc.book_count, 0) AS book_count,
COALESCE(sc.series_count, 0) AS series_count
FROM filtered f
LEFT JOIN book_counts bc ON bc.author_name = f.name
LEFT JOIN series_counts sc ON sc.author_name = f.name
ORDER BY {order_clause}
LIMIT $2 OFFSET $3
"#
@@ -116,6 +134,10 @@ pub async fn list_authors(
)
) AS name
FROM books
UNION
SELECT DISTINCT UNNEST(authors) AS name
FROM series_metadata
WHERE authors != '{}'
)
SELECT COUNT(*) AS total
FROM all_authors

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,10 @@ pub struct LibraryResponse {
pub metadata_refresh_mode: String,
#[schema(value_type = Option<String>)]
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
pub series_count: i64,
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
#[schema(value_type = Vec<String>)]
pub thumbnail_book_ids: Vec<Uuid>,
}
#[derive(Deserialize, ToSchema)]
@@ -44,14 +48,27 @@ pub struct CreateLibraryRequest {
responses(
(status = 200, body = Vec<LibraryResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
COALESCE((
SELECT ARRAY_AGG(first_id ORDER BY series_name)
FROM (
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
b.id as first_id
FROM books b
WHERE b.library_id = l.id
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
b.volume NULLS LAST, b.title ASC
LIMIT 5
) sub
), ARRAY[]::uuid[]) as thumbnail_book_ids
FROM libraries l ORDER BY l.created_at DESC"
)
.fetch_all(&state.pool)
@@ -65,6 +82,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
series_count: row.get("series_count"),
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -73,6 +91,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids: row.get("thumbnail_book_ids"),
})
.collect();
@@ -120,6 +139,7 @@ pub async fn create_library(
root_path,
enabled: true,
book_count: 0,
series_count: 0,
monitor_enabled: false,
scan_mode: "manual".to_string(),
next_scan_at: None,
@@ -128,6 +148,7 @@ pub async fn create_library(
fallback_metadata_provider: None,
metadata_refresh_mode: "manual".to_string(),
next_metadata_refresh_at: None,
thumbnail_book_ids: vec![],
}))
}
@@ -199,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
(status = 200, body = IndexJobResponse),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
@@ -337,12 +357,29 @@ pub async fn update_monitoring(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -351,6 +388,7 @@ pub async fn update_monitoring(
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}
@@ -403,12 +441,29 @@ pub async fn update_metadata_provider(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -417,5 +472,6 @@ pub async fn update_metadata_provider(
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}

View File

@@ -17,9 +17,11 @@ mod prowlarr;
mod qbittorrent;
mod reading_progress;
mod search;
mod series;
mod settings;
mod state;
mod stats;
mod telegram;
mod thumbnails;
mod tokens;
@@ -86,14 +88,13 @@ async fn main() -> anyhow::Result<()> {
};
let admin_routes = Router::new()
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
.route("/libraries", axum::routing::post(libraries::create_library))
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
.route("/books/:id", axum::routing::patch(books::update_book))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
@@ -111,6 +112,7 @@ async fn main() -> anyhow::Result<()> {
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
.route("/telegram/test", get(telegram::test_telegram))
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
.route("/komga/reports", get(komga::list_sync_reports))
.route("/komga/reports/:id", get(komga::get_sync_report))
@@ -133,18 +135,20 @@ async fn main() -> anyhow::Result<()> {
));
let read_routes = Router::new()
.route("/libraries", get(libraries::list_libraries))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/books", get(books::list_books))
.route("/books/ongoing", get(books::ongoing_books))
.route("/books/ongoing", get(series::ongoing_books))
.route("/books/:id", get(books::get_book))
.route("/books/:id/thumbnail", get(books::get_thumbnail))
.route("/books/:id/pages/:n", get(pages::get_page))
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
.route("/libraries/:library_id/series", get(books::list_series))
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
.route("/series", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series))
.route("/series/statuses", get(books::series_statuses))
.route("/series/provider-statuses", get(books::provider_statuses))
.route("/libraries/:library_id/series", get(series::list_series))
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
.route("/series", get(series::list_all_series))
.route("/series/ongoing", get(series::ongoing_series))
.route("/series/statuses", get(series::series_statuses))
.route("/series/provider-statuses", get(series::provider_statuses))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
.route("/authors", get(authors::list_authors))
.route("/stats", get(stats::get_stats))

View File

@@ -369,6 +369,26 @@ pub async fn approve_metadata(
.await?;
}
// Notify via Telegram (with first book thumbnail if available)
let provider_for_notif: String = row.get("provider");
let thumbnail_path: Option<String> = sqlx::query_scalar(
"SELECT thumbnail_path FROM books WHERE library_id = $1 AND series_name = $2 AND thumbnail_path IS NOT NULL ORDER BY sort_order LIMIT 1",
)
.bind(library_id)
.bind(&series_name)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
notifications::notify(
state.pool.clone(),
notifications::NotificationEvent::MetadataApproved {
series_name: series_name.clone(),
provider: provider_for_notif,
thumbnail_path,
},
);
Ok(Json(ApproveResponse {
status: "approved".to_string(),
report,

View File

@@ -124,6 +124,12 @@ pub async fn start_batch(
// Spawn the background processing task
let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
@@ -134,6 +140,13 @@ pub async fn start_batch(
.bind(e.to_string())
.execute(&pool)
.await;
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataBatchFailed {
library_name,
error: e.to_string(),
},
);
}
});
@@ -621,6 +634,21 @@ async fn process_metadata_batch(
info!("[METADATA_BATCH] job={job_id} completed: {processed}/{total} series processed");
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataBatchCompleted {
library_name,
total_series: total,
processed,
},
);
Ok(())
}

View File

@@ -133,6 +133,12 @@ pub async fn start_refresh(
// Spawn the background processing task
let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
@@ -143,6 +149,13 @@ pub async fn start_refresh(
.bind(e.to_string())
.execute(&pool)
.await;
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
});
@@ -319,6 +332,22 @@ async fn process_metadata_refresh(
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataRefreshCompleted {
library_name,
refreshed,
unchanged,
errors,
},
);
Ok(())
}

View File

@@ -10,14 +10,14 @@ use utoipa::OpenApi;
crate::reading_progress::update_reading_progress,
crate::reading_progress::mark_series_read,
crate::books::get_thumbnail,
crate::books::list_series,
crate::books::list_all_series,
crate::books::ongoing_series,
crate::books::ongoing_books,
crate::series::list_series,
crate::series::list_all_series,
crate::series::ongoing_series,
crate::series::ongoing_books,
crate::books::convert_book,
crate::books::update_book,
crate::books::get_series_metadata,
crate::books::update_series,
crate::series::get_series_metadata,
crate::series::update_series,
crate::pages::get_page,
crate::search::search_books,
crate::index_jobs::enqueue_rebuild,
@@ -35,6 +35,7 @@ use utoipa::OpenApi;
crate::libraries::delete_library,
crate::libraries::scan_library,
crate::libraries::update_monitoring,
crate::libraries::update_metadata_provider,
crate::tokens::list_tokens,
crate::tokens::create_token,
crate::tokens::revoke_token,
@@ -54,8 +55,8 @@ use utoipa::OpenApi;
crate::metadata::get_metadata_links,
crate::metadata::get_missing_books,
crate::metadata::delete_metadata_link,
crate::books::series_statuses,
crate::books::provider_statuses,
crate::series::series_statuses,
crate::series::provider_statuses,
crate::settings::list_status_mappings,
crate::settings::upsert_status_mapping,
crate::settings::delete_status_mapping,
@@ -63,6 +64,14 @@ use utoipa::OpenApi;
crate::prowlarr::test_prowlarr,
crate::qbittorrent::add_torrent,
crate::qbittorrent::test_qbittorrent,
crate::metadata_batch::start_batch,
crate::metadata_batch::get_batch_report,
crate::metadata_batch::get_batch_results,
crate::metadata_refresh::start_refresh,
crate::metadata_refresh::get_refresh_report,
crate::komga::sync_komga_read_books,
crate::komga::list_sync_reports,
crate::komga::get_sync_report,
),
components(
schemas(
@@ -74,14 +83,14 @@ use utoipa::OpenApi;
crate::reading_progress::UpdateReadingProgressRequest,
crate::reading_progress::MarkSeriesReadRequest,
crate::reading_progress::MarkSeriesReadResponse,
crate::books::SeriesItem,
crate::books::SeriesPage,
crate::books::ListAllSeriesQuery,
crate::books::OngoingQuery,
crate::series::SeriesItem,
crate::series::SeriesPage,
crate::series::ListAllSeriesQuery,
crate::series::OngoingQuery,
crate::books::UpdateBookRequest,
crate::books::SeriesMetadata,
crate::books::UpdateSeriesRequest,
crate::books::UpdateSeriesResponse,
crate::series::SeriesMetadata,
crate::series::UpdateSeriesRequest,
crate::series::UpdateSeriesResponse,
crate::pages::PageQuery,
crate::search::SearchQuery,
crate::search::SearchResponse,
@@ -96,6 +105,7 @@ use utoipa::OpenApi;
crate::libraries::LibraryResponse,
crate::libraries::CreateLibraryRequest,
crate::libraries::UpdateMonitoringRequest,
crate::libraries::UpdateMetadataProviderRequest,
crate::tokens::CreateTokenRequest,
crate::tokens::TokenResponse,
crate::tokens::CreatedTokenResponse,
@@ -137,7 +147,16 @@ use utoipa::OpenApi;
crate::prowlarr::ProwlarrRelease,
crate::prowlarr::ProwlarrCategory,
crate::prowlarr::ProwlarrSearchResponse,
crate::prowlarr::MissingVolumeInput,
crate::prowlarr::ProwlarrTestResponse,
crate::metadata_batch::MetadataBatchRequest,
crate::metadata_batch::MetadataBatchReportDto,
crate::metadata_batch::MetadataBatchResultDto,
crate::metadata_refresh::MetadataRefreshRequest,
crate::metadata_refresh::MetadataRefreshReportDto,
crate::komga::KomgaSyncRequest,
crate::komga::KomgaSyncResponse,
crate::komga::KomgaSyncReportSummary,
ErrorResponse,
)
),
@@ -145,11 +164,16 @@ use utoipa::OpenApi;
("Bearer" = [])
),
tags(
(name = "authors", description = "Author browsing and listing"),
(name = "books", description = "Read-only endpoints for browsing and searching books"),
(name = "books", description = "Book browsing, details and management"),
(name = "series", description = "Series browsing, filtering and management"),
(name = "search", description = "Full-text search across books and series"),
(name = "reading-progress", description = "Reading progress tracking per book"),
(name = "libraries", description = "Library management endpoints (Admin only)"),
(name = "authors", description = "Author browsing and listing"),
(name = "stats", description = "Collection statistics and dashboard data"),
(name = "libraries", description = "Library listing, scanning, and management (create/delete/settings: Admin only)"),
(name = "indexing", description = "Search index management and job control (Admin only)"),
(name = "metadata", description = "External metadata providers and matching (Admin only)"),
(name = "komga", description = "Komga read-status sync (Admin only)"),
(name = "tokens", description = "API token management (Admin only)"),
(name = "settings", description = "Application settings and cache management (Admin only)"),
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),

View File

@@ -43,7 +43,7 @@ pub struct SearchResponse {
#[utoipa::path(
get,
path = "/search",
tag = "books",
tag = "search",
params(
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
("library_id" = Option<String>, Query, description = "Filter by library ID"),

1028
apps/api/src/series.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ pub struct StatsResponse {
#[utoipa::path(
get,
path = "/stats",
tag = "books",
tag = "stats",
responses(
(status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"),

46
apps/api/src/telegram.rs Normal file
View File

@@ -0,0 +1,46 @@
use axum::{extract::State, Json};
use serde::Serialize;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct TelegramTestResponse {
pub success: bool,
pub message: String,
}
/// Test Telegram connection by sending a test message
#[utoipa::path(
get,
path = "/telegram/test",
tag = "notifications",
responses(
(status = 200, body = TelegramTestResponse),
(status = 400, description = "Telegram not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn test_telegram(
State(state): State<AppState>,
) -> Result<Json<TelegramTestResponse>, ApiError> {
let config = notifications::load_telegram_config(&state.pool)
.await
.ok_or_else(|| {
ApiError::bad_request(
"Telegram is not configured or disabled. Set bot_token, chat_id, and enable it.",
)
})?;
match notifications::send_test_message(&config).await {
Ok(()) => Ok(Json(TelegramTestResponse {
success: true,
message: "Test message sent successfully".to_string(),
})),
Err(e) => Ok(Json(TelegramTestResponse {
success: false,
message: format!("Failed to send: {e}"),
})),
}
}

View File

@@ -7,10 +7,11 @@ export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode(""));
let lastData: string | null = null;
let isActive = true;
let consecutiveErrors = 0;
let intervalId: ReturnType<typeof setInterval> | null = null;
const fetchJobs = async () => {
if (!isActive) return;
@@ -25,51 +26,52 @@ export async function GET(request: NextRequest) {
const data = await response.json();
const dataStr = JSON.stringify(data);
// Send if data changed
// Send only if data changed
if (dataStr !== lastData && isActive) {
lastData = dataStr;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${dataStr}\n\n`)
);
} catch (err) {
// Controller closed, ignore
} catch {
isActive = false;
}
}
// Adapt interval: 2s when active jobs exist, 15s when idle
const hasActiveJobs = data.some((j: { status: string }) =>
j.status === "running" || j.status === "pending" || j.status === "extracting_pages" || j.status === "generating_thumbnails"
);
const nextInterval = hasActiveJobs ? 2000 : 15000;
restartInterval(nextInterval);
}
} catch (error) {
if (isActive) {
consecutiveErrors++;
// Only log first failure and every 30th to avoid spam
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
}
}
}
};
// Initial fetch
const restartInterval = (ms: number) => {
if (intervalId !== null) clearInterval(intervalId);
intervalId = setInterval(fetchJobs, ms);
};
// Initial fetch + start polling
await fetchJobs();
// Poll every 2 seconds
const interval = setInterval(async () => {
if (!isActive) {
clearInterval(interval);
return;
}
await fetchJobs();
}, 2000);
// Cleanup
request.signal.addEventListener("abort", () => {
isActive = false;
clearInterval(interval);
if (intervalId !== null) clearInterval(intervalId);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/telegram/test");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to test Telegram connection";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -21,26 +21,19 @@ export default async function AuthorDetailPage({
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
// Fetch books by this author (server-side filtering via API) and series
// Fetch books by this author (server-side filtering via API) and series by this author
const [booksPage, seriesPage] = await Promise.all([
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
),
fetchAllSeries(undefined, undefined, undefined, 1, 200).catch(
fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch(
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
),
]);
const totalPages = Math.ceil(booksPage.total / limit);
// Extract unique series names from this author's books
const authorSeriesNames = new Set(
booksPage.items
.map((b) => b.series)
.filter((s): s is string => s != null && s !== "")
);
const authorSeries = seriesPage.items.filter((s) => authorSeriesNames.has(s.name));
const authorSeries = seriesPage.items;
return (
<>
@@ -95,7 +88,7 @@ export default async function AuthorDetailPage({
alt={s.name}
fill
className="object-cover"
unoptimized
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-3">

View File

@@ -2,11 +2,15 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { EditBookForm } from "../../components/EditBookForm";
import nextDynamic from "next/dynamic";
import { SafeHtml } from "../../components/SafeHtml";
import { getServerTranslations } from "../../../lib/i18n/server";
import Image from "next/image";
import Link from "next/link";
const EditBookForm = nextDynamic(
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
);
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -95,7 +99,7 @@ export default async function BookDetailPage({
alt={t("bookDetail.coverOf", { title: book.title })}
fill
className="object-cover"
unoptimized
sizes="192px"
loading="lazy"
/>
</div>

View File

@@ -18,6 +18,8 @@ export default async function BooksPage({
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
@@ -62,7 +64,7 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
items: [] as BookDto[],
total: 0,
page: 1,
@@ -91,12 +93,26 @@ export default async function BooksPage({
{ value: "read", label: t("status.read") },
];
const formatOptions = [
{ value: "", label: t("books.allFormats") },
{ value: "cbz", label: "CBZ" },
{ value: "cbr", label: "CBR" },
{ value: "pdf", label: "PDF" },
{ value: "epub", label: "EPUB" },
];
const metadataOptions = [
{ value: "", label: t("series.metadataAll") },
{ value: "linked", label: t("series.metadataLinked") },
{ value: "unlinked", label: t("series.metadataUnlinked") },
];
const sortOptions = [
{ value: "", label: t("books.sortTitle") },
{ value: "latest", label: t("books.sortLatest") },
];
const hasFilters = searchQuery || libraryId || readingStatus || sort;
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
return (
<>
@@ -117,6 +133,8 @@ export default async function BooksPage({
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
]}
/>
@@ -152,7 +170,7 @@ export default async function BooksPage({
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
unoptimized
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { BookDto, ReadingStatus } from "../../lib/api";
@@ -17,7 +17,7 @@ interface BookCardProps {
readingStatus?: ReadingStatus;
}
function BookImage({ src, alt }: { src: string; alt: string }) {
const BookImage = memo(function BookImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
@@ -51,13 +51,12 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
unoptimized
/>
</div>
);
}
});
export function BookCard({ book, readingStatus }: BookCardProps) {
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
const { t } = useTranslation();
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
const status = readingStatus ?? book.reading_status;
@@ -129,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
</div>
</Link>
);
}
});
interface BooksGridProps {
books: (BookDto & { coverUrl?: string })[];

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { FolderBrowser } from "./FolderBrowser";
import { FolderItem } from "../../lib/api";
import { Button } from "./ui";
@@ -64,14 +65,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
</div>
{/* Popup Modal */}
{isOpen && (
{isOpen && createPortal(
<>
{/* Backdrop */}
<div
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => setIsOpen(false)}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
@@ -121,7 +122,8 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
</div>
</div>
</div>
</>
</>,
document.body
)}
</div>
);

View File

@@ -54,21 +54,62 @@ export function JobsIndicator() {
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
useEffect(() => {
const fetchActiveJobs = async () => {
try {
const response = await fetch("/api/jobs/active");
if (response.ok) {
const jobs = await response.json();
setActiveJobs(jobs);
let eventSource: EventSource | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource("/api/jobs/stream");
eventSource.onmessage = (event) => {
try {
const allJobs: Job[] = JSON.parse(event.data);
const active = allJobs.filter(j =>
j.status === "running" || j.status === "pending" ||
j.status === "extracting_pages" || j.status === "generating_thumbnails"
);
setActiveJobs(active);
} catch {
// ignore malformed data
}
} catch (error) {
console.error("Failed to fetch jobs:", error);
};
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
// Reconnect after 5s on error
reconnectTimeout = setTimeout(connect, 5000);
};
};
const disconnect = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
fetchActiveJobs();
const interval = setInterval(fetchActiveJobs, 2000);
return () => clearInterval(interval);
const handleVisibilityChange = () => {
if (document.hidden) {
disconnect();
} else {
connect();
}
};
connect();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
disconnect();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// Position the popin relative to the button

View File

@@ -57,13 +57,13 @@ function getDateParts(dateStr: string): { mins: number; hours: number; useDate:
}
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const [jobs, setJobs] = useState(initialJobs);
const formatDate = (dateStr: string): string => {
const parts = getDateParts(dateStr);
if (parts.useDate) {
return parts.date.toLocaleDateString();
return parts.date.toLocaleDateString(locale);
}
if (parts.mins < 1) return t("time.justNow");
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { Button } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { useTranslation } from "../../lib/i18n/context";
@@ -24,23 +25,11 @@ export function LibraryActions({
metadataProvider,
fallbackMetadataProvider,
metadataRefreshMode,
onUpdate
}: LibraryActionsProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [saveError, setSaveError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = (formData: FormData) => {
setSaveError(null);
@@ -89,11 +78,11 @@ export function LibraryActions({
};
return (
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
<>
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
onClick={() => setIsOpen(true)}
className={isOpen ? "bg-accent" : ""}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -102,121 +91,201 @@ export function LibraryActions({
</svg>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
<form action={handleSubmit}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<input
type="checkbox"
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
{t("libraryActions.autoScan")}
</label>
</div>
{isOpen && createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => setIsOpen(false)}
/>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<input
type="checkbox"
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
{t("libraryActions.fileWatch")}
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{t("libraryActions.schedule")}</label>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
{t("libraryActions.provider")}
</label>
<select
name="metadata_provider"
defaultValue={metadataProvider || ""}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="">{t("libraryActions.default")}</option>
<option value="none">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
{/* Form */}
<form action={handleSubmit}>
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
{t("libraryActions.fallback")}
</label>
<select
name="fallback_metadata_provider"
defaultValue={fallbackMetadataProvider || ""}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
{/* Section: Indexation */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 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>
{t("libraryActions.sectionIndexation")}
</h3>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
<select
name="metadata_refresh_mode"
defaultValue={metadataRefreshMode}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
</div>
{/* Auto scan */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
{t("libraryActions.autoScan")}
</label>
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.autoScanDesc")}</p>
</div>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[130px] shrink-0"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
</div>
{saveError && (
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
{saveError}
</p>
)}
{/* File watcher */}
<div>
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
{t("libraryActions.fileWatch")}
</label>
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.fileWatchDesc")}</p>
</div>
</div>
<Button
type="submit"
size="sm"
className="w-full"
disabled={isPending}
>
{isPending ? t("libraryActions.saving") : t("common.save")}
</Button>
<hr className="border-border/40" />
{/* Section: Metadata */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t("libraryActions.sectionMetadata")}
</h3>
{/* Provider */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{metadataProvider && metadataProvider !== "none" && <ProviderIcon provider={metadataProvider} size={16} />}
{t("libraryActions.provider")}
</label>
<select
name="metadata_provider"
defaultValue={metadataProvider || ""}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">{t("libraryActions.default")}</option>
<option value="none">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.providerDesc")}</p>
</div>
{/* Fallback */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
{t("libraryActions.fallback")}
</label>
<select
name="fallback_metadata_provider"
defaultValue={fallbackMetadataProvider || ""}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.fallbackDesc")}</p>
</div>
{/* Metadata refresh */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
<select
name="metadata_refresh_mode"
defaultValue={metadataRefreshMode}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.metadataRefreshDesc")}</p>
</div>
</div>
{saveError && (
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
{saveError}
</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border/50 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
disabled={isPending}
>
{isPending ? t("libraryActions.saving") : t("common.save")}
</Button>
</div>
</form>
</div>
</form>
</div>
</div>
</>,
document.body
)}
</div>
</>
);
}

View File

@@ -18,6 +18,10 @@ const FILTER_ICONS: Record<string, string> = {
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
// Sort - arrows up/down
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
// Format - document/file
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
// Metadata - link/chain
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
};
interface FieldDef {
@@ -35,12 +39,17 @@ interface LiveSearchFormProps {
debounceMs?: number;
}
const STORAGE_KEY_PREFIX = "filters:";
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const restoredRef = useRef(false);
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
const buildUrl = useCallback((): string => {
if (!formRef.current) return basePath;
@@ -54,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
return qs ? `${basePath}?${qs}` : basePath;
}, [basePath]);
const saveFilters = useCallback(() => {
if (!formRef.current) return;
const formData = new FormData(formRef.current);
const filters: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
const str = value.toString().trim();
if (str) filters[key] = str;
}
try {
localStorage.setItem(storageKey, JSON.stringify(filters));
} catch {}
}, [storageKey]);
const navigate = useCallback((immediate: boolean) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (immediate) {
saveFilters();
router.replace(buildUrl() as any);
} else {
timerRef.current = setTimeout(() => {
saveFilters();
router.replace(buildUrl() as any);
}, debounceMs);
}
}, [router, buildUrl, debounceMs]);
}, [router, buildUrl, debounceMs, saveFilters]);
// Restore filters from localStorage on mount if URL has no filters
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
const hasUrlFilters = fields.some((f) => {
const val = searchParams.get(f.name);
return val && val.trim() !== "";
});
if (hasUrlFilters) return;
try {
const saved = localStorage.getItem(storageKey);
if (!saved) return;
const filters: Record<string, string> = JSON.parse(saved);
const fieldNames = new Set(fields.map((f) => f.name));
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (fieldNames.has(key) && value) params.set(key, value);
}
const qs = params.toString();
if (qs) {
router.replace(`${basePath}?${qs}` as any);
}
} catch {}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
@@ -85,6 +136,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
onSubmit={(e) => {
e.preventDefault();
if (timerRef.current) clearTimeout(timerRef.current);
saveFilters();
router.replace(buildUrl() as any);
}}
className="space-y-4"
@@ -145,7 +197,11 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
{hasFilters && (
<button
type="button"
onClick={() => router.replace(basePath as any)}
onClick={() => {
formRef.current?.reset();
try { localStorage.removeItem(storageKey); } catch {}
router.replace(basePath as any);
}}
className="
inline-flex items-center gap-1
h-8 px-2.5

View File

@@ -34,7 +34,8 @@ type IconName =
| "warning"
| "tag"
| "document"
| "authors";
| "authors"
| "bell";
type IconSize = "sm" | "md" | "lg" | "xl";
@@ -88,6 +89,7 @@ const icons: Record<IconName, string> = {
tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
};
const colorClasses: Partial<Record<IconName, string>> = {

View File

@@ -60,30 +60,62 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
async function triggerMetadataBatch(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (!libraryId) return;
let result;
try {
result = await startMetadataBatch(libraryId);
} catch {
// Library may have metadata disabled — ignore silently
return;
if (libraryId) {
let result;
try {
result = await startMetadataBatch(libraryId);
} catch {
// Library may have metadata disabled — ignore silently
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries — skip those with metadata disabled
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
if (lib.metadata_provider === "none") continue;
try {
const result = await startMetadataBatch(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Library may have metadata disabled or other issue — skip
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerMetadataRefresh(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (!libraryId) return;
let result;
try {
result = await startMetadataRefresh(libraryId);
} catch {
return;
if (libraryId) {
let result;
try {
result = await startMetadataRefresh(libraryId);
} catch {
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries — skip those with metadata disabled
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
if (lib.metadata_provider === "none") continue;
try {
const result = await startMetadataRefresh(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Library may have metadata disabled or no approved links — skip
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
return (
@@ -197,7 +229,6 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t("jobs.groupMetadata")}
<span className="text-xs font-normal text-muted-foreground">({t("jobs.requiresLibrary")})</span>
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerMetadataBatch}

View File

@@ -2,13 +2,21 @@ import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMe
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
import nextDynamic from "next/dynamic";
import { OffsetPagination } from "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
const EditSeriesForm = nextDynamic(
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
);
const MetadataSearchModal = nextDynamic(
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ProwlarrSearchModal = nextDynamic(
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../../lib/i18n/server";
@@ -94,7 +102,7 @@ export default async function SeriesDetailPage({
alt={t("books.coverOf", { name: displayName })}
fill
className="object-cover"
unoptimized
sizes="160px"
/>
</div>
</div>

View File

@@ -86,7 +86,7 @@ export default async function LibrarySeriesPage({
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
unoptimized
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
/>
</div>
<div className="p-3">

View File

@@ -1,9 +1,12 @@
import { revalidatePath } from "next/cache";
import Image from "next/image";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, startMetadataBatch, LibraryDto, FolderItem } from "../../lib/api";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
import type { TranslationKey } from "../../lib/i18n/fr";
import { getServerTranslations } from "../../lib/i18n/server";
import { LibraryActions } from "../components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm";
import { ProviderIcon } from "../components/ProviderIcon";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
@@ -31,18 +34,12 @@ export default async function LibrariesPage() {
listFolders().catch(() => [] as FolderItem[])
]);
const seriesCounts = await Promise.all(
libraries.map(async (lib) => {
try {
const seriesPage = await fetchSeries(lib.id);
return { id: lib.id, count: seriesPage.items.length };
} catch {
return { id: lib.id, count: 0 };
}
})
const thumbnailMap = new Map(
libraries.map(lib => [
lib.id,
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
])
);
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
async function addLibrary(formData: FormData) {
"use server";
@@ -61,35 +58,6 @@ export default async function LibrariesPage() {
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");
}
async function batchMetadataAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
try {
await startMetadataBatch(id);
} catch {
// Library may have metadata disabled — ignore silently
return;
}
revalidatePath("/libraries");
revalidatePath("/jobs");
}
return (
<>
<div className="mb-6">
@@ -100,7 +68,7 @@ export default async function LibrariesPage() {
{t("libraries.title")}
</h1>
</div>
{/* Add Library Form */}
<Card className="mb-6">
<CardHeader>
@@ -115,106 +83,139 @@ export default async function LibrariesPage() {
{/* Libraries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{libraries.map((lib) => {
const seriesCount = seriesCountMap.get(lib.id) || 0;
const thumbnails = thumbnailMap.get(lib.id) || [];
return (
<Card key={lib.id} className="flex flex-col">
<Card key={lib.id} className="flex flex-col overflow-hidden">
{/* Thumbnail fan */}
{thumbnails.length > 0 ? (
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
<Image
src={thumbnails[0]}
alt=""
fill
className="object-cover blur-xl scale-110 opacity-40"
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 flex items-end justify-center">
{thumbnails.map((url, i) => {
const count = thumbnails.length;
const mid = (count - 1) / 2;
const angle = (i - mid) * 12;
const radius = 220;
const rad = ((angle - 90) * Math.PI) / 180;
const cx = Math.cos(rad) * radius;
const cy = Math.sin(rad) * radius;
return (
<Image
key={i}
src={url}
alt=""
width={96}
height={144}
className="absolute object-cover shadow-lg"
style={{
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
transformOrigin: 'bottom center',
zIndex: count - Math.abs(Math.round(i - mid)),
bottom: '-185px',
}}
sizes="96px"
loading="lazy"
/>
);
})}
</div>
</Link>
) : (
<div className="h-8 bg-muted/10" />
)}
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{lib.name}</CardTitle>
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
</div>
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
metadataProvider={lib.metadata_provider}
fallbackMetadataProvider={lib.fallback_metadata_provider}
metadataRefreshMode={lib.metadata_refresh_mode}
/>
<div className="flex items-center gap-1">
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
metadataProvider={lib.metadata_provider}
fallbackMetadataProvider={lib.fallback_metadata_provider}
metadataRefreshMode={lib.metadata_refresh_mode}
/>
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="ghost" size="sm" formAction={removeLibrary} className="text-muted-foreground hover:text-destructive">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</form>
</div>
</div>
<code className="text-xs font-mono text-muted-foreground break-all">{lib.root_path}</code>
</CardHeader>
<CardContent className="flex-1 pt-0">
{/* Path */}
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{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/50 rounded-lg hover:bg-accent transition-colors duration-200"
<div className="grid grid-cols-2 gap-3 mb-3">
<Link
href={`/libraries/${lib.id}/books`}
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
</Link>
<Link
href={`/libraries/${lib.id}/series`}
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
<Link
href={`/libraries/${lib.id}/series`}
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
<span className="block text-2xl font-bold text-foreground">{lib.series_count}</span>
<span className="text-xs text-muted-foreground">{t("libraries.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-foreground'}`}>
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? t("libraries.auto") : t("libraries.manual")}
{/* Configuration tags */}
<div className="flex flex-wrap gap-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
lib.monitor_enabled
? 'bg-success/10 text-success'
: 'bg-muted/50 text-muted-foreground'
}`}>
<span className="text-[9px]">{lib.monitor_enabled ? '●' : '○'}</span>
{t("libraries.scanLabel", { mode: t(`monitoring.${lib.scan_mode}` as TranslationKey) })}
</span>
{lib.watcher_enabled && (
<span className="text-warning" title="Surveillance de fichiers active"></span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
lib.watcher_enabled
? 'bg-warning/10 text-warning'
: 'bg-muted/50 text-muted-foreground'
}`}>
<span>{lib.watcher_enabled ? '⚡' : '○'}</span>
<span>{t("libraries.watcherLabel")}</span>
</span>
{lib.metadata_provider && lib.metadata_provider !== "none" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 text-primary">
<ProviderIcon provider={lib.metadata_provider} size={11} />
{lib.metadata_provider.replace('_', ' ')}
</span>
)}
{lib.metadata_refresh_mode !== "manual" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
{t("libraries.metaRefreshLabel", { mode: t(`monitoring.${lib.metadata_refresh_mode}` as TranslationKey) })}
</span>
)}
{lib.monitor_enabled && lib.next_scan_at && (
<span className="text-xs text-muted-foreground ml-auto">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
</span>
)}
{lib.metadata_refresh_mode !== "manual" && lib.next_metadata_refresh_at && (
<span className="text-xs text-muted-foreground ml-auto" title={t("libraries.nextMetadataRefresh", { time: formatNextScan(lib.next_metadata_refresh_at, t("libraries.imminent")) })}>
{t("libraries.nextMetadataRefreshShort", { time: formatNextScan(lib.next_metadata_refresh_at, t("libraries.imminent")) })}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{t("libraries.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}>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{t("libraries.fullIndex")}
</Button>
</form>
{lib.metadata_provider !== "none" && (
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="secondary" size="sm" formAction={batchMetadataAction} title={t("libraries.batchMetadata")}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Button>
</form>
)}
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</form>
</div>
</CardContent>
</Card>

View File

@@ -138,7 +138,7 @@ export default async function SeriesPage({
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
unoptimized
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-3">

View File

@@ -150,11 +150,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
}
}
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general");
const tabs = [
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
];
return (
@@ -734,7 +735,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{new Date(r.created_at).toLocaleString()}
{new Date(r.created_at).toLocaleString(locale)}
</span>
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
{r.komga_url}
@@ -826,6 +827,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</CardContent>
</Card>
</>)}
{activeTab === "notifications" && (<>
{/* Telegram Notifications */}
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
</>)}
</>
);
}
@@ -1480,3 +1486,254 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
</Card>
);
}
// ---------------------------------------------------------------------------
// Telegram Notifications sub-component
// ---------------------------------------------------------------------------
const DEFAULT_EVENTS = {
scan_completed: true,
scan_failed: true,
scan_cancelled: true,
thumbnail_completed: true,
thumbnail_failed: true,
conversion_completed: true,
conversion_failed: true,
metadata_approved: true,
metadata_batch_completed: true,
metadata_batch_failed: true,
metadata_refresh_completed: true,
metadata_refresh_failed: true,
};
function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
const { t } = useTranslation();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
const [enabled, setEnabled] = useState(false);
const [events, setEvents] = useState(DEFAULT_EVENTS);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
fetch("/api/settings/telegram")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
if (data.bot_token) setBotToken(data.bot_token);
if (data.chat_id) setChatId(data.chat_id);
if (data.enabled !== undefined) setEnabled(data.enabled);
if (data.events) setEvents({ ...DEFAULT_EVENTS, ...data.events });
}
})
.catch(() => {});
}, []);
function saveTelegram(token?: string, chat?: string, en?: boolean, ev?: typeof events) {
handleUpdateSetting("telegram", {
bot_token: token ?? botToken,
chat_id: chat ?? chatId,
enabled: en ?? enabled,
events: ev ?? events,
});
}
async function handleTestConnection() {
setIsTesting(true);
setTestResult(null);
try {
const resp = await fetch("/api/telegram/test");
const data = await resp.json();
if (data.error) {
setTestResult({ success: false, message: data.error });
} else {
setTestResult(data);
}
} catch {
setTestResult({ success: false, message: "Failed to connect" });
} finally {
setIsTesting(false);
}
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="bell" size="md" />
{t("settings.telegram")}
</CardTitle>
<CardDescription>{t("settings.telegramDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Setup guide */}
<div>
<button
type="button"
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
>
<Icon name={showHelp ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.telegramHelp")}
</button>
{showHelp && (
<div className="mt-3 p-4 rounded-lg bg-muted/30 space-y-3 text-sm text-foreground">
<div>
<p className="font-medium mb-1">1. Bot Token</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpBot") }} />
</div>
<div>
<p className="font-medium mb-1">2. Chat ID</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpChat") }} />
</div>
<div>
<p className="font-medium mb-1">3. Group chat</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpGroup") }} />
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
setEnabled(e.target.checked);
saveTelegram(undefined, undefined, e.target.checked);
}}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<span className="text-sm font-medium text-foreground">{t("settings.telegramEnabled")}</span>
</div>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
<FormInput
type="password"
placeholder={t("settings.botTokenPlaceholder")}
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
onBlur={() => saveTelegram()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.chatId")}</label>
<FormInput
type="text"
placeholder={t("settings.chatIdPlaceholder")}
value={chatId}
onChange={(e) => setChatId(e.target.value)}
onBlur={() => saveTelegram()}
/>
</FormField>
</FormRow>
{/* Event toggles grouped by category */}
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-4">{t("settings.telegramEvents")}</h4>
<div className="grid grid-cols-2 gap-x-6 gap-y-5">
{([
{
category: t("settings.eventCategoryScan"),
icon: "search" as const,
items: [
{ key: "scan_completed" as const, label: t("settings.eventCompleted") },
{ key: "scan_failed" as const, label: t("settings.eventFailed") },
{ key: "scan_cancelled" as const, label: t("settings.eventCancelled") },
],
},
{
category: t("settings.eventCategoryThumbnail"),
icon: "image" as const,
items: [
{ key: "thumbnail_completed" as const, label: t("settings.eventCompleted") },
{ key: "thumbnail_failed" as const, label: t("settings.eventFailed") },
],
},
{
category: t("settings.eventCategoryConversion"),
icon: "refresh" as const,
items: [
{ key: "conversion_completed" as const, label: t("settings.eventCompleted") },
{ key: "conversion_failed" as const, label: t("settings.eventFailed") },
],
},
{
category: t("settings.eventCategoryMetadata"),
icon: "tag" as const,
items: [
{ key: "metadata_approved" as const, label: t("settings.eventLinked") },
{ key: "metadata_batch_completed" as const, label: t("settings.eventBatchCompleted") },
{ key: "metadata_batch_failed" as const, label: t("settings.eventBatchFailed") },
{ key: "metadata_refresh_completed" as const, label: t("settings.eventRefreshCompleted") },
{ key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") },
],
},
]).map(({ category, icon, items }) => (
<div key={category}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Icon name={icon} size="sm" className="text-muted-foreground" />
{category}
</p>
<div className="space-y-1">
{items.map(({ key, label }) => (
<label key={key} className="flex items-center justify-between py-1.5 cursor-pointer group">
<span className="text-sm text-foreground group-hover:text-foreground/80">{label}</span>
<div className="relative">
<input
type="checkbox"
checked={events[key]}
onChange={(e) => {
const updated = { ...events, [key]: e.target.checked };
setEvents(updated);
saveTelegram(undefined, undefined, undefined, updated);
}}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</div>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleTestConnection}
disabled={isTesting || !botToken || !chatId || !enabled}
>
{isTesting ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
{t("settings.testing")}
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
{t("settings.testConnection")}
</>
)}
</Button>
{testResult && (
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
{testResult.message}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -12,6 +12,8 @@ export type LibraryDto = {
fallback_metadata_provider: string | null;
metadata_refresh_mode: string;
next_metadata_refresh_at: string | null;
series_count: number;
thumbnail_book_ids: string[];
};
export type IndexJobDto = {
@@ -139,7 +141,7 @@ export function config() {
export async function apiFetch<T>(
path: string,
init?: RequestInit,
init?: RequestInit & { next?: { revalidate?: number; tags?: string[] } },
): Promise<T> {
const { baseUrl, token } = config();
const headers = new Headers(init?.headers || {});
@@ -148,10 +150,12 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json");
}
const { next: nextOptions, ...restInit } = init ?? {};
const res = await fetch(`${baseUrl}${path}`, {
...init,
...restInit,
headers,
cache: "no-store",
...(nextOptions ? { next: nextOptions } : { cache: "no-store" as const }),
});
if (!res.ok) {
@@ -166,7 +170,7 @@ export async function apiFetch<T>(
}
export async function fetchLibraries() {
return apiFetch<LibraryDto[]>("/libraries");
return apiFetch<LibraryDto[]>("/libraries", { next: { revalidate: 30 } });
}
export async function createLibrary(name: string, rootPath: string) {
@@ -286,6 +290,8 @@ export async function fetchBooks(
readingStatus?: string,
sort?: string,
author?: string,
format?: string,
metadataProvider?: string,
): Promise<BooksPageDto> {
const params = new URLSearchParams();
if (libraryId) params.set("library_id", libraryId);
@@ -293,6 +299,8 @@ export async function fetchBooks(
if (readingStatus) params.set("reading_status", readingStatus);
if (sort) params.set("sort", sort);
if (author) params.set("author", author);
if (format) params.set("format", format);
if (metadataProvider) params.set("metadata_provider", metadataProvider);
params.set("page", page.toString());
params.set("limit", limit.toString());
@@ -334,6 +342,7 @@ export async function fetchAllSeries(
seriesStatus?: string,
hasMissing?: boolean,
metadataProvider?: string,
author?: string,
): Promise<SeriesPageDto> {
const params = new URLSearchParams();
if (libraryId) params.set("library_id", libraryId);
@@ -343,6 +352,7 @@ export async function fetchAllSeries(
if (seriesStatus) params.set("series_status", seriesStatus);
if (hasMissing) params.set("has_missing", "true");
if (metadataProvider) params.set("metadata_provider", metadataProvider);
if (author) params.set("author", author);
params.set("page", page.toString());
params.set("limit", limit.toString());
@@ -350,7 +360,7 @@ export async function fetchAllSeries(
}
export async function fetchSeriesStatuses(): Promise<string[]> {
return apiFetch<string[]>("/series/statuses");
return apiFetch<string[]>("/series/statuses", { next: { revalidate: 300 } });
}
export async function searchBooks(
@@ -415,7 +425,7 @@ export type ThumbnailStats = {
};
export async function getSettings() {
return apiFetch<Settings>("/settings");
return apiFetch<Settings>("/settings", { next: { revalidate: 60 } });
}
export async function updateSetting(key: string, value: unknown) {
@@ -426,7 +436,7 @@ export async function updateSetting(key: string, value: unknown) {
}
export async function getCacheStats() {
return apiFetch<CacheStats>("/settings/cache/stats");
return apiFetch<CacheStats>("/settings/cache/stats", { next: { revalidate: 30 } });
}
export async function clearCache() {
@@ -436,7 +446,7 @@ export async function clearCache() {
}
export async function getThumbnailStats() {
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats", { next: { revalidate: 30 } });
}
// Status mappings
@@ -447,7 +457,7 @@ export type StatusMappingDto = {
};
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
return apiFetch<StatusMappingDto[]>("/settings/status-mappings");
return apiFetch<StatusMappingDto[]>("/settings/status-mappings", { next: { revalidate: 60 } });
}
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
@@ -552,7 +562,7 @@ export type StatsResponse = {
};
export async function fetchStats() {
return apiFetch<StatsResponse>("/stats");
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
}
// ---------------------------------------------------------------------------

View File

@@ -100,6 +100,8 @@ const en: Record<TranslationKey, string> = {
"books.noResults": "No books found for \"{{query}}\"",
"books.noBooks": "No books available",
"books.coverOf": "Cover of {{name}}",
"books.format": "Format",
"books.allFormats": "All formats",
// Series page
"series.title": "Series",
@@ -140,6 +142,9 @@ const en: Record<TranslationKey, string> = {
"libraries.imminent": "Imminent",
"libraries.nextMetadataRefresh": "Next metadata refresh: {{time}}",
"libraries.nextMetadataRefreshShort": "Meta.: {{time}}",
"libraries.scanLabel": "Scan: {{mode}}",
"libraries.watcherLabel": "File watch",
"libraries.metaRefreshLabel": "Meta refresh: {{mode}}",
"libraries.index": "Index",
"libraries.fullIndex": "Full",
"libraries.batchMetadata": "Batch metadata",
@@ -157,14 +162,22 @@ const en: Record<TranslationKey, string> = {
"librarySeries.noBooksInSeries": "No books in this series",
// Library actions
"libraryActions.autoScan": "Auto scan",
"libraryActions.fileWatch": "File watch ⚡",
"libraryActions.schedule": "📅 Schedule",
"libraryActions.settingsTitle": "Library settings",
"libraryActions.sectionIndexation": "Indexation",
"libraryActions.sectionMetadata": "Metadata",
"libraryActions.autoScan": "Scheduled scan",
"libraryActions.autoScanDesc": "Automatically scan for new and modified files",
"libraryActions.fileWatch": "Real-time file watch",
"libraryActions.fileWatchDesc": "Detect file changes instantly via filesystem events",
"libraryActions.schedule": "Frequency",
"libraryActions.provider": "Provider",
"libraryActions.fallback": "Fallback",
"libraryActions.providerDesc": "Source used to fetch series and volume metadata",
"libraryActions.fallback": "Fallback provider",
"libraryActions.fallbackDesc": "Used when the primary provider returns no results",
"libraryActions.default": "Default",
"libraryActions.none": "None",
"libraryActions.metadataRefreshSchedule": "Refresh meta.",
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
"libraryActions.saving": "Saving...",
// Library sub-page header
@@ -530,6 +543,33 @@ const en: Record<TranslationKey, string> = {
"settings.qbittorrentUsername": "Username",
"settings.qbittorrentPassword": "Password",
// Settings - Telegram Notifications
"settings.notifications": "Notifications",
"settings.telegram": "Telegram",
"settings.telegramDesc": "Receive Telegram notifications for scans, errors, and metadata linking.",
"settings.botToken": "Bot Token",
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"settings.chatId": "Chat ID",
"settings.chatIdPlaceholder": "123456789",
"settings.telegramEnabled": "Enable Telegram notifications",
"settings.telegramEvents": "Events",
"settings.eventCategoryScan": "Scans",
"settings.eventCategoryThumbnail": "Thumbnails",
"settings.eventCategoryConversion": "CBR → CBZ Conversion",
"settings.eventCategoryMetadata": "Metadata",
"settings.eventCompleted": "Completed",
"settings.eventFailed": "Failed",
"settings.eventCancelled": "Cancelled",
"settings.eventLinked": "Linked",
"settings.eventBatchCompleted": "Batch completed",
"settings.eventBatchFailed": "Batch failed",
"settings.eventRefreshCompleted": "Refresh completed",
"settings.eventRefreshFailed": "Refresh failed",
"settings.telegramHelp": "How to get the required information?",
"settings.telegramHelpBot": "Open Telegram, search for <b>@BotFather</b>, send <code>/newbot</code> and follow the instructions. Copy the token it gives you.",
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
"settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. <code>-123456789</code>).",
// Settings - Language
"settings.language": "Language",
"settings.languageDesc": "Choose the interface language",

View File

@@ -98,6 +98,8 @@ const fr = {
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
"books.noBooks": "Aucun livre disponible",
"books.coverOf": "Couverture de {{name}}",
"books.format": "Format",
"books.allFormats": "Tous les formats",
// Series page
"series.title": "Séries",
@@ -138,6 +140,9 @@ const fr = {
"libraries.imminent": "Imminent",
"libraries.nextMetadataRefresh": "Prochain rafraîchissement méta. : {{time}}",
"libraries.nextMetadataRefreshShort": "Méta. : {{time}}",
"libraries.scanLabel": "Scan : {{mode}}",
"libraries.watcherLabel": "Surveillance fichiers",
"libraries.metaRefreshLabel": "Rafraîch. méta. : {{mode}}",
"libraries.index": "Indexer",
"libraries.fullIndex": "Complet",
"libraries.batchMetadata": "Métadonnées en lot",
@@ -155,14 +160,22 @@ const fr = {
"librarySeries.noBooksInSeries": "Aucun livre dans cette série",
// Library actions
"libraryActions.autoScan": "Scan auto",
"libraryActions.fileWatch": "Surveillance fichiers ⚡",
"libraryActions.schedule": "📅 Planification",
"libraryActions.settingsTitle": "Paramètres de la bibliothèque",
"libraryActions.sectionIndexation": "Indexation",
"libraryActions.sectionMetadata": "Métadonnées",
"libraryActions.autoScan": "Scan planifié",
"libraryActions.autoScanDesc": "Scanner automatiquement les fichiers nouveaux et modifiés",
"libraryActions.fileWatch": "Surveillance en temps réel",
"libraryActions.fileWatchDesc": "Détecter les changements de fichiers instantanément",
"libraryActions.schedule": "Fréquence",
"libraryActions.provider": "Fournisseur",
"libraryActions.fallback": "Secours",
"libraryActions.providerDesc": "Source utilisée pour récupérer les métadonnées des séries",
"libraryActions.fallback": "Fournisseur de secours",
"libraryActions.fallbackDesc": "Utilisé quand le fournisseur principal ne retourne aucun résultat",
"libraryActions.default": "Par défaut",
"libraryActions.none": "Aucun",
"libraryActions.metadataRefreshSchedule": "Rafraîchir méta.",
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
"libraryActions.saving": "Enregistrement...",
// Library sub-page header
@@ -528,6 +541,33 @@ const fr = {
"settings.qbittorrentUsername": "Nom d'utilisateur",
"settings.qbittorrentPassword": "Mot de passe",
// Settings - Telegram Notifications
"settings.notifications": "Notifications",
"settings.telegram": "Telegram",
"settings.telegramDesc": "Recevoir des notifications Telegram lors des scans, erreurs et liaisons de métadonnées.",
"settings.botToken": "Bot Token",
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"settings.chatId": "Chat ID",
"settings.chatIdPlaceholder": "123456789",
"settings.telegramEnabled": "Activer les notifications Telegram",
"settings.telegramEvents": "Événements",
"settings.eventCategoryScan": "Scans",
"settings.eventCategoryThumbnail": "Miniatures",
"settings.eventCategoryConversion": "Conversion CBR → CBZ",
"settings.eventCategoryMetadata": "Métadonnées",
"settings.eventCompleted": "Terminé",
"settings.eventFailed": "Échoué",
"settings.eventCancelled": "Annulé",
"settings.eventLinked": "Liée",
"settings.eventBatchCompleted": "Batch terminé",
"settings.eventBatchFailed": "Batch échoué",
"settings.eventRefreshCompleted": "Rafraîchissement terminé",
"settings.eventRefreshFailed": "Rafraîchissement échoué",
"settings.telegramHelp": "Comment obtenir les informations ?",
"settings.telegramHelpBot": "Ouvrez Telegram, recherchez <b>@BotFather</b>, envoyez <code>/newbot</code> et suivez les instructions. Copiez le token fourni.",
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
"settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: <code>-123456789</code>).",
// Settings - Language
"settings.language": "Langue",
"settings.languageDesc": "Choisir la langue de l'interface",

View File

@@ -1,7 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
typedRoutes: true
typedRoutes: true,
images: {
minimumCacheTTL: 86400,
},
};
export default nextConfig;

View File

@@ -1,6 +1,6 @@
{
"name": "stripstream-backoffice",
"version": "1.17.0",
"version": "1.23.0",
"private": true,
"scripts": {
"dev": "next dev -p 7082",

View File

@@ -14,6 +14,7 @@ futures = "0.3"
image.workspace = true
jpeg-decoder.workspace = true
num_cpus.workspace = true
notifications = { path = "../../crates/notifications" }
parsers = { path = "../../crates/parsers" }
reqwest.workspace = true
serde.workspace = true

View File

@@ -6,13 +6,15 @@ COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
echo "fn main() {}" > apps/api/src/main.rs && \
echo "fn main() {}" > apps/indexer/src/main.rs && \
echo "" > apps/indexer/src/lib.rs && \
echo "" > crates/core/src/lib.rs && \
echo "" > crates/notifications/src/lib.rs && \
echo "" > crates/parsers/src/lib.rs
# Build dependencies only (cached as long as Cargo.toml files don't change)
@@ -25,12 +27,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY crates/core/src crates/core/src
COPY crates/notifications/src crates/notifications/src
COPY crates/parsers/src crates/parsers/src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
cargo build --release -p indexer && \
cp /app/target/release/indexer /usr/local/bin/indexer

View File

@@ -328,6 +328,7 @@ pub async fn process_job(
removed_files: 0,
errors: 0,
warnings: 0,
new_series: 0,
};
let mut total_processed_count = 0i32;

View File

@@ -14,6 +14,7 @@ use crate::{
utils,
AppState,
};
use std::collections::HashSet;
#[derive(Serialize)]
pub struct JobStats {
@@ -22,6 +23,7 @@ pub struct JobStats {
pub removed_files: usize,
pub errors: usize,
pub warnings: usize,
pub new_series: usize,
}
const BATCH_SIZE: usize = 100;
@@ -106,6 +108,18 @@ pub async fn scan_library_discovery(
HashMap::new()
};
// Track existing series names for new_series counting
let existing_series: HashSet<String> = sqlx::query_scalar(
"SELECT DISTINCT COALESCE(NULLIF(series, ''), 'unclassified') FROM books WHERE library_id = $1",
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
.into_iter()
.collect();
let mut seen_new_series: HashSet<String> = HashSet::new();
let mut seen: HashMap<String, bool> = HashMap::new();
let mut library_processed_count = 0i32;
let mut last_progress_update = std::time::Instant::now();
@@ -382,6 +396,12 @@ pub async fn scan_library_discovery(
let book_id = Uuid::new_v4();
let file_id = Uuid::new_v4();
// Track new series
let series_key = parsed.series.as_deref().unwrap_or("unclassified").to_string();
if !existing_series.contains(&series_key) && seen_new_series.insert(series_key) {
stats.new_series += 1;
}
books_to_insert.push(BookInsert {
book_id,
library_id,

View File

@@ -1,10 +1,12 @@
use std::time::Duration;
use sqlx::Row;
use tracing::{error, info, trace};
use uuid::Uuid;
use crate::{job, scheduler, watcher, AppState};
pub async fn run_worker(state: AppState, interval_seconds: u64) {
let wait = Duration::from_secs(interval_seconds.max(1));
// Cleanup stale jobs from previous runs
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
error!("[CLEANUP] Failed to cleanup stale jobs: {}", err);
@@ -34,21 +36,183 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
}
});
struct JobInfo {
job_type: String,
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
}
async fn load_job_info(
pool: &sqlx::PgPool,
job_id: Uuid,
library_id: Option<Uuid>,
) -> JobInfo {
let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1")
.bind(job_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
let (job_type, book_id): (String, Option<Uuid>) = match row {
Some(r) => (r.get("type"), r.get("book_id")),
None => ("unknown".to_string(), None),
};
let library_name: Option<String> = if let Some(lib_id) = library_id {
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(lib_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
} else {
None
};
let (book_title, thumbnail_path): (Option<String>, Option<String>) = if let Some(bid) = book_id {
let row = sqlx::query("SELECT title, thumbnail_path FROM books WHERE id = $1")
.bind(bid)
.fetch_optional(pool)
.await
.ok()
.flatten();
match row {
Some(r) => (r.get("title"), r.get("thumbnail_path")),
None => (None, None),
}
} else {
(None, None)
};
JobInfo { job_type, library_name, book_title, thumbnail_path }
}
async fn load_scan_stats(pool: &sqlx::PgPool, job_id: Uuid) -> notifications::ScanStats {
let row = sqlx::query("SELECT stats_json FROM index_jobs WHERE id = $1")
.bind(job_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
if let Some(row) = row {
if let Ok(val) = row.try_get::<serde_json::Value, _>("stats_json") {
return notifications::ScanStats {
scanned_files: val.get("scanned_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
indexed_files: val.get("indexed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
removed_files: val.get("removed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
new_series: val.get("new_series").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
errors: val.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
};
}
}
notifications::ScanStats {
scanned_files: 0,
indexed_files: 0,
removed_files: 0,
new_series: 0,
errors: 0,
}
}
fn build_completed_event(
job_type: &str,
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
stats: notifications::ScanStats,
duration_seconds: u64,
) -> notifications::NotificationEvent {
match notifications::job_type_category(job_type) {
"thumbnail" => notifications::NotificationEvent::ThumbnailCompleted {
job_type: job_type.to_string(),
library_name,
duration_seconds,
},
"conversion" => notifications::NotificationEvent::ConversionCompleted {
library_name,
book_title,
thumbnail_path,
},
_ => notifications::NotificationEvent::ScanCompleted {
job_type: job_type.to_string(),
library_name,
stats,
duration_seconds,
},
}
}
fn build_failed_event(
job_type: &str,
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
error: String,
) -> notifications::NotificationEvent {
match notifications::job_type_category(job_type) {
"thumbnail" => notifications::NotificationEvent::ThumbnailFailed {
job_type: job_type.to_string(),
library_name,
error,
},
"conversion" => notifications::NotificationEvent::ConversionFailed {
library_name,
book_title,
thumbnail_path,
error,
},
_ => notifications::NotificationEvent::ScanFailed {
job_type: job_type.to_string(),
library_name,
error,
},
}
}
loop {
match job::claim_next_job(&state.pool).await {
Ok(Some((job_id, library_id))) => {
info!("[INDEXER] Starting job {} library={:?}", job_id, library_id);
let started_at = std::time::Instant::now();
let info = load_job_info(&state.pool, job_id, library_id).await;
if let Err(err) = job::process_job(&state, job_id, library_id).await {
let err_str = err.to_string();
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
info!("[INDEXER] Job {} was cancelled by user", job_id);
// Status is already 'cancelled' in DB, don't change it
notifications::notify(
state.pool.clone(),
notifications::NotificationEvent::ScanCancelled {
job_type: info.job_type.clone(),
library_name: info.library_name.clone(),
},
);
} else {
error!("[INDEXER] Job {} failed: {}", job_id, err);
let _ = job::fail_job(&state.pool, job_id, &err_str).await;
notifications::notify(
state.pool.clone(),
build_failed_event(&info.job_type, info.library_name.clone(), info.book_title.clone(), info.thumbnail_path.clone(), err_str),
);
}
} else {
info!("[INDEXER] Job {} completed", job_id);
let stats = load_scan_stats(&state.pool, job_id).await;
notifications::notify(
state.pool.clone(),
build_completed_event(
&info.job_type,
info.library_name.clone(),
info.book_title.clone(),
info.thumbnail_path.clone(),
stats,
started_at.elapsed().as_secs(),
),
);
}
}
Ok(None) => {

View File

@@ -0,0 +1,13 @@
[package]
name = "notifications"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,513 @@
use anyhow::Result;
use serde::Deserialize;
use sqlx::PgPool;
use tracing::{info, warn};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub chat_id: String,
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_events")]
pub events: EventToggles,
}
#[derive(Debug, Deserialize)]
pub struct EventToggles {
#[serde(default = "default_true")]
pub scan_completed: bool,
#[serde(default = "default_true")]
pub scan_failed: bool,
#[serde(default = "default_true")]
pub scan_cancelled: bool,
#[serde(default = "default_true")]
pub thumbnail_completed: bool,
#[serde(default = "default_true")]
pub thumbnail_failed: bool,
#[serde(default = "default_true")]
pub conversion_completed: bool,
#[serde(default = "default_true")]
pub conversion_failed: bool,
#[serde(default = "default_true")]
pub metadata_approved: bool,
#[serde(default = "default_true")]
pub metadata_batch_completed: bool,
#[serde(default = "default_true")]
pub metadata_batch_failed: bool,
#[serde(default = "default_true")]
pub metadata_refresh_completed: bool,
#[serde(default = "default_true")]
pub metadata_refresh_failed: bool,
}
fn default_true() -> bool {
true
}
fn default_events() -> EventToggles {
EventToggles {
scan_completed: true,
scan_failed: true,
scan_cancelled: true,
thumbnail_completed: true,
thumbnail_failed: true,
conversion_completed: true,
conversion_failed: true,
metadata_approved: true,
metadata_batch_completed: true,
metadata_batch_failed: true,
metadata_refresh_completed: true,
metadata_refresh_failed: true,
}
}
/// Load the Telegram config from `app_settings` (key = "telegram").
/// Returns `None` when the row is missing, disabled, or has empty credentials.
pub async fn load_telegram_config(pool: &PgPool) -> Option<TelegramConfig> {
let row = sqlx::query_scalar::<_, serde_json::Value>(
"SELECT value FROM app_settings WHERE key = 'telegram'",
)
.fetch_optional(pool)
.await
.ok()??;
let config: TelegramConfig = serde_json::from_value(row).ok()?;
if !config.enabled || config.bot_token.is_empty() || config.chat_id.is_empty() {
return None;
}
Some(config)
}
// ---------------------------------------------------------------------------
// Telegram HTTP
// ---------------------------------------------------------------------------
fn build_client() -> Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?)
}
async fn send_telegram(config: &TelegramConfig, text: &str) -> Result<()> {
let url = format!(
"https://api.telegram.org/bot{}/sendMessage",
config.bot_token
);
let body = serde_json::json!({
"chat_id": config.chat_id,
"text": text,
"parse_mode": "HTML",
});
let resp = build_client()?.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Telegram API returned {status}: {text}");
}
Ok(())
}
async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path: &str) -> Result<()> {
let url = format!(
"https://api.telegram.org/bot{}/sendPhoto",
config.bot_token
);
let photo_bytes = tokio::fs::read(photo_path).await?;
let filename = std::path::Path::new(photo_path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mime = if filename.ends_with(".webp") {
"image/webp"
} else if filename.ends_with(".png") {
"image/png"
} else {
"image/jpeg"
};
let part = reqwest::multipart::Part::bytes(photo_bytes)
.file_name(filename)
.mime_str(mime)?;
let form = reqwest::multipart::Form::new()
.text("chat_id", config.chat_id.clone())
.text("caption", caption.to_string())
.text("parse_mode", "HTML")
.part("photo", part);
let resp = build_client()?.post(&url).multipart(form).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Telegram API returned {status}: {text}");
}
Ok(())
}
/// Send a test message. Returns the result directly (not fire-and-forget).
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
send_telegram(config, "🔔 <b>Stripstream Librarian</b>\nTest notification — connection OK!").await
}
// ---------------------------------------------------------------------------
// Notification events
// ---------------------------------------------------------------------------
pub struct ScanStats {
pub scanned_files: usize,
pub indexed_files: usize,
pub removed_files: usize,
pub new_series: usize,
pub errors: usize,
}
pub enum NotificationEvent {
// Scan jobs (rebuild, full_rebuild, rescan, scan)
ScanCompleted {
job_type: String,
library_name: Option<String>,
stats: ScanStats,
duration_seconds: u64,
},
ScanFailed {
job_type: String,
library_name: Option<String>,
error: String,
},
ScanCancelled {
job_type: String,
library_name: Option<String>,
},
// Thumbnail jobs (thumbnail_rebuild, thumbnail_regenerate)
ThumbnailCompleted {
job_type: String,
library_name: Option<String>,
duration_seconds: u64,
},
ThumbnailFailed {
job_type: String,
library_name: Option<String>,
error: String,
},
// CBR→CBZ conversion
ConversionCompleted {
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
},
ConversionFailed {
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
error: String,
},
// Metadata manual approve
MetadataApproved {
series_name: String,
provider: String,
thumbnail_path: Option<String>,
},
// Metadata batch (auto-match)
MetadataBatchCompleted {
library_name: Option<String>,
total_series: i32,
processed: i32,
},
MetadataBatchFailed {
library_name: Option<String>,
error: String,
},
// Metadata refresh
MetadataRefreshCompleted {
library_name: Option<String>,
refreshed: i32,
unchanged: i32,
errors: i32,
},
MetadataRefreshFailed {
library_name: Option<String>,
error: String,
},
}
/// Classify an indexer job_type string into the right event constructor category.
/// Returns "scan", "thumbnail", or "conversion".
pub fn job_type_category(job_type: &str) -> &'static str {
match job_type {
"thumbnail_rebuild" | "thumbnail_regenerate" => "thumbnail",
"cbr_to_cbz" => "conversion",
_ => "scan",
}
}
fn format_event(event: &NotificationEvent) -> String {
match event {
NotificationEvent::ScanCompleted {
job_type,
library_name,
stats,
duration_seconds,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds);
format!(
"📚 <b>Scan completed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
New books: {}\n\
New series: {}\n\
Files scanned: {}\n\
Removed: {}\n\
Errors: {}\n\
Duration: {duration}",
stats.indexed_files,
stats.new_series,
stats.scanned_files,
stats.removed_files,
stats.errors,
)
}
NotificationEvent::ScanFailed {
job_type,
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
"❌ <b>Scan failed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Error: {err}"
)
}
NotificationEvent::ScanCancelled {
job_type,
library_name,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"⏹ <b>Scan cancelled</b>\n\
Library: {lib}\n\
Type: {job_type}"
)
}
NotificationEvent::ThumbnailCompleted {
job_type,
library_name,
duration_seconds,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds);
format!(
"🖼 <b>Thumbnails completed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Duration: {duration}"
)
}
NotificationEvent::ThumbnailFailed {
job_type,
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
"❌ <b>Thumbnails failed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Error: {err}"
)
}
NotificationEvent::ConversionCompleted {
library_name,
book_title,
..
} => {
let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown");
format!(
"🔄 <b>CBR→CBZ conversion completed</b>\n\
Library: {lib}\n\
Book: {title}"
)
}
NotificationEvent::ConversionFailed {
library_name,
book_title,
error,
..
} => {
let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown");
let err = truncate(error, 200);
format!(
"❌ <b>CBR→CBZ conversion failed</b>\n\
Library: {lib}\n\
Book: {title}\n\
Error: {err}"
)
}
NotificationEvent::MetadataApproved {
series_name,
provider,
..
} => {
format!(
"🔗 <b>Metadata linked</b>\n\
Series: {series_name}\n\
Provider: {provider}"
)
}
NotificationEvent::MetadataBatchCompleted {
library_name,
total_series,
processed,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"🔍 <b>Metadata batch completed</b>\n\
Library: {lib}\n\
Series processed: {processed}/{total_series}"
)
}
NotificationEvent::MetadataBatchFailed {
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
"❌ <b>Metadata batch failed</b>\n\
Library: {lib}\n\
Error: {err}"
)
}
NotificationEvent::MetadataRefreshCompleted {
library_name,
refreshed,
unchanged,
errors,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"🔄 <b>Metadata refresh completed</b>\n\
Library: {lib}\n\
Updated: {refreshed}\n\
Unchanged: {unchanged}\n\
Errors: {errors}"
)
}
NotificationEvent::MetadataRefreshFailed {
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
"❌ <b>Metadata refresh failed</b>\n\
Library: {lib}\n\
Error: {err}"
)
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() > max {
format!("{}", &s[..max])
} else {
s.to_string()
}
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
let m = secs / 60;
let s = secs % 60;
format!("{m}m{s}s")
}
}
// ---------------------------------------------------------------------------
// Public entry point — fire & forget
// ---------------------------------------------------------------------------
/// Returns whether this event type is enabled in the config.
fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool {
match event {
NotificationEvent::ScanCompleted { .. } => config.events.scan_completed,
NotificationEvent::ScanFailed { .. } => config.events.scan_failed,
NotificationEvent::ScanCancelled { .. } => config.events.scan_cancelled,
NotificationEvent::ThumbnailCompleted { .. } => config.events.thumbnail_completed,
NotificationEvent::ThumbnailFailed { .. } => config.events.thumbnail_failed,
NotificationEvent::ConversionCompleted { .. } => config.events.conversion_completed,
NotificationEvent::ConversionFailed { .. } => config.events.conversion_failed,
NotificationEvent::MetadataApproved { .. } => config.events.metadata_approved,
NotificationEvent::MetadataBatchCompleted { .. } => config.events.metadata_batch_completed,
NotificationEvent::MetadataBatchFailed { .. } => config.events.metadata_batch_failed,
NotificationEvent::MetadataRefreshCompleted { .. } => config.events.metadata_refresh_completed,
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
}
}
/// Extract thumbnail path from event if present and file exists on disk.
fn event_thumbnail(event: &NotificationEvent) -> Option<&str> {
let path = match event {
NotificationEvent::ConversionCompleted { thumbnail_path, .. } => thumbnail_path.as_deref(),
NotificationEvent::ConversionFailed { thumbnail_path, .. } => thumbnail_path.as_deref(),
NotificationEvent::MetadataApproved { thumbnail_path, .. } => thumbnail_path.as_deref(),
_ => None,
};
path.filter(|p| std::path::Path::new(p).exists())
}
/// Load config + format + send in a spawned task. Errors are only logged.
pub fn notify(pool: PgPool, event: NotificationEvent) {
tokio::spawn(async move {
let config = match load_telegram_config(&pool).await {
Some(c) => c,
None => return, // disabled or not configured
};
if !is_event_enabled(&config, &event) {
return;
}
let text = format_event(&event);
let sent = if let Some(photo) = event_thumbnail(&event) {
match send_telegram_photo(&config, &text, photo).await {
Ok(()) => Ok(()),
Err(e) => {
warn!("[TELEGRAM] Photo send failed, falling back to text: {e}");
send_telegram(&config, &text).await
}
}
} else {
send_telegram(&config, &text).await
};
match sent {
Ok(()) => info!("[TELEGRAM] Notification sent"),
Err(e) => warn!("[TELEGRAM] Failed to send notification: {e}"),
}
});
}

310
docs/FEATURES.md Normal file
View File

@@ -0,0 +1,310 @@
# Stripstream Librarian — Features & Business Rules
## Libraries
### Multi-Library Management
- Create and manage multiple independent libraries, each with its own root path
- Enable/disable libraries individually
- Delete a library cascades to all its books, jobs, and metadata
### Scanning & Indexing
- **Incremental scan**: uses directory mtime tracking to skip unchanged directories
- **Full rebuild**: force re-walk all directories, ignoring cached mtimes
- **Rescan**: deep rescan to discover newly supported formats
- **Two-phase pipeline**:
- Phase 1 (Discovery): fast filename-based metadata extraction (no archive I/O)
- Phase 2 (Analysis): extract page counts, first page image from archives
### Real-Time Monitoring
- **Automatic periodic scanning**: configurable interval (default 5 seconds)
- **Filesystem watcher**: real-time detection of file changes for instant indexing
- Each can be toggled per library (`monitor_enabled`, `watcher_enabled`)
---
## Books
### Format Support
- **CBZ** (ZIP-based comic archives)
- **CBR** (RAR-based comic archives)
- **PDF**
- **EPUB**
- Automatic format detection from file extension and magic bytes
### Metadata Extraction
- **Title**: derived from filename or external metadata
- **Series**: derived from directory structure (first directory level under library root)
- **Volume**: extracted from filename with pattern detection:
- `T##` (Tome) — most common for French comics
- `Vol.##`, `Vol ##`, `Volume ##`
- `###` (standalone number)
- `-## ` (dash-separated)
- **Author(s)**: single scalar and array support
- **Page count**: extracted from archive analysis
- **Language**, **kind** (ebook, comic, bd)
### Thumbnails
- Generated from the first page of each archive
- Output format configurable: WebP (default), JPEG, PNG
- Configurable dimensions (default 300×400)
- Lazy generation: created on first access if missing
- Bulk operations: rebuild missing or regenerate all
### CBR to CBZ Conversion
- Convert RAR archives to ZIP format
- Tracked as background job with progress
---
## Series
### Automatic Aggregation
- Series derived from directory structure during scanning
- Books without series grouped as "unclassified"
### Series Metadata
- Description, publisher, start year, status (`ongoing`, `ended`, `completed`, `on_hold`, `hiatus`)
- Total volume count (from external providers)
- Authors (aggregated from books or metadata)
### Filtering & Discovery
- Filter by: series name (partial match), reading status, series status, metadata provider linkage
- Sort by: name, reading status, book count
- **Missing books detection**: identifies gaps in volume numbering within a series
---
## Reading Progress
### Per-Book Tracking
- Three states: `unread` (default), `reading`, `read`
- Current page tracking when status is `reading`
- `last_read_at` timestamp auto-updated
### Series-Level Status
- Calculated from book statuses:
- All read → series `read`
- None read → series `unread`
- Mixed → series `reading`
### Bulk Operations
- Mark entire series as read (updates all books)
---
## Search & Discovery
### Full-Text Search
- PostgreSQL-based (`ILIKE` + `pg_trgm`)
- Searches across: book titles, series names, authors (scalar and array fields), series metadata authors
- Case-insensitive partial matching
- Library-scoped filtering
### Results
- Book hits: title, authors, series, volume, language, kind
- Series hits: name, book count, read count, first book (for linking)
- Processing time included in response
---
## Authors
- Unique author aggregation from books and series metadata
- Per-author book and series count
- Searchable by name (partial match)
- Sortable by name or book count
---
## External Metadata
### Supported Providers
| Provider | Focus |
|----------|-------|
| Google Books | General books (default fallback) |
| ComicVine | Comics |
| BedéThèque | Franco-Belgian comics |
| AniList | Manga/anime |
| Open Library | General books |
### Provider Configuration
- Global default provider with library-level override
- Fallback provider if primary is unavailable
### Matching Workflow
1. **Search**: query a provider, get candidates with confidence scores
2. **Match**: link a series to an external result (status `pending`)
3. **Approve**: validate and sync metadata to series and books
4. **Reject**: discard a match
### Batch Processing
- Auto-match all series in a library via `metadata_batch` job
- Configurable confidence threshold
- Result statuses: `auto_matched`, `no_results`, `too_many_results`, `low_confidence`, `already_linked`
### Metadata Refresh
- Update approved links with latest data from providers
- Change tracking reports per series/book
- Non-destructive: only updates when provider has new data
### Field Locking
- Individual book fields can be locked to prevent external sync from overwriting manual edits
---
## External Integrations
### Komga Sync
- Import reading progress from a Komga server
- Matches local series/books by name
- Detailed sync report: matched, already read, newly marked, unmatched
### Prowlarr (Indexer Search)
- Search Prowlarr for missing volumes in a series
- Volume pattern matching against release titles
- Results: title, size, seeders/leechers, download URL, matched missing volumes
### qBittorrent
- Add torrents directly from Prowlarr search results
- Connection test endpoint
---
## Page Rendering & Caching
### Page Extraction
- Render any page from supported archive formats
- 1-indexed page numbers
### Image Processing
- Output formats: original, JPEG, PNG, WebP
- Quality parameter (1100)
- Max width parameter (12160 px)
- Configurable resampling filter: lanczos3, nearest, triangle/bilinear
- Concurrent render limit (default 8) with semaphore
### Caching
- **LRU in-memory cache**: 512 entries
- **Disk cache**: SHA256-keyed, two-level directory structure
- Cache key = hash(path + page + format + quality + width)
- Configurable cache directory and max size
- Manual cache clear via settings
---
## Background Jobs
### Job Types
| Type | Description |
|------|-------------|
| `rebuild` | Incremental scan |
| `full_rebuild` | Full filesystem rescan |
| `rescan` | Deep rescan for new formats |
| `thumbnail_rebuild` | Generate missing thumbnails |
| `thumbnail_regenerate` | Clear and regenerate all thumbnails |
| `cbr_to_cbz` | Convert RAR to ZIP |
| `metadata_batch` | Auto-match series to metadata |
| `metadata_refresh` | Update approved metadata links |
### Job Lifecycle
- Status flow: `pending``running``success` | `failed` | `cancelled`
- Intermediate statuses: `extracting_pages`, `generating_thumbnails`
- Real-time progress via **Server-Sent Events** (SSE)
- Per-file error tracking (non-fatal: job continues on errors)
- Cancellation support for pending/running jobs
### Progress Tracking
- Percentage (0100), current file, processed/total counts
- Timing: started_at, finished_at, phase2_started_at
- Stats JSON blob with job-specific metrics
---
## Authentication & Security
### Token System
- **Bootstrap token**: admin token via `API_BOOTSTRAP_TOKEN` env var
- **API tokens**: create, list, revoke with scopes
- Token format: `stl_{prefix}_{secret}` with Argon2 hashing
- Expiration dates, last usage tracking, revocation
### Access Control
- **Two scopes**: `admin` (full access) and `read` (read-only)
- Route-level middleware enforcement
- Rate limiting: configurable sliding window (default 120 req/s)
---
## Backoffice (Web UI)
### Dashboard
- Statistics cards: books, series, authors, libraries
- Donut charts: reading status breakdown, format distribution
- Bar charts: books per language
- Per-library reading progress bars
- Top series by book/page count
- Monthly addition timeline
- Metadata coverage stats
### Pages
- **Libraries**: list, create, delete, configure monitoring and metadata provider
- **Books**: global list with filtering/sorting, detail view with metadata and page rendering
- **Series**: global list, per-library view, detail with metadata management
- **Authors**: list with book/series counts, detail with author's books
- **Jobs**: history, live progress via SSE, error details
- **Tokens**: create, list, revoke API tokens
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent)
### Interactive Features
- Real-time search with suggestions
- Metadata search and matching modals
- Prowlarr search modal for missing volumes
- Folder browser/picker for library paths
- Book/series editing forms
- Quick reading status toggles
- CBR to CBZ conversion trigger
---
## API
### Documentation
- OpenAPI/Swagger UI available at `/swagger-ui`
- Health check (`/health`), readiness (`/ready`), Prometheus metrics (`/metrics`)
### Public Endpoints (no auth)
- `GET /health`, `GET /ready`, `GET /metrics`, `GET /swagger-ui`
### Read Endpoints (read scope)
- Libraries, books, series, authors listing and detail
- Book pages and thumbnails
- Reading progress get/update
- Full-text search, collection statistics
### Admin Endpoints (admin scope)
- Library CRUD and configuration
- Book metadata editing, CBR conversion
- Series metadata editing
- Indexing job management (trigger, cancel, stream)
- API token management
- Metadata operations (search, match, approve, reject, batch, refresh)
- External integrations (Prowlarr, qBittorrent, Komga)
- Application settings and cache management
---
## Database
### Key Design Decisions
- PostgreSQL with `pg_trgm` for full-text search (no external search engine)
- All deletions cascade from libraries
- Unique constraints: file paths, token prefixes, metadata links (library + series + provider)
- Directory mtime caching for incremental scan optimization
- Connection pool: 10 (API), 20 (indexer)
### Archive Resilience
- CBZ: fallback streaming reader if central directory corrupted
- CBR: RAR extraction via system `unar`, fallback to CBZ parsing
- PDF: `pdfinfo` for page count, `pdftoppm` for rendering
- EPUB: ZIP-based extraction
- FD exhaustion detection: aborts if too many consecutive IO errors

View File

@@ -0,0 +1,3 @@
INSERT INTO app_settings (key, value) VALUES
('telegram', '{"bot_token": "", "chat_id": "", "enabled": false, "events": {"job_completed": true, "job_failed": true, "job_cancelled": true, "metadata_approved": true}}')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,8 @@
-- Update telegram events from 4 generic toggles to 12 granular toggles
UPDATE app_settings
SET value = jsonb_set(
value,
'{events}',
'{"scan_completed": true, "scan_failed": true, "scan_cancelled": true, "thumbnail_completed": true, "thumbnail_failed": true, "conversion_completed": true, "conversion_failed": true, "metadata_approved": true, "metadata_batch_completed": true, "metadata_batch_failed": true, "metadata_refresh_completed": true, "metadata_refresh_failed": true}'::jsonb
)
WHERE key = 'telegram';