Compare commits
30 Commits
ed7665248e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 766e3a01b2 | |||
| 626e2e035d | |||
| cfd2321db2 | |||
| 1b715033ce | |||
| 81d1586501 | |||
| bd74c9e3e3 | |||
| 41228430cf | |||
| 6a4ba06fac | |||
| e5c3542d3f | |||
| 24516f1069 | |||
| 5383cdef60 | |||
| be5c3f7a34 | |||
| caa9922ff9 | |||
| 135f000c71 | |||
| d9e50a4235 | |||
| 5f6eb5a5cb | |||
| 41c77fca2e | |||
| 49621f3fb1 | |||
| 6df743b2e6 | |||
| edfefc0128 | |||
| b0185abefe | |||
| b9e54cbfd8 | |||
| 3f0bd783cd | |||
| fc8856c83f | |||
| bd09f3d943 | |||
| 1f434c3d67 | |||
| 4972a403df | |||
| 629708cdd0 | |||
| 560087a897 | |||
| 27f553b005 |
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.16.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -76,6 +76,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
"lru",
|
"lru",
|
||||||
|
"notifications",
|
||||||
"parsers",
|
"parsers",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -1232,7 +1233,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.16.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1240,6 +1241,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
|
"notifications",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"parsers",
|
"parsers",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1663,6 +1665,19 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notifications"
|
||||||
|
version = "1.23.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1771,7 +1786,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.16.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2270,6 +2285,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2278,6 +2294,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -2906,7 +2923,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.16.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ members = [
|
|||||||
"apps/api",
|
"apps/api",
|
||||||
"apps/indexer",
|
"apps/indexer",
|
||||||
"crates/core",
|
"crates/core",
|
||||||
|
"crates/notifications",
|
||||||
"crates/parsers",
|
"crates/parsers",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.16.0"
|
version = "1.23.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
jpeg-decoder = "0.3"
|
jpeg-decoder = "0.3"
|
||||||
lru = "0.12"
|
lru = "0.12"
|
||||||
rayon = "1.10"
|
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"
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -81,28 +81,58 @@ The backoffice will be available at http://localhost:7082
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Libraries Management
|
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||||
- Create and manage multiple libraries
|
|
||||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
|
||||||
- Real-time file watcher for instant indexing
|
|
||||||
- Full and incremental rebuild options
|
|
||||||
|
|
||||||
### Books Management
|
### Libraries
|
||||||
- Support for CBZ, CBR, and PDF formats
|
- Multi-library management with per-library configuration
|
||||||
- Automatic metadata extraction
|
- Incremental and full scanning, real-time filesystem watcher
|
||||||
- Series and volume detection
|
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
|
||||||
- Full-text search powered by PostgreSQL
|
|
||||||
|
|
||||||
### Jobs Monitoring
|
### Books & Series
|
||||||
- Real-time job progress tracking
|
- **Formats**: CBZ, CBR, PDF, EPUB
|
||||||
- Detailed statistics (scanned, indexed, removed, errors)
|
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
|
||||||
- Job history and logs
|
- Series aggregation with missing volume detection
|
||||||
- Cancel pending jobs
|
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
|
||||||
|
- CBR → CBZ conversion
|
||||||
|
|
||||||
### Search
|
### Reading Progress
|
||||||
- Full-text search across titles, authors, and series
|
- Per-book tracking: unread / reading / read with current page
|
||||||
- Library filtering
|
- Series-level aggregated reading status
|
||||||
- Real-time suggestions
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ futures = "0.3"
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
jpeg-decoder.workspace = true
|
jpeg-decoder.workspace = true
|
||||||
lru.workspace = true
|
lru.workspace = true
|
||||||
|
notifications = { path = "../../crates/notifications" }
|
||||||
stripstream-core = { path = "../../crates/core" }
|
stripstream-core = { path = "../../crates/core" }
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
|||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY crates/core/Cargo.toml crates/core/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
|
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/api/src/main.rs && \
|
||||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||||
echo "" > apps/indexer/src/lib.rs && \
|
echo "" > apps/indexer/src/lib.rs && \
|
||||||
echo "" > crates/core/src/lib.rs && \
|
echo "" > crates/core/src/lib.rs && \
|
||||||
|
echo "" > crates/notifications/src/lib.rs && \
|
||||||
echo "" > crates/parsers/src/lib.rs
|
echo "" > crates/parsers/src/lib.rs
|
||||||
|
|
||||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
# 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/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
|
COPY crates/notifications/src crates/notifications/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--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 && \
|
cargo build --release -p api && \
|
||||||
cp /app/target/release/api /usr/local/bin/api
|
cp /app/target/release/api /usr/local/bin/api
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub async fn list_authors(
|
|||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(|s| format!("%{s}%"));
|
.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!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH all_authors AS (
|
WITH all_authors AS (
|
||||||
@@ -79,18 +79,21 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{{}}'
|
||||||
),
|
),
|
||||||
filtered AS (
|
filtered AS (
|
||||||
SELECT name FROM all_authors
|
SELECT name FROM all_authors
|
||||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||||
),
|
),
|
||||||
counted AS (
|
book_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
f.name,
|
f.name AS author_name,
|
||||||
COUNT(DISTINCT b.id) AS book_count,
|
COUNT(DISTINCT b.id) AS book_count
|
||||||
COUNT(DISTINCT NULLIF(b.series, '')) AS series_count
|
|
||||||
FROM filtered f
|
FROM filtered f
|
||||||
JOIN books b ON (
|
LEFT JOIN books b ON (
|
||||||
f.name = ANY(
|
f.name = ANY(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(b.authors, '{{}}'),
|
NULLIF(b.authors, '{{}}'),
|
||||||
@@ -99,9 +102,24 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
GROUP BY f.name
|
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
|
SELECT
|
||||||
FROM counted
|
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}
|
ORDER BY {order_clause}
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#
|
"#
|
||||||
@@ -116,6 +134,10 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{}'
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM all_authors
|
FROM all_authors
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ pub struct RebuildRequest {
|
|||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
#[schema(value_type = Option<bool>, example = false)]
|
#[schema(value_type = Option<bool>, example = false)]
|
||||||
pub full: Option<bool>,
|
pub full: Option<bool>,
|
||||||
|
/// Deep rescan: clears directory mtimes to force re-walking all directories,
|
||||||
|
/// discovering newly supported formats without deleting existing data.
|
||||||
|
#[schema(value_type = Option<bool>, example = false)]
|
||||||
|
pub rescan: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
|
|||||||
) -> Result<Json<IndexJobResponse>, ApiError> {
|
) -> Result<Json<IndexJobResponse>, ApiError> {
|
||||||
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
||||||
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
|
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
|
||||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
let is_rescan = payload.as_ref().and_then(|p| p.0.rescan).unwrap_or(false);
|
||||||
|
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub struct LibraryResponse {
|
|||||||
pub metadata_refresh_mode: String,
|
pub metadata_refresh_mode: String,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
|
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)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -44,14 +48,27 @@ pub struct CreateLibraryRequest {
|
|||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<LibraryResponse>),
|
(status = 200, body = Vec<LibraryResponse>),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
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 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"
|
FROM libraries l ORDER BY l.created_at DESC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.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"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
|
series_count: row.get("series_count"),
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
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"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -120,6 +139,7 @@ pub async fn create_library(
|
|||||||
root_path,
|
root_path,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
book_count: 0,
|
book_count: 0,
|
||||||
|
series_count: 0,
|
||||||
monitor_enabled: false,
|
monitor_enabled: false,
|
||||||
scan_mode: "manual".to_string(),
|
scan_mode: "manual".to_string(),
|
||||||
next_scan_at: None,
|
next_scan_at: None,
|
||||||
@@ -128,6 +148,7 @@ pub async fn create_library(
|
|||||||
fallback_metadata_provider: None,
|
fallback_metadata_provider: None,
|
||||||
metadata_refresh_mode: "manual".to_string(),
|
metadata_refresh_mode: "manual".to_string(),
|
||||||
next_metadata_refresh_at: None,
|
next_metadata_refresh_at: None,
|
||||||
|
thumbnail_book_ids: vec![],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
|
|||||||
(status = 200, body = IndexJobResponse),
|
(status = 200, body = IndexJobResponse),
|
||||||
(status = 404, description = "Library not found"),
|
(status = 404, description = "Library not found"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
@@ -219,7 +239,8 @@ pub async fn scan_library(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
|
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
|
||||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
let is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false);
|
||||||
|
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||||
|
|
||||||
// Create indexing job for this library
|
// Create indexing job for this library
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -336,12 +357,29 @@ pub async fn update_monitoring(
|
|||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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 {
|
Ok(Json(LibraryResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count,
|
book_count,
|
||||||
|
series_count,
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
@@ -350,6 +388,7 @@ pub async fn update_monitoring(
|
|||||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,12 +441,29 @@ pub async fn update_metadata_provider(
|
|||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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 {
|
Ok(Json(LibraryResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count,
|
book_count,
|
||||||
|
series_count,
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
@@ -416,5 +472,6 @@ pub async fn update_metadata_provider(
|
|||||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ mod prowlarr;
|
|||||||
mod qbittorrent;
|
mod qbittorrent;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod series;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod telegram;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
|
||||||
@@ -86,14 +88,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let admin_routes = Router::new()
|
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", 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/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||||
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||||
.route("/books/:id/convert", axum::routing::post(books::convert_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/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
||||||
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
||||||
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
.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("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||||
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||||
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
.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/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
@@ -133,18 +135,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let read_routes = Router::new()
|
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", 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", get(books::get_book))
|
||||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.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("/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", get(series::list_series))
|
||||||
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
|
||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(series::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(series::ongoing_series))
|
||||||
.route("/series/statuses", get(books::series_statuses))
|
.route("/series/statuses", get(series::series_statuses))
|
||||||
.route("/series/provider-statuses", get(books::provider_statuses))
|
.route("/series/provider-statuses", get(series::provider_statuses))
|
||||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
.route("/authors", get(authors::list_authors))
|
.route("/authors", get(authors::list_authors))
|
||||||
.route("/stats", get(stats::get_stats))
|
.route("/stats", get(stats::get_stats))
|
||||||
|
|||||||
@@ -369,6 +369,26 @@ pub async fn approve_metadata(
|
|||||||
.await?;
|
.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 {
|
Ok(Json(ApproveResponse {
|
||||||
status: "approved".to_string(),
|
status: "approved".to_string(),
|
||||||
report,
|
report,
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ pub async fn start_batch(
|
|||||||
|
|
||||||
// Spawn the background processing task
|
// Spawn the background processing task
|
||||||
let pool = state.pool.clone();
|
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 {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
||||||
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
||||||
@@ -134,6 +140,13 @@ pub async fn start_batch(
|
|||||||
.bind(e.to_string())
|
.bind(e.to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.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");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ pub async fn start_refresh(
|
|||||||
|
|
||||||
// Spawn the background processing task
|
// Spawn the background processing task
|
||||||
let pool = state.pool.clone();
|
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 {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||||
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||||
@@ -143,6 +149,13 @@ pub async fn start_refresh(
|
|||||||
.bind(e.to_string())
|
.bind(e.to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.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");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::update_reading_progress,
|
crate::reading_progress::update_reading_progress,
|
||||||
crate::reading_progress::mark_series_read,
|
crate::reading_progress::mark_series_read,
|
||||||
crate::books::get_thumbnail,
|
crate::books::get_thumbnail,
|
||||||
crate::books::list_series,
|
crate::series::list_series,
|
||||||
crate::books::list_all_series,
|
crate::series::list_all_series,
|
||||||
crate::books::ongoing_series,
|
crate::series::ongoing_series,
|
||||||
crate::books::ongoing_books,
|
crate::series::ongoing_books,
|
||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
crate::books::update_book,
|
crate::books::update_book,
|
||||||
crate::books::get_series_metadata,
|
crate::series::get_series_metadata,
|
||||||
crate::books::update_series,
|
crate::series::update_series,
|
||||||
crate::pages::get_page,
|
crate::pages::get_page,
|
||||||
crate::search::search_books,
|
crate::search::search_books,
|
||||||
crate::index_jobs::enqueue_rebuild,
|
crate::index_jobs::enqueue_rebuild,
|
||||||
@@ -35,6 +35,7 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::delete_library,
|
crate::libraries::delete_library,
|
||||||
crate::libraries::scan_library,
|
crate::libraries::scan_library,
|
||||||
crate::libraries::update_monitoring,
|
crate::libraries::update_monitoring,
|
||||||
|
crate::libraries::update_metadata_provider,
|
||||||
crate::tokens::list_tokens,
|
crate::tokens::list_tokens,
|
||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
@@ -54,8 +55,8 @@ use utoipa::OpenApi;
|
|||||||
crate::metadata::get_metadata_links,
|
crate::metadata::get_metadata_links,
|
||||||
crate::metadata::get_missing_books,
|
crate::metadata::get_missing_books,
|
||||||
crate::metadata::delete_metadata_link,
|
crate::metadata::delete_metadata_link,
|
||||||
crate::books::series_statuses,
|
crate::series::series_statuses,
|
||||||
crate::books::provider_statuses,
|
crate::series::provider_statuses,
|
||||||
crate::settings::list_status_mappings,
|
crate::settings::list_status_mappings,
|
||||||
crate::settings::upsert_status_mapping,
|
crate::settings::upsert_status_mapping,
|
||||||
crate::settings::delete_status_mapping,
|
crate::settings::delete_status_mapping,
|
||||||
@@ -63,6 +64,14 @@ use utoipa::OpenApi;
|
|||||||
crate::prowlarr::test_prowlarr,
|
crate::prowlarr::test_prowlarr,
|
||||||
crate::qbittorrent::add_torrent,
|
crate::qbittorrent::add_torrent,
|
||||||
crate::qbittorrent::test_qbittorrent,
|
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(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -74,14 +83,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::UpdateReadingProgressRequest,
|
crate::reading_progress::UpdateReadingProgressRequest,
|
||||||
crate::reading_progress::MarkSeriesReadRequest,
|
crate::reading_progress::MarkSeriesReadRequest,
|
||||||
crate::reading_progress::MarkSeriesReadResponse,
|
crate::reading_progress::MarkSeriesReadResponse,
|
||||||
crate::books::SeriesItem,
|
crate::series::SeriesItem,
|
||||||
crate::books::SeriesPage,
|
crate::series::SeriesPage,
|
||||||
crate::books::ListAllSeriesQuery,
|
crate::series::ListAllSeriesQuery,
|
||||||
crate::books::OngoingQuery,
|
crate::series::OngoingQuery,
|
||||||
crate::books::UpdateBookRequest,
|
crate::books::UpdateBookRequest,
|
||||||
crate::books::SeriesMetadata,
|
crate::series::SeriesMetadata,
|
||||||
crate::books::UpdateSeriesRequest,
|
crate::series::UpdateSeriesRequest,
|
||||||
crate::books::UpdateSeriesResponse,
|
crate::series::UpdateSeriesResponse,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
crate::search::SearchResponse,
|
crate::search::SearchResponse,
|
||||||
@@ -96,6 +105,7 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::LibraryResponse,
|
crate::libraries::LibraryResponse,
|
||||||
crate::libraries::CreateLibraryRequest,
|
crate::libraries::CreateLibraryRequest,
|
||||||
crate::libraries::UpdateMonitoringRequest,
|
crate::libraries::UpdateMonitoringRequest,
|
||||||
|
crate::libraries::UpdateMetadataProviderRequest,
|
||||||
crate::tokens::CreateTokenRequest,
|
crate::tokens::CreateTokenRequest,
|
||||||
crate::tokens::TokenResponse,
|
crate::tokens::TokenResponse,
|
||||||
crate::tokens::CreatedTokenResponse,
|
crate::tokens::CreatedTokenResponse,
|
||||||
@@ -137,7 +147,16 @@ use utoipa::OpenApi;
|
|||||||
crate::prowlarr::ProwlarrRelease,
|
crate::prowlarr::ProwlarrRelease,
|
||||||
crate::prowlarr::ProwlarrCategory,
|
crate::prowlarr::ProwlarrCategory,
|
||||||
crate::prowlarr::ProwlarrSearchResponse,
|
crate::prowlarr::ProwlarrSearchResponse,
|
||||||
|
crate::prowlarr::MissingVolumeInput,
|
||||||
crate::prowlarr::ProwlarrTestResponse,
|
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,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -145,11 +164,16 @@ use utoipa::OpenApi;
|
|||||||
("Bearer" = [])
|
("Bearer" = [])
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "authors", description = "Author browsing and listing"),
|
(name = "books", description = "Book browsing, details and management"),
|
||||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
(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 = "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 = "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 = "tokens", description = "API token management (Admin only)"),
|
||||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||||
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub struct SearchResponse {
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/search",
|
path = "/search",
|
||||||
tag = "books",
|
tag = "search",
|
||||||
params(
|
params(
|
||||||
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
|
|||||||
1028
apps/api/src/series.rs
Normal file
1028
apps/api/src/series.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ pub struct StatsResponse {
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/stats",
|
path = "/stats",
|
||||||
tag = "books",
|
tag = "stats",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = StatsResponse),
|
(status = 200, body = StatsResponse),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
|
|||||||
46
apps/api/src/telegram.rs
Normal file
46
apps/api/src/telegram.rs
Normal 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}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
controller.enqueue(new TextEncoder().encode(""));
|
controller.enqueue(new TextEncoder().encode(""));
|
||||||
|
|
||||||
let lastData: string | null = null;
|
let lastData: string | null = null;
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
@@ -25,51 +26,52 @@ export async function GET(request: NextRequest) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const dataStr = JSON.stringify(data);
|
const dataStr = JSON.stringify(data);
|
||||||
|
|
||||||
// Send if data changed
|
// Send only if data changed
|
||||||
if (dataStr !== lastData && isActive) {
|
if (dataStr !== lastData && isActive) {
|
||||||
lastData = dataStr;
|
lastData = dataStr;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Controller closed, ignore
|
|
||||||
isActive = false;
|
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) {
|
} catch (error) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
consecutiveErrors++;
|
consecutiveErrors++;
|
||||||
// Only log first failure and every 30th to avoid spam
|
|
||||||
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
||||||
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
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();
|
await fetchJobs();
|
||||||
|
|
||||||
// Poll every 2 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (!isActive) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchJobs();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
request.signal.addEventListener("abort", () => {
|
request.signal.addEventListener("abort", () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
clearInterval(interval);
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
controller.close();
|
controller.close();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
|
|||||||
12
apps/backoffice/app/api/telegram/test/route.ts
Normal file
12
apps/backoffice/app/api/telegram/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,26 +21,19 @@ export default async function AuthorDetailPage({
|
|||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
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([
|
const [booksPage, seriesPage] = await Promise.all([
|
||||||
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||||
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
() => ({ 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
|
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
// Extract unique series names from this author's books
|
const authorSeries = seriesPage.items;
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -95,7 +88,7 @@ export default async function AuthorDetailPage({
|
|||||||
alt={s.name}
|
alt={s.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro
|
|||||||
import { BookPreview } from "../../components/BookPreview";
|
import { BookPreview } from "../../components/BookPreview";
|
||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "../../components/ConvertButton";
|
||||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||||
import { EditBookForm } from "../../components/EditBookForm";
|
import nextDynamic from "next/dynamic";
|
||||||
import { SafeHtml } from "../../components/SafeHtml";
|
import { SafeHtml } from "../../components/SafeHtml";
|
||||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EditBookForm = nextDynamic(
|
||||||
|
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
|
||||||
|
);
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -95,7 +99,7 @@ export default async function BookDetailPage({
|
|||||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="192px"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default async function BooksPage({
|
|||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
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 sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
@@ -62,7 +64,7 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} 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[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -91,12 +93,26 @@ export default async function BooksPage({
|
|||||||
{ value: "read", label: t("status.read") },
|
{ 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 = [
|
const sortOptions = [
|
||||||
{ value: "", label: t("books.sortTitle") },
|
{ value: "", label: t("books.sortTitle") },
|
||||||
{ value: "latest", label: t("books.sortLatest") },
|
{ value: "latest", label: t("books.sortLatest") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,6 +133,8 @@ export default async function BooksPage({
|
|||||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
{ 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 },
|
{ 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 })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||||
@@ -17,7 +17,7 @@ interface BookCardProps {
|
|||||||
readingStatus?: ReadingStatus;
|
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 [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = 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"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
onError={() => setHasError(true)}
|
onError={() => setHasError(true)}
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||||
const status = readingStatus ?? book.reading_status;
|
const status = readingStatus ?? book.reading_status;
|
||||||
@@ -129,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface BooksGridProps {
|
interface BooksGridProps {
|
||||||
books: (BookDto & { coverUrl?: string })[];
|
books: (BookDto & { coverUrl?: string })[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { FolderBrowser } from "./FolderBrowser";
|
import { FolderBrowser } from "./FolderBrowser";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
@@ -64,14 +65,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Popup Modal */}
|
{/* Popup Modal */}
|
||||||
{isOpen && (
|
{isOpen && createPortal(
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,21 +54,62 @@ export function JobsIndicator() {
|
|||||||
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchActiveJobs = async () => {
|
let eventSource: EventSource | null = null;
|
||||||
try {
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const response = await fetch("/api/jobs/active");
|
|
||||||
if (response.ok) {
|
const connect = () => {
|
||||||
const jobs = await response.json();
|
if (eventSource) {
|
||||||
setActiveJobs(jobs);
|
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 handleVisibilityChange = () => {
|
||||||
const interval = setInterval(fetchActiveJobs, 2000);
|
if (document.hidden) {
|
||||||
return () => clearInterval(interval);
|
disconnect();
|
||||||
|
} else {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Position the popin relative to the button
|
// Position the popin relative to the button
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ function getDateParts(dateStr: string): { mins: number; hours: number; useDate:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||||
const { t } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const [jobs, setJobs] = useState(initialJobs);
|
const [jobs, setJobs] = useState(initialJobs);
|
||||||
|
|
||||||
const formatDate = (dateStr: string): string => {
|
const formatDate = (dateStr: string): string => {
|
||||||
const parts = getDateParts(dateStr);
|
const parts = getDateParts(dateStr);
|
||||||
if (parts.useDate) {
|
if (parts.useDate) {
|
||||||
return parts.date.toLocaleDateString();
|
return parts.date.toLocaleDateString(locale);
|
||||||
}
|
}
|
||||||
if (parts.mins < 1) return t("time.justNow");
|
if (parts.mins < 1) return t("time.justNow");
|
||||||
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });
|
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { Button } from "../components/ui";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
@@ -24,23 +25,11 @@ export function LibraryActions({
|
|||||||
metadataProvider,
|
metadataProvider,
|
||||||
fallbackMetadataProvider,
|
fallbackMetadataProvider,
|
||||||
metadataRefreshMode,
|
metadataRefreshMode,
|
||||||
onUpdate
|
|
||||||
}: LibraryActionsProps) {
|
}: LibraryActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
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) => {
|
const handleSubmit = (formData: FormData) => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -89,11 +78,11 @@ export function LibraryActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={isOpen ? "bg-accent" : ""}
|
className={isOpen ? "bg-accent" : ""}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -102,121 +91,201 @@ export function LibraryActions({
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && createPortal(
|
||||||
<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}>
|
{/* Backdrop */}
|
||||||
<div className="space-y-4">
|
<div
|
||||||
<div className="flex items-center justify-between">
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
onClick={() => setIsOpen(false)}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Modal */}
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
<input
|
<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">
|
||||||
type="checkbox"
|
{/* Header */}
|
||||||
name="watcher_enabled"
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||||
value="true"
|
<div className="flex items-center gap-2.5">
|
||||||
defaultChecked={watcherEnabled}
|
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
<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" />
|
||||||
{t("libraryActions.fileWatch")}
|
</svg>
|
||||||
</label>
|
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<div className="flex items-center justify-between">
|
type="button"
|
||||||
<label className="text-sm font-medium text-foreground">{t("libraryActions.schedule")}</label>
|
onClick={() => setIsOpen(false)}
|
||||||
<select
|
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
||||||
name="scan_mode"
|
|
||||||
defaultValue={scanMode}
|
|
||||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
|
||||||
>
|
>
|
||||||
<option value="manual">{t("monitoring.manual")}</option>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<option value="daily">{t("monitoring.daily")}</option>
|
</svg>
|
||||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Form */}
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
<form action={handleSubmit}>
|
||||||
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Section: Indexation */}
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
<div className="space-y-5">
|
||||||
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||||
{t("libraryActions.fallback")}
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</label>
|
<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" />
|
||||||
<select
|
</svg>
|
||||||
name="fallback_metadata_provider"
|
{t("libraryActions.sectionIndexation")}
|
||||||
defaultValue={fallbackMetadataProvider || ""}
|
</h3>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Auto scan */}
|
||||||
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<select
|
<div className="flex-1">
|
||||||
name="metadata_refresh_mode"
|
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||||
defaultValue={metadataRefreshMode}
|
<input
|
||||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
type="checkbox"
|
||||||
>
|
name="monitor_enabled"
|
||||||
<option value="manual">{t("monitoring.manual")}</option>
|
value="true"
|
||||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
defaultChecked={monitorEnabled}
|
||||||
<option value="daily">{t("monitoring.daily")}</option>
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
/>
|
||||||
</select>
|
{t("libraryActions.autoScan")}
|
||||||
</div>
|
</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 && (
|
{/* File watcher */}
|
||||||
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
<div>
|
||||||
{saveError}
|
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||||
</p>
|
<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
|
<hr className="border-border/40" />
|
||||||
type="submit"
|
|
||||||
size="sm"
|
{/* Section: Metadata */}
|
||||||
className="w-full"
|
<div className="space-y-5">
|
||||||
disabled={isPending}
|
<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">
|
||||||
{isPending ? t("libraryActions.saving") : t("common.save")}
|
<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" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
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 - arrows up/down
|
||||||
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
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 {
|
interface FieldDef {
|
||||||
@@ -35,12 +39,17 @@ interface LiveSearchFormProps {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = "filters:";
|
||||||
|
|
||||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
|
||||||
|
|
||||||
const buildUrl = useCallback((): string => {
|
const buildUrl = useCallback((): string => {
|
||||||
if (!formRef.current) return basePath;
|
if (!formRef.current) return basePath;
|
||||||
@@ -54,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
return qs ? `${basePath}?${qs}` : basePath;
|
return qs ? `${basePath}?${qs}` : basePath;
|
||||||
}, [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) => {
|
const navigate = useCallback((immediate: boolean) => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
} else {
|
} else {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
}, debounceMs);
|
}, 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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -85,6 +136,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -145,7 +197,11 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.replace(basePath as any)}
|
onClick={() => {
|
||||||
|
formRef.current?.reset();
|
||||||
|
try { localStorage.removeItem(storageKey); } catch {}
|
||||||
|
router.replace(basePath as any);
|
||||||
|
}}
|
||||||
className="
|
className="
|
||||||
inline-flex items-center gap-1
|
inline-flex items-center gap-1
|
||||||
h-8 px-2.5
|
h-8 px-2.5
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
|||||||
// Job type badge
|
// Job type badge
|
||||||
const jobTypeVariants: Record<string, BadgeVariant> = {
|
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||||
rebuild: "primary",
|
rebuild: "primary",
|
||||||
|
rescan: "primary",
|
||||||
full_rebuild: "warning",
|
full_rebuild: "warning",
|
||||||
thumbnail_rebuild: "secondary",
|
thumbnail_rebuild: "secondary",
|
||||||
thumbnail_regenerate: "warning",
|
thumbnail_regenerate: "warning",
|
||||||
@@ -109,6 +110,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
|||||||
const variant = jobTypeVariants[key] || "default";
|
const variant = jobTypeVariants[key] || "default";
|
||||||
const jobTypeLabels: Record<string, string> = {
|
const jobTypeLabels: Record<string, string> = {
|
||||||
rebuild: t("jobType.rebuild"),
|
rebuild: t("jobType.rebuild"),
|
||||||
|
rescan: t("jobType.rescan"),
|
||||||
full_rebuild: t("jobType.full_rebuild"),
|
full_rebuild: t("jobType.full_rebuild"),
|
||||||
thumbnail_rebuild: t("jobType.thumbnail_rebuild"),
|
thumbnail_rebuild: t("jobType.thumbnail_rebuild"),
|
||||||
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
|
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ type IconName =
|
|||||||
| "warning"
|
| "warning"
|
||||||
| "tag"
|
| "tag"
|
||||||
| "document"
|
| "document"
|
||||||
| "authors";
|
| "authors"
|
||||||
|
| "bell";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
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",
|
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",
|
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",
|
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>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
description: t("jobType.full_rebuildDesc"),
|
description: t("jobType.full_rebuildDesc"),
|
||||||
isThumbnailOnly: false,
|
isThumbnailOnly: false,
|
||||||
},
|
},
|
||||||
|
rescan: {
|
||||||
|
label: t("jobType.rescanLabel"),
|
||||||
|
description: t("jobType.rescanDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
thumbnail_rebuild: {
|
thumbnail_rebuild: {
|
||||||
label: t("jobType.thumbnail_rebuildLabel"),
|
label: t("jobType.thumbnail_rebuildLabel"),
|
||||||
description: t("jobType.thumbnail_rebuildDesc"),
|
description: t("jobType.thumbnail_rebuildDesc"),
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerRescan(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
const result = await rebuildIndex(libraryId || undefined, false, true);
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function triggerThumbnailsRebuild(formData: FormData) {
|
async function triggerThumbnailsRebuild(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
@@ -52,30 +60,62 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
async function triggerMetadataBatch(formData: FormData) {
|
async function triggerMetadataBatch(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (libraryId) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await startMetadataBatch(libraryId);
|
result = await startMetadataBatch(libraryId);
|
||||||
} catch {
|
} catch {
|
||||||
// Library may have metadata disabled — ignore silently
|
// Library may have metadata disabled — ignore silently
|
||||||
return;
|
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) {
|
async function triggerMetadataRefresh(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (libraryId) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await startMetadataRefresh(libraryId);
|
result = await startMetadataRefresh(libraryId);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -127,13 +167,23 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" formAction={triggerFullRebuild}
|
<button type="submit" formAction={triggerRescan}
|
||||||
className="w-full text-left rounded-lg border border-warning/30 bg-warning/5 p-3 hover:bg-warning/10 transition-colors group cursor-pointer">
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-primary shrink-0" 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>
|
||||||
|
<span className="font-medium text-sm text-foreground">{t("jobs.rescan")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rescanShort")}</p>
|
||||||
|
</button>
|
||||||
|
<button type="submit" formAction={triggerFullRebuild}
|
||||||
|
className="w-full text-left rounded-lg border border-destructive/30 bg-destructive/5 p-3 hover:bg-destructive/10 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-sm text-warning">{t("jobs.fullRebuild")}</span>
|
<span className="font-medium text-sm text-destructive">{t("jobs.fullRebuild")}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -179,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" />
|
<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>
|
</svg>
|
||||||
{t("jobs.groupMetadata")}
|
{t("jobs.groupMetadata")}
|
||||||
<span className="text-xs font-normal text-muted-foreground">({t("jobs.requiresLibrary")})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button type="submit" formAction={triggerMetadataBatch}
|
<button type="submit" formAction={triggerMetadataBatch}
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMe
|
|||||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
import nextDynamic from "next/dynamic";
|
||||||
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
|
||||||
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
|
|
||||||
import { OffsetPagination } from "../../../../components/ui";
|
import { OffsetPagination } from "../../../../components/ui";
|
||||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { notFound } from "next/navigation";
|
||||||
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ export default async function SeriesDetailPage({
|
|||||||
alt={t("books.coverOf", { name: displayName })}
|
alt={t("books.coverOf", { name: displayName })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="160px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function LibrarySeriesPage({
|
|||||||
alt={t("books.coverOf", { name: s.name })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { getServerTranslations } from "../../lib/i18n/server";
|
||||||
import { LibraryActions } from "../components/LibraryActions";
|
import { LibraryActions } from "../components/LibraryActions";
|
||||||
import { LibraryForm } from "../components/LibraryForm";
|
import { LibraryForm } from "../components/LibraryForm";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
Button, Badge
|
Button, Badge
|
||||||
@@ -31,18 +34,12 @@ export default async function LibrariesPage() {
|
|||||||
listFolders().catch(() => [] as FolderItem[])
|
listFolders().catch(() => [] as FolderItem[])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seriesCounts = await Promise.all(
|
const thumbnailMap = new Map(
|
||||||
libraries.map(async (lib) => {
|
libraries.map(lib => [
|
||||||
try {
|
lib.id,
|
||||||
const seriesPage = await fetchSeries(lib.id);
|
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
|
||||||
return { id: lib.id, count: seriesPage.items.length };
|
])
|
||||||
} catch {
|
|
||||||
return { id: lib.id, count: 0 };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
|
|
||||||
|
|
||||||
async function addLibrary(formData: FormData) {
|
async function addLibrary(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -61,35 +58,6 @@ export default async function LibrariesPage() {
|
|||||||
revalidatePath("/libraries");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -100,7 +68,7 @@ export default async function LibrariesPage() {
|
|||||||
{t("libraries.title")}
|
{t("libraries.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Library Form */}
|
{/* Add Library Form */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -115,106 +83,139 @@ export default async function LibrariesPage() {
|
|||||||
{/* Libraries Grid */}
|
{/* Libraries Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{libraries.map((lib) => {
|
{libraries.map((lib) => {
|
||||||
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
const thumbnails = thumbnailMap.get(lib.id) || [];
|
||||||
return (
|
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">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||||
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<LibraryActions
|
<div className="flex items-center gap-1">
|
||||||
libraryId={lib.id}
|
<LibraryActions
|
||||||
monitorEnabled={lib.monitor_enabled}
|
libraryId={lib.id}
|
||||||
scanMode={lib.scan_mode}
|
monitorEnabled={lib.monitor_enabled}
|
||||||
watcherEnabled={lib.watcher_enabled}
|
scanMode={lib.scan_mode}
|
||||||
metadataProvider={lib.metadata_provider}
|
watcherEnabled={lib.watcher_enabled}
|
||||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
metadataProvider={lib.metadata_provider}
|
||||||
metadataRefreshMode={lib.metadata_refresh_mode}
|
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>
|
</div>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground break-all">{lib.root_path}</code>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 pt-0">
|
<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 */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.id}/books`}
|
href={`/libraries/${lib.id}/books`}
|
||||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
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="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||||
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.id}/series`}
|
href={`/libraries/${lib.id}/series`}
|
||||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
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>
|
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Configuration tags */}
|
||||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? t("libraries.auto") : t("libraries.manual")}
|
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>
|
</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 && (
|
{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")) })}
|
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default async function SeriesPage({
|
|||||||
alt={t("books.coverOf", { name: s.name })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
|||||||
@@ -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 = [
|
const tabs = [
|
||||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
{ 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: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||||
|
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -734,7 +735,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{new Date(r.created_at).toLocaleString()}
|
{new Date(r.created_at).toLocaleString(locale)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
||||||
{r.komga_url}
|
{r.komga_url}
|
||||||
@@ -826,6 +827,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{activeTab === "notifications" && (<>
|
||||||
|
{/* Telegram Notifications */}
|
||||||
|
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
</>)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1480,3 +1486,254 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
|||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type LibraryDto = {
|
|||||||
fallback_metadata_provider: string | null;
|
fallback_metadata_provider: string | null;
|
||||||
metadata_refresh_mode: string;
|
metadata_refresh_mode: string;
|
||||||
next_metadata_refresh_at: string | null;
|
next_metadata_refresh_at: string | null;
|
||||||
|
series_count: number;
|
||||||
|
thumbnail_book_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IndexJobDto = {
|
export type IndexJobDto = {
|
||||||
@@ -139,7 +141,7 @@ export function config() {
|
|||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit & { next?: { revalidate?: number; tags?: string[] } },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
const headers = new Headers(init?.headers || {});
|
const headers = new Headers(init?.headers || {});
|
||||||
@@ -148,10 +150,12 @@ export async function apiFetch<T>(
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { next: nextOptions, ...restInit } = init ?? {};
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
...init,
|
...restInit,
|
||||||
headers,
|
headers,
|
||||||
cache: "no-store",
|
...(nextOptions ? { next: nextOptions } : { cache: "no-store" as const }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -166,7 +170,7 @@ export async function apiFetch<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLibraries() {
|
export async function fetchLibraries() {
|
||||||
return apiFetch<LibraryDto[]>("/libraries");
|
return apiFetch<LibraryDto[]>("/libraries", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLibrary(name: string, rootPath: string) {
|
export async function createLibrary(name: string, rootPath: string) {
|
||||||
@@ -221,10 +225,11 @@ export async function listJobs() {
|
|||||||
return apiFetch<IndexJobDto[]>("/index/status");
|
return apiFetch<IndexJobDto[]>("/index/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rebuildIndex(libraryId?: string, full?: boolean) {
|
export async function rebuildIndex(libraryId?: string, full?: boolean, rescan?: boolean) {
|
||||||
const body: { library_id?: string; full?: boolean } = {};
|
const body: { library_id?: string; full?: boolean; rescan?: boolean } = {};
|
||||||
if (libraryId) body.library_id = libraryId;
|
if (libraryId) body.library_id = libraryId;
|
||||||
if (full) body.full = true;
|
if (full) body.full = true;
|
||||||
|
if (rescan) body.rescan = true;
|
||||||
return apiFetch<IndexJobDto>("/index/rebuild", {
|
return apiFetch<IndexJobDto>("/index/rebuild", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -285,6 +290,8 @@ export async function fetchBooks(
|
|||||||
readingStatus?: string,
|
readingStatus?: string,
|
||||||
sort?: string,
|
sort?: string,
|
||||||
author?: string,
|
author?: string,
|
||||||
|
format?: string,
|
||||||
|
metadataProvider?: string,
|
||||||
): Promise<BooksPageDto> {
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -292,6 +299,8 @@ export async function fetchBooks(
|
|||||||
if (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
if (author) params.set("author", author);
|
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("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -333,6 +342,7 @@ export async function fetchAllSeries(
|
|||||||
seriesStatus?: string,
|
seriesStatus?: string,
|
||||||
hasMissing?: boolean,
|
hasMissing?: boolean,
|
||||||
metadataProvider?: string,
|
metadataProvider?: string,
|
||||||
|
author?: string,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -342,6 +352,7 @@ export async function fetchAllSeries(
|
|||||||
if (seriesStatus) params.set("series_status", seriesStatus);
|
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||||
if (hasMissing) params.set("has_missing", "true");
|
if (hasMissing) params.set("has_missing", "true");
|
||||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||||
|
if (author) params.set("author", author);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -349,7 +360,7 @@ export async function fetchAllSeries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeriesStatuses(): Promise<string[]> {
|
export async function fetchSeriesStatuses(): Promise<string[]> {
|
||||||
return apiFetch<string[]>("/series/statuses");
|
return apiFetch<string[]>("/series/statuses", { next: { revalidate: 300 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchBooks(
|
export async function searchBooks(
|
||||||
@@ -414,7 +425,7 @@ export type ThumbnailStats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getSettings() {
|
export async function getSettings() {
|
||||||
return apiFetch<Settings>("/settings");
|
return apiFetch<Settings>("/settings", { next: { revalidate: 60 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSetting(key: string, value: unknown) {
|
export async function updateSetting(key: string, value: unknown) {
|
||||||
@@ -425,7 +436,7 @@ export async function updateSetting(key: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCacheStats() {
|
export async function getCacheStats() {
|
||||||
return apiFetch<CacheStats>("/settings/cache/stats");
|
return apiFetch<CacheStats>("/settings/cache/stats", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCache() {
|
export async function clearCache() {
|
||||||
@@ -435,7 +446,7 @@ export async function clearCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getThumbnailStats() {
|
export async function getThumbnailStats() {
|
||||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status mappings
|
// Status mappings
|
||||||
@@ -446,7 +457,7 @@ export type StatusMappingDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStatusMappings(): Promise<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> {
|
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
|
||||||
@@ -551,7 +562,7 @@ export type StatsResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStats() {
|
export async function fetchStats() {
|
||||||
return apiFetch<StatsResponse>("/stats");
|
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"books.noResults": "No books found for \"{{query}}\"",
|
"books.noResults": "No books found for \"{{query}}\"",
|
||||||
"books.noBooks": "No books available",
|
"books.noBooks": "No books available",
|
||||||
"books.coverOf": "Cover of {{name}}",
|
"books.coverOf": "Cover of {{name}}",
|
||||||
|
"books.format": "Format",
|
||||||
|
"books.allFormats": "All formats",
|
||||||
|
|
||||||
// Series page
|
// Series page
|
||||||
"series.title": "Series",
|
"series.title": "Series",
|
||||||
@@ -140,6 +142,9 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"libraries.imminent": "Imminent",
|
"libraries.imminent": "Imminent",
|
||||||
"libraries.nextMetadataRefresh": "Next metadata refresh: {{time}}",
|
"libraries.nextMetadataRefresh": "Next metadata refresh: {{time}}",
|
||||||
"libraries.nextMetadataRefreshShort": "Meta.: {{time}}",
|
"libraries.nextMetadataRefreshShort": "Meta.: {{time}}",
|
||||||
|
"libraries.scanLabel": "Scan: {{mode}}",
|
||||||
|
"libraries.watcherLabel": "File watch",
|
||||||
|
"libraries.metaRefreshLabel": "Meta refresh: {{mode}}",
|
||||||
"libraries.index": "Index",
|
"libraries.index": "Index",
|
||||||
"libraries.fullIndex": "Full",
|
"libraries.fullIndex": "Full",
|
||||||
"libraries.batchMetadata": "Batch metadata",
|
"libraries.batchMetadata": "Batch metadata",
|
||||||
@@ -157,14 +162,22 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"librarySeries.noBooksInSeries": "No books in this series",
|
"librarySeries.noBooksInSeries": "No books in this series",
|
||||||
|
|
||||||
// Library actions
|
// Library actions
|
||||||
"libraryActions.autoScan": "Auto scan",
|
"libraryActions.settingsTitle": "Library settings",
|
||||||
"libraryActions.fileWatch": "File watch ⚡",
|
"libraryActions.sectionIndexation": "Indexation",
|
||||||
"libraryActions.schedule": "📅 Schedule",
|
"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.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.default": "Default",
|
||||||
"libraryActions.none": "None",
|
"libraryActions.none": "None",
|
||||||
"libraryActions.metadataRefreshSchedule": "Refresh meta.",
|
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
|
||||||
|
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
|
||||||
"libraryActions.saving": "Saving...",
|
"libraryActions.saving": "Saving...",
|
||||||
|
|
||||||
// Library sub-page header
|
// Library sub-page header
|
||||||
@@ -186,6 +199,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"jobs.startJobDescription": "Select a library (or all) and choose the action to perform.",
|
"jobs.startJobDescription": "Select a library (or all) and choose the action to perform.",
|
||||||
"jobs.allLibraries": "All libraries",
|
"jobs.allLibraries": "All libraries",
|
||||||
"jobs.rebuild": "Rebuild",
|
"jobs.rebuild": "Rebuild",
|
||||||
|
"jobs.rescan": "Deep rescan",
|
||||||
"jobs.fullRebuild": "Full rebuild",
|
"jobs.fullRebuild": "Full rebuild",
|
||||||
"jobs.generateThumbnails": "Generate thumbnails",
|
"jobs.generateThumbnails": "Generate thumbnails",
|
||||||
"jobs.regenerateThumbnails": "Regenerate thumbnails",
|
"jobs.regenerateThumbnails": "Regenerate thumbnails",
|
||||||
@@ -198,12 +212,14 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"jobs.groupMetadata": "Metadata",
|
"jobs.groupMetadata": "Metadata",
|
||||||
"jobs.requiresLibrary": "Requires a specific library",
|
"jobs.requiresLibrary": "Requires a specific library",
|
||||||
"jobs.rebuildShort": "Scan new & modified files",
|
"jobs.rebuildShort": "Scan new & modified files",
|
||||||
|
"jobs.rescanShort": "Re-walk all directories to discover new formats",
|
||||||
"jobs.fullRebuildShort": "Delete all & re-scan from scratch",
|
"jobs.fullRebuildShort": "Delete all & re-scan from scratch",
|
||||||
"jobs.generateThumbnailsShort": "Missing thumbnails only",
|
"jobs.generateThumbnailsShort": "Missing thumbnails only",
|
||||||
"jobs.regenerateThumbnailsShort": "Recreate all thumbnails",
|
"jobs.regenerateThumbnailsShort": "Recreate all thumbnails",
|
||||||
"jobs.batchMetadataShort": "Auto-match unlinked series",
|
"jobs.batchMetadataShort": "Auto-match unlinked series",
|
||||||
"jobs.refreshMetadataShort": "Update existing linked series",
|
"jobs.refreshMetadataShort": "Update existing linked series",
|
||||||
"jobs.rebuildDescription": "Incremental scan: detects files added, modified, or deleted since the last scan, indexes them, and generates missing thumbnails. Existing unmodified data is preserved. This is the most common and fastest action.",
|
"jobs.rebuildDescription": "Incremental scan: detects files added, modified, or deleted since the last scan, indexes them, and generates missing thumbnails. Existing unmodified data is preserved. This is the most common and fastest action.",
|
||||||
|
"jobs.rescanDescription": "Re-walks all directories regardless of whether they changed, discovering files in newly supported formats (e.g. EPUB). Existing books and metadata are fully preserved — only genuinely new files are added. Slower than a rebuild but safe for your data.",
|
||||||
"jobs.fullRebuildDescription": "Deletes all indexed data (books, series, thumbnails) then performs a full scan from scratch. Useful if the database is out of sync or corrupted. Long and destructive operation: reading statuses and manual metadata will be lost.",
|
"jobs.fullRebuildDescription": "Deletes all indexed data (books, series, thumbnails) then performs a full scan from scratch. Useful if the database is out of sync or corrupted. Long and destructive operation: reading statuses and manual metadata will be lost.",
|
||||||
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
|
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
|
||||||
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
|
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
|
||||||
@@ -310,6 +326,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// Job types
|
// Job types
|
||||||
"jobType.rebuild": "Indexing",
|
"jobType.rebuild": "Indexing",
|
||||||
|
"jobType.rescan": "Deep rescan",
|
||||||
"jobType.full_rebuild": "Full indexing",
|
"jobType.full_rebuild": "Full indexing",
|
||||||
"jobType.thumbnail_rebuild": "Thumbnails",
|
"jobType.thumbnail_rebuild": "Thumbnails",
|
||||||
"jobType.thumbnail_regenerate": "Regen. thumbnails",
|
"jobType.thumbnail_regenerate": "Regen. thumbnails",
|
||||||
@@ -318,6 +335,8 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"jobType.metadata_refresh": "Refresh meta.",
|
"jobType.metadata_refresh": "Refresh meta.",
|
||||||
"jobType.rebuildLabel": "Incremental indexing",
|
"jobType.rebuildLabel": "Incremental indexing",
|
||||||
"jobType.rebuildDesc": "Scans new/modified files, analyzes them, and generates missing thumbnails.",
|
"jobType.rebuildDesc": "Scans new/modified files, analyzes them, and generates missing thumbnails.",
|
||||||
|
"jobType.rescanLabel": "Deep rescan",
|
||||||
|
"jobType.rescanDesc": "Re-walks all directories to discover files in newly supported formats (e.g. EPUB). Existing data is preserved — only new files are added.",
|
||||||
"jobType.full_rebuildLabel": "Full reindexing",
|
"jobType.full_rebuildLabel": "Full reindexing",
|
||||||
"jobType.full_rebuildDesc": "Deletes all existing data then performs a full scan, re-analysis, and thumbnail generation.",
|
"jobType.full_rebuildDesc": "Deletes all existing data then performs a full scan, re-analysis, and thumbnail generation.",
|
||||||
"jobType.thumbnail_rebuildLabel": "Thumbnail rebuild",
|
"jobType.thumbnail_rebuildLabel": "Thumbnail rebuild",
|
||||||
@@ -524,6 +543,33 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"settings.qbittorrentUsername": "Username",
|
"settings.qbittorrentUsername": "Username",
|
||||||
"settings.qbittorrentPassword": "Password",
|
"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<TOKEN>/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
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.languageDesc": "Choose the interface language",
|
"settings.languageDesc": "Choose the interface language",
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ const fr = {
|
|||||||
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
||||||
"books.noBooks": "Aucun livre disponible",
|
"books.noBooks": "Aucun livre disponible",
|
||||||
"books.coverOf": "Couverture de {{name}}",
|
"books.coverOf": "Couverture de {{name}}",
|
||||||
|
"books.format": "Format",
|
||||||
|
"books.allFormats": "Tous les formats",
|
||||||
|
|
||||||
// Series page
|
// Series page
|
||||||
"series.title": "Séries",
|
"series.title": "Séries",
|
||||||
@@ -138,6 +140,9 @@ const fr = {
|
|||||||
"libraries.imminent": "Imminent",
|
"libraries.imminent": "Imminent",
|
||||||
"libraries.nextMetadataRefresh": "Prochain rafraîchissement méta. : {{time}}",
|
"libraries.nextMetadataRefresh": "Prochain rafraîchissement méta. : {{time}}",
|
||||||
"libraries.nextMetadataRefreshShort": "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.index": "Indexer",
|
||||||
"libraries.fullIndex": "Complet",
|
"libraries.fullIndex": "Complet",
|
||||||
"libraries.batchMetadata": "Métadonnées en lot",
|
"libraries.batchMetadata": "Métadonnées en lot",
|
||||||
@@ -155,14 +160,22 @@ const fr = {
|
|||||||
"librarySeries.noBooksInSeries": "Aucun livre dans cette série",
|
"librarySeries.noBooksInSeries": "Aucun livre dans cette série",
|
||||||
|
|
||||||
// Library actions
|
// Library actions
|
||||||
"libraryActions.autoScan": "Scan auto",
|
"libraryActions.settingsTitle": "Paramètres de la bibliothèque",
|
||||||
"libraryActions.fileWatch": "Surveillance fichiers ⚡",
|
"libraryActions.sectionIndexation": "Indexation",
|
||||||
"libraryActions.schedule": "📅 Planification",
|
"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.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.default": "Par défaut",
|
||||||
"libraryActions.none": "Aucun",
|
"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...",
|
"libraryActions.saving": "Enregistrement...",
|
||||||
|
|
||||||
// Library sub-page header
|
// Library sub-page header
|
||||||
@@ -183,8 +196,9 @@ const fr = {
|
|||||||
"jobs.startJob": "Lancer une tâche",
|
"jobs.startJob": "Lancer une tâche",
|
||||||
"jobs.startJobDescription": "Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.",
|
"jobs.startJobDescription": "Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.",
|
||||||
"jobs.allLibraries": "Toutes les bibliothèques",
|
"jobs.allLibraries": "Toutes les bibliothèques",
|
||||||
"jobs.rebuild": "Reconstruction",
|
"jobs.rebuild": "Mise à jour",
|
||||||
"jobs.fullRebuild": "Reconstruction complète",
|
"jobs.rescan": "Rescan complet",
|
||||||
|
"jobs.fullRebuild": "Reconstruction complète (destructif)",
|
||||||
"jobs.generateThumbnails": "Générer les miniatures",
|
"jobs.generateThumbnails": "Générer les miniatures",
|
||||||
"jobs.regenerateThumbnails": "Regénérer les miniatures",
|
"jobs.regenerateThumbnails": "Regénérer les miniatures",
|
||||||
"jobs.batchMetadata": "Métadonnées en lot",
|
"jobs.batchMetadata": "Métadonnées en lot",
|
||||||
@@ -196,12 +210,14 @@ const fr = {
|
|||||||
"jobs.groupMetadata": "Métadonnées",
|
"jobs.groupMetadata": "Métadonnées",
|
||||||
"jobs.requiresLibrary": "Requiert une bibliothèque spécifique",
|
"jobs.requiresLibrary": "Requiert une bibliothèque spécifique",
|
||||||
"jobs.rebuildShort": "Scanner les fichiers nouveaux et modifiés",
|
"jobs.rebuildShort": "Scanner les fichiers nouveaux et modifiés",
|
||||||
"jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro",
|
"jobs.rescanShort": "Re-parcourir tous les dossiers pour découvrir de nouveaux formats",
|
||||||
|
"jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro. Les métadonnées, statuts de lecture et liens seront perdus.",
|
||||||
"jobs.generateThumbnailsShort": "Miniatures manquantes uniquement",
|
"jobs.generateThumbnailsShort": "Miniatures manquantes uniquement",
|
||||||
"jobs.regenerateThumbnailsShort": "Recréer toutes les miniatures",
|
"jobs.regenerateThumbnailsShort": "Recréer toutes les miniatures",
|
||||||
"jobs.batchMetadataShort": "Lier automatiquement les séries non liées",
|
"jobs.batchMetadataShort": "Lier automatiquement les séries non liées",
|
||||||
"jobs.refreshMetadataShort": "Mettre à jour les séries déjà liées",
|
"jobs.refreshMetadataShort": "Mettre à jour les séries déjà liées",
|
||||||
"jobs.rebuildDescription": "Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C'est l'action la plus courante et la plus rapide.",
|
"jobs.rebuildDescription": "Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C'est l'action la plus courante et la plus rapide.",
|
||||||
|
"jobs.rescanDescription": "Re-parcourt tous les dossiers même s'ils n'ont pas changé, pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les livres et métadonnées existants sont entièrement préservés — seuls les fichiers réellement nouveaux sont ajoutés. Plus lent qu'un rebuild mais sans risque pour vos données.",
|
||||||
"jobs.fullRebuildDescription": "Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.",
|
"jobs.fullRebuildDescription": "Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.",
|
||||||
"jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.",
|
"jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.",
|
||||||
"jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.",
|
"jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.",
|
||||||
@@ -308,6 +324,7 @@ const fr = {
|
|||||||
|
|
||||||
// Job types
|
// Job types
|
||||||
"jobType.rebuild": "Indexation",
|
"jobType.rebuild": "Indexation",
|
||||||
|
"jobType.rescan": "Rescan complet",
|
||||||
"jobType.full_rebuild": "Indexation complète",
|
"jobType.full_rebuild": "Indexation complète",
|
||||||
"jobType.thumbnail_rebuild": "Miniatures",
|
"jobType.thumbnail_rebuild": "Miniatures",
|
||||||
"jobType.thumbnail_regenerate": "Régén. miniatures",
|
"jobType.thumbnail_regenerate": "Régén. miniatures",
|
||||||
@@ -316,6 +333,8 @@ const fr = {
|
|||||||
"jobType.metadata_refresh": "Rafraîchir méta.",
|
"jobType.metadata_refresh": "Rafraîchir méta.",
|
||||||
"jobType.rebuildLabel": "Indexation incrémentale",
|
"jobType.rebuildLabel": "Indexation incrémentale",
|
||||||
"jobType.rebuildDesc": "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
"jobType.rebuildDesc": "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||||
|
"jobType.rescanLabel": "Rescan complet",
|
||||||
|
"jobType.rescanDesc": "Re-parcourt tous les dossiers pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les données existantes sont préservées — seuls les nouveaux fichiers sont ajoutés.",
|
||||||
"jobType.full_rebuildLabel": "Réindexation complète",
|
"jobType.full_rebuildLabel": "Réindexation complète",
|
||||||
"jobType.full_rebuildDesc": "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
"jobType.full_rebuildDesc": "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||||
"jobType.thumbnail_rebuildLabel": "Reconstruction des miniatures",
|
"jobType.thumbnail_rebuildLabel": "Reconstruction des miniatures",
|
||||||
@@ -522,6 +541,33 @@ const fr = {
|
|||||||
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
||||||
"settings.qbittorrentPassword": "Mot de passe",
|
"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<TOKEN>/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
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
typedRoutes: true
|
typedRoutes: true,
|
||||||
|
images: {
|
||||||
|
minimumCacheTTL: 86400,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.16.0",
|
"version": "1.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ futures = "0.3"
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
jpeg-decoder.workspace = true
|
jpeg-decoder.workspace = true
|
||||||
num_cpus.workspace = true
|
num_cpus.workspace = true
|
||||||
|
notifications = { path = "../../crates/notifications" }
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
|||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY crates/core/Cargo.toml crates/core/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
|
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/api/src/main.rs && \
|
||||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||||
echo "" > apps/indexer/src/lib.rs && \
|
echo "" > apps/indexer/src/lib.rs && \
|
||||||
echo "" > crates/core/src/lib.rs && \
|
echo "" > crates/core/src/lib.rs && \
|
||||||
|
echo "" > crates/notifications/src/lib.rs && \
|
||||||
echo "" > crates/parsers/src/lib.rs
|
echo "" > crates/parsers/src/lib.rs
|
||||||
|
|
||||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
# 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/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
|
COPY crates/notifications/src crates/notifications/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--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 && \
|
cargo build --release -p indexer && \
|
||||||
cp /app/target/release/indexer /usr/local/bin/indexer
|
cp /app/target/release/indexer /usr/local/bin/indexer
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const API_ONLY_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
|
|||||||
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
|
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
|
||||||
"rebuild",
|
"rebuild",
|
||||||
"full_rebuild",
|
"full_rebuild",
|
||||||
|
"rescan",
|
||||||
"scan",
|
"scan",
|
||||||
"thumbnail_rebuild",
|
"thumbnail_rebuild",
|
||||||
"thumbnail_regenerate",
|
"thumbnail_regenerate",
|
||||||
@@ -211,11 +212,29 @@ pub async fn process_job(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let is_full_rebuild = job_type == "full_rebuild";
|
let is_full_rebuild = job_type == "full_rebuild";
|
||||||
|
let is_rescan = job_type == "rescan";
|
||||||
info!(
|
info!(
|
||||||
"[JOB] {} type={} full_rebuild={}",
|
"[JOB] {} type={} full_rebuild={} rescan={}",
|
||||||
job_id, job_type, is_full_rebuild
|
job_id, job_type, is_full_rebuild, is_rescan
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rescan: clear directory mtimes to force re-walking all directories,
|
||||||
|
// but keep existing data intact (unlike full_rebuild)
|
||||||
|
if is_rescan {
|
||||||
|
if let Some(library_id) = target_library_id {
|
||||||
|
let _ = sqlx::query("DELETE FROM directory_mtimes WHERE library_id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
info!("[JOB] Rescan: cleared directory mtimes for library {}", library_id);
|
||||||
|
} else {
|
||||||
|
let _ = sqlx::query("DELETE FROM directory_mtimes")
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
info!("[JOB] Rescan: cleared all directory mtimes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Full rebuild: delete existing data first
|
// Full rebuild: delete existing data first
|
||||||
if is_full_rebuild {
|
if is_full_rebuild {
|
||||||
info!("[JOB] Full rebuild: deleting existing data");
|
info!("[JOB] Full rebuild: deleting existing data");
|
||||||
@@ -258,7 +277,7 @@ pub async fn process_job(
|
|||||||
// For full rebuilds, the DB is already cleared, so we must walk the filesystem.
|
// For full rebuilds, the DB is already cleared, so we must walk the filesystem.
|
||||||
let library_ids: Vec<uuid::Uuid> = libraries.iter().map(|r| r.get("id")).collect();
|
let library_ids: Vec<uuid::Uuid> = libraries.iter().map(|r| r.get("id")).collect();
|
||||||
|
|
||||||
let total_files: usize = if !is_full_rebuild {
|
let total_files: usize = if !is_full_rebuild && !is_rescan {
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM book_files bf JOIN books b ON b.id = bf.book_id WHERE b.library_id = ANY($1)"
|
"SELECT COUNT(*) FROM book_files bf JOIN books b ON b.id = bf.book_id WHERE b.library_id = ANY($1)"
|
||||||
)
|
)
|
||||||
@@ -309,6 +328,7 @@ pub async fn process_job(
|
|||||||
removed_files: 0,
|
removed_files: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
warnings: 0,
|
warnings: 0,
|
||||||
|
new_series: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut total_processed_count = 0i32;
|
let mut total_processed_count = 0i32;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
utils,
|
utils,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct JobStats {
|
pub struct JobStats {
|
||||||
@@ -22,6 +23,7 @@ pub struct JobStats {
|
|||||||
pub removed_files: usize,
|
pub removed_files: usize,
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
pub warnings: usize,
|
pub warnings: usize,
|
||||||
|
pub new_series: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BATCH_SIZE: usize = 100;
|
const BATCH_SIZE: usize = 100;
|
||||||
@@ -106,6 +108,18 @@ pub async fn scan_library_discovery(
|
|||||||
HashMap::new()
|
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 seen: HashMap<String, bool> = HashMap::new();
|
||||||
let mut library_processed_count = 0i32;
|
let mut library_processed_count = 0i32;
|
||||||
let mut last_progress_update = std::time::Instant::now();
|
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 book_id = Uuid::new_v4();
|
||||||
let file_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 {
|
books_to_insert.push(BookInsert {
|
||||||
book_id,
|
book_id,
|
||||||
library_id,
|
library_id,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use sqlx::Row;
|
||||||
use tracing::{error, info, trace};
|
use tracing::{error, info, trace};
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::{job, scheduler, watcher, AppState};
|
use crate::{job, scheduler, watcher, AppState};
|
||||||
|
|
||||||
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||||
let wait = Duration::from_secs(interval_seconds.max(1));
|
let wait = Duration::from_secs(interval_seconds.max(1));
|
||||||
|
|
||||||
// Cleanup stale jobs from previous runs
|
// Cleanup stale jobs from previous runs
|
||||||
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
|
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
|
||||||
error!("[CLEANUP] Failed to cleanup stale jobs: {}", err);
|
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 {
|
loop {
|
||||||
match job::claim_next_job(&state.pool).await {
|
match job::claim_next_job(&state.pool).await {
|
||||||
Ok(Some((job_id, library_id))) => {
|
Ok(Some((job_id, library_id))) => {
|
||||||
info!("[INDEXER] Starting job {} library={:?}", 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 {
|
if let Err(err) = job::process_job(&state, job_id, library_id).await {
|
||||||
let err_str = err.to_string();
|
let err_str = err.to_string();
|
||||||
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
||||||
info!("[INDEXER] Job {} was cancelled by user", job_id);
|
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 {
|
} else {
|
||||||
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
||||||
let _ = job::fail_job(&state.pool, job_id, &err_str).await;
|
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 {
|
} else {
|
||||||
info!("[INDEXER] Job {} completed", job_id);
|
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) => {
|
Ok(None) => {
|
||||||
|
|||||||
13
crates/notifications/Cargo.toml
Normal file
13
crates/notifications/Cargo.toml
Normal 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
|
||||||
513
crates/notifications/src/lib.rs
Normal file
513
crates/notifications/src/lib.rs
Normal 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
310
docs/FEATURES.md
Normal 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 (1–100)
|
||||||
|
- Max width parameter (1–2160 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 (0–100), 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
|
||||||
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add rescan job type: clears directory mtimes to force re-walking all directories
|
||||||
|
-- while preserving existing data (unlike full_rebuild which deletes everything).
|
||||||
|
-- Useful for discovering newly supported formats (e.g. EPUB) without losing metadata.
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
|
||||||
|
ADD CONSTRAINT index_jobs_type_check
|
||||||
|
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'rescan', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch', 'metadata_refresh'));
|
||||||
3
infra/migrations/0048_add_telegram_settings.sql
Normal file
3
infra/migrations/0048_add_telegram_settings.sql
Normal 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;
|
||||||
8
infra/migrations/0049_update_telegram_events.sql
Normal file
8
infra/migrations/0049_update_telegram_events.sql
Normal 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';
|
||||||
Reference in New Issue
Block a user