Compare commits
126 Commits
1d25c8869f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 | |||
| ee65c6263a | |||
| 691b6b22ab | |||
| 11c80a16a3 | |||
| c366b44c54 | |||
| 92f80542e6 | |||
| 3a25e42a20 | |||
| 24763bf5a7 | |||
| 08f0397029 | |||
| 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 | |||
| ed7665248e | |||
| 736b8aedc0 | |||
| 3daa49ae6c | |||
| 5fb24188e1 | |||
| 54f972db17 | |||
| acd8b62382 | |||
| cc65e3d1ad | |||
| 70889ca955 | |||
| 4ad6d57271 | |||
| fe5de3d5c1 | |||
| 5a224c48c0 | |||
| d08fe31b1b | |||
| 4d69ed91c5 | |||
| c6ddd3e6c7 | |||
| 504185f31f | |||
| acd0cce3f8 | |||
| e14da4fc8d | |||
| c04d4fb618 | |||
| 57bc82703d | |||
| e6aa7ebed0 | |||
| c44b51d6ef | |||
| d4c48de780 | |||
| 8948f75d62 | |||
| d304877a83 | |||
| 9cec32ba3e | |||
| e8768dfad7 | |||
| cfc98819ab | |||
| bfc1c76fe2 | |||
| 39e9f35acb | |||
| 36987f59b9 | |||
| 931d0e06f4 | |||
| 741a4da878 | |||
| e28b78d0e6 | |||
| 163dc3698c | |||
| 818bd82e0f | |||
| 76c8bcbf2c | |||
| 00094b22c6 | |||
| 1e4d9acebe | |||
| b226aa3a35 | |||
| d913be9d2a | |||
| e9bb951d97 | |||
| 037ede2750 | |||
| 06a245d90a | |||
| 63d5fcaa13 | |||
| 020cb6baae | |||
| 6db8042ffe | |||
| d4f87c4044 | |||
| 055c376222 | |||
| 1cc5d049ea | |||
| b955c2697c | |||
| 9a8c1577af | |||
| 52b9b0e00e | |||
| 51ef2fa725 | |||
| 7d53babc84 | |||
| 00f4445924 | |||
| 1a91c051b5 | |||
| 48ca9d0a8b | |||
| f75d795215 | |||
| ac13f53124 | |||
| c9ccf5cd90 | |||
| a99bfb5a91 | |||
| 389d71b42f | |||
| 2985ef5561 | |||
| 4be8177683 | |||
| a675dcd2a4 | |||
| 127cd8a42c | |||
| 1b9f2d3915 | |||
| f095bf050b | |||
| b17718df9b | |||
| 5c3ddf7819 | |||
| c56d02a895 | |||
| bc98067871 | |||
| a085924f8a | |||
| 9fbdf793d0 | |||
| b14accbbe0 | |||
| 330239d2c3 | |||
| bf5a20882b | |||
| 44c6dd626a | |||
| 9153b0c750 | |||
| e18bbba4ce | |||
| 2870dd9dbc | |||
| cf2e7a0be7 | |||
| 82444cda02 |
@@ -9,9 +9,6 @@
|
||||
# REQUIRED - Change these values in production!
|
||||
# =============================================================================
|
||||
|
||||
# Master key for Meilisearch authentication (required)
|
||||
MEILI_MASTER_KEY=change-me-in-production
|
||||
|
||||
# Bootstrap token for initial API admin access (required)
|
||||
# Use this token for the first API calls before creating proper API tokens
|
||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||
@@ -28,9 +25,6 @@ API_BASE_URL=http://api:7080
|
||||
INDEXER_LISTEN_ADDR=0.0.0.0:7081
|
||||
INDEXER_SCAN_INTERVAL_SECONDS=5
|
||||
|
||||
# Meilisearch Search Engine
|
||||
MEILI_URL=http://meilisearch:7700
|
||||
|
||||
# PostgreSQL Database
|
||||
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||
|
||||
@@ -77,5 +71,4 @@ THUMBNAILS_HOST_PATH=./data/thumbnails
|
||||
# - API: change "7080:7080" to "YOUR_PORT:7080"
|
||||
# - Indexer: change "7081:7081" to "YOUR_PORT:7081"
|
||||
# - Backoffice: change "7082:7082" to "YOUR_PORT:7082"
|
||||
# - Meilisearch: change "7700:7700" to "YOUR_PORT:7700"
|
||||
# - PostgreSQL: change "6432:5432" to "YOUR_PORT:5432"
|
||||
|
||||
17
.gitea/workflows/deploy.yml
Normal file
17
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Deploy with Docker Compose
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # adapte la branche que tu veux déployer
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||
steps:
|
||||
- name: Deploy stack
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
BUILDKIT_PROGRESS=plain cd /Users/julienfroidefond/Sites/docker-stack && docker pull julienfroidefond32/stripstream-backoffice && docker pull julienfroidefond32/stripstream-api && docker pull julienfroidefond32/stripstream-indexer && ./scripts/stack.sh up stripstream
|
||||
@@ -77,7 +77,7 @@ sqlx migrate add -r migration_name
|
||||
|
||||
```bash
|
||||
# Start infrastructure only
|
||||
docker compose up -d postgres meilisearch
|
||||
docker compose up -d postgres
|
||||
|
||||
# Start full stack
|
||||
docker compose up -d
|
||||
|
||||
@@ -10,7 +10,6 @@ Gestionnaire de bibliothèque de bandes dessinées/ebooks. Workspace Cargo multi
|
||||
| Indexer (background) | `apps/indexer/` | 7081 |
|
||||
| Backoffice (Next.js) | `apps/backoffice/` | 7082 |
|
||||
| PostgreSQL | infra | 6432 |
|
||||
| Meilisearch | infra | 7700 |
|
||||
|
||||
Crates partagés : `crates/core` (config env), `crates/parsers` (CBZ/CBR/PDF).
|
||||
|
||||
@@ -31,7 +30,7 @@ cargo test
|
||||
cargo test -p parsers
|
||||
|
||||
# Infra (dépendances uniquement) — docker-compose.yml est à la racine
|
||||
docker compose up -d postgres meilisearch
|
||||
docker compose up -d postgres
|
||||
|
||||
# Backoffice dev
|
||||
cd apps/backoffice && npm install && npm run dev # http://localhost:7082
|
||||
@@ -46,7 +45,7 @@ sqlx migrate run # DATABASE_URL doit être défini
|
||||
cp .env.example .env # puis éditer les valeurs REQUIRED
|
||||
```
|
||||
|
||||
Variables **requises** au démarrage : `DATABASE_URL`, `MEILI_URL`, `MEILI_MASTER_KEY`, `API_BOOTSTRAP_TOKEN`.
|
||||
Variables **requises** au démarrage : `DATABASE_URL`, `API_BOOTSTRAP_TOKEN`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -56,6 +55,7 @@ Variables **requises** au démarrage : `DATABASE_URL`, `MEILI_URL`, `MEILI_MASTE
|
||||
- **Thumbnails** : stockés dans `THUMBNAIL_DIRECTORY` (défaut `/data/thumbnails`), générés par **l'API** (pas l'indexer) — l'indexer déclenche un checkup via `POST /index/jobs/:id/thumbnails/checkup`.
|
||||
- **Workspace Cargo** : les dépendances externes sont définies dans le `Cargo.toml` racine, pas dans les crates individuels.
|
||||
- **Migrations** : dossier `infra/migrations/`, géré par sqlx. Toujours migrer avant de démarrer les services.
|
||||
- **Recherche** : full-text via PostgreSQL (`ILIKE` + `pg_trgm`), pas de moteur de recherche externe.
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
@@ -64,6 +64,7 @@ Variables **requises** au démarrage : `DATABASE_URL`, `MEILI_URL`, `MEILI_MASTE
|
||||
| `crates/core/src/config.rs` | Config depuis env (API, Indexer, AdminUI) |
|
||||
| `crates/parsers/src/lib.rs` | Détection format, extraction métadonnées |
|
||||
| `apps/api/src/books.rs` | Endpoints CRUD livres |
|
||||
| `apps/api/src/search.rs` | Recherche full-text PostgreSQL |
|
||||
| `apps/api/src/pages.rs` | Rendu pages + cache LRU |
|
||||
| `apps/indexer/src/scanner.rs` | Scan filesystem |
|
||||
| `infra/migrations/*.sql` | Schéma DB |
|
||||
|
||||
386
Cargo.lock
generated
386
Cargo.lock
generated
@@ -19,6 +19,19 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -51,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.2.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -63,9 +76,12 @@ dependencies = [
|
||||
"image",
|
||||
"jpeg-decoder",
|
||||
"lru",
|
||||
"notifications",
|
||||
"parsers",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -299,9 +315,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
version = "1.2.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -463,6 +479,29 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -483,6 +522,17 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -512,6 +562,21 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.1.2"
|
||||
@@ -521,6 +586,12 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -629,6 +700,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -728,6 +809,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -738,6 +828,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -855,6 +954,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1096,9 +1207,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
@@ -1122,7 +1233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "0.2.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1130,6 +1241,7 @@ dependencies = [
|
||||
"futures",
|
||||
"image",
|
||||
"jpeg-decoder",
|
||||
"notifications",
|
||||
"num_cpus",
|
||||
"parsers",
|
||||
"reqwest",
|
||||
@@ -1286,9 +1398,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -1406,6 +1518,37 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -1482,9 +1625,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
@@ -1496,6 +1639,12 @@ version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -1516,6 +1665,19 @@ dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notifications"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1589,9 +1751,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
@@ -1624,7 +1786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "0.2.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -1690,6 +1852,58 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1762,9 +1976,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
@@ -1793,6 +2007,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -1870,9 +2090,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@@ -2065,6 +2285,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -2073,6 +2294,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2230,6 +2452,41 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0e749d29b2064585327af5038a5a8eb73aeebad4a3472e83531a436563f7208"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -2302,6 +2559,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -2365,6 +2631,12 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2382,12 +2654,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2613,6 +2885,31 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -2626,7 +2923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "0.2.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -2679,6 +2976,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -2751,9 +3059,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
@@ -2918,9 +3226,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.22"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
@@ -2991,6 +3299,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -3038,6 +3352,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf16string"
|
||||
version = "0.2.0"
|
||||
@@ -3095,9 +3415,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -3775,18 +4095,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.40"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.40"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3911,9 +4231,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.12"
|
||||
version = "0.5.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||
checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -3,13 +3,14 @@ members = [
|
||||
"apps/api",
|
||||
"apps/indexer",
|
||||
"crates/core",
|
||||
"crates/notifications",
|
||||
"crates/parsers",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.2.0"
|
||||
version = "1.27.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
||||
jpeg-decoder = "0.3"
|
||||
lru = "0.12"
|
||||
rayon = "1.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -41,3 +42,4 @@ walkdir = "2.5"
|
||||
webp = "0.3"
|
||||
utoipa = "4.0"
|
||||
utoipa-swagger-ui = "6.0"
|
||||
scraper = "0.21"
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Julien Froidefond
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
10
PLAN.md
10
PLAN.md
@@ -12,7 +12,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- Backend/API: Rust (`axum`)
|
||||
- Indexation: service Rust dedie (`indexer`)
|
||||
- DB: PostgreSQL
|
||||
- Recherche: Meilisearch
|
||||
- Recherche: PostgreSQL full-text (ILIKE + pg_trgm)
|
||||
- Deploiement: Docker Compose
|
||||
- Auth: token bootstrap env + tokens admin en DB (creables/revocables)
|
||||
- Expiration tokens admin: aucune par defaut (revocation manuelle)
|
||||
@@ -33,7 +33,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
**DoD:** Build des crates OK.
|
||||
|
||||
### T2 - Infra Docker Compose
|
||||
- [x] Definir services `postgres`, `meilisearch`, `api`, `indexer`
|
||||
- [x] Definir services `postgres`, `api`, `indexer`
|
||||
- [x] Volumes persistants
|
||||
- [x] Healthchecks
|
||||
|
||||
@@ -114,7 +114,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
**DoD:** Pagination/filtres fonctionnels.
|
||||
|
||||
### T13 - Recherche
|
||||
- [x] Projection vers Meilisearch
|
||||
- [x] Recherche full-text PostgreSQL
|
||||
- [x] `GET /search?q=...&library_id=...&type=...`
|
||||
- [x] Fuzzy + filtres
|
||||
|
||||
@@ -264,10 +264,10 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- Bootstrap token = break-glass (peut etre desactive plus tard)
|
||||
|
||||
## Journal
|
||||
- 2026-03-05: `docker compose up -d --build` valide, stack complete en healthy (`postgres`, `meilisearch`, `api`, `indexer`, `admin-ui`).
|
||||
- 2026-03-05: `docker compose up -d --build` valide, stack complete en healthy (`postgres`, `api`, `indexer`, `admin-ui`).
|
||||
- 2026-03-05: ajustements infra appliques pour demarrage stable (`unrar` -> `unrar-free`, image `rust:1-bookworm`, healthchecks `127.0.0.1`).
|
||||
- 2026-03-05: ajout d'un service `migrate` dans Compose pour executer automatiquement `infra/migrations/0001_init.sql` au demarrage.
|
||||
- 2026-03-05: Lot 2 termine (jobs, scan incremental, parsers `cbz/cbr/pdf`, API livres, sync + recherche Meilisearch).
|
||||
- 2026-03-05: Lot 2 termine (jobs, scan incremental, parsers `cbz/cbr/pdf`, API livres, recherche PostgreSQL).
|
||||
- 2026-03-05: verification de bout en bout OK sur une librairie de test (`/libraries/demo`) avec indexation, listing `/books` et recherche `/search` (1 CBZ detecte).
|
||||
- 2026-03-05: Lot 3 avancee: endpoint pages (`/books/:id/pages/:n`) actif avec cache LRU, ETag/Cache-Control, limite concurrence rendu et timeouts.
|
||||
- 2026-03-05: hardening API: readiness expose sans auth via `route_layer`, metriques simples `/metrics`, rate limiting lecture (120 req/s).
|
||||
|
||||
99
README.md
99
README.md
@@ -9,7 +9,7 @@ The project consists of the following components:
|
||||
- **API** (`apps/api/`) - Rust-based REST API service
|
||||
- **Indexer** (`apps/indexer/`) - Rust-based background indexing service
|
||||
- **Backoffice** (`apps/backoffice/`) - Next.js web administration interface
|
||||
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL and Meilisearch
|
||||
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -27,19 +27,16 @@ The project consists of the following components:
|
||||
```
|
||||
|
||||
2. Edit `.env` and set secure values for:
|
||||
- `MEILI_MASTER_KEY` - Master key for Meilisearch
|
||||
- `API_BOOTSTRAP_TOKEN` - Bootstrap token for initial API authentication
|
||||
|
||||
### Running with Docker
|
||||
|
||||
```bash
|
||||
cd infra
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- PostgreSQL (port 6432)
|
||||
- Meilisearch (port 7700)
|
||||
- API service (port 7080)
|
||||
- Indexer service (port 7081)
|
||||
- Backoffice web UI (port 7082)
|
||||
@@ -48,7 +45,6 @@ This will start:
|
||||
|
||||
- **Backoffice**: http://localhost:7082
|
||||
- **API**: http://localhost:7080
|
||||
- **Meilisearch**: http://localhost:7700
|
||||
|
||||
### Default Credentials
|
||||
|
||||
@@ -62,8 +58,7 @@ The default bootstrap token is configured in your `.env` file. Use this for init
|
||||
|
||||
```bash
|
||||
# Start dependencies
|
||||
cd infra
|
||||
docker compose up -d postgres meilisearch
|
||||
docker compose up -d postgres
|
||||
|
||||
# Run API
|
||||
cd apps/api
|
||||
@@ -86,28 +81,66 @@ The backoffice will be available at http://localhost:7082
|
||||
|
||||
## Features
|
||||
|
||||
### Libraries Management
|
||||
- Create and manage multiple libraries
|
||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
||||
- Real-time file watcher for instant indexing
|
||||
- Full and incremental rebuild options
|
||||
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||
|
||||
### Books Management
|
||||
- Support for CBZ, CBR, and PDF formats
|
||||
- Automatic metadata extraction
|
||||
- Series and volume detection
|
||||
- Full-text search with Meilisearch
|
||||
### Libraries
|
||||
- Multi-library management with per-library configuration
|
||||
- Incremental and full scanning, real-time filesystem watcher
|
||||
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
|
||||
|
||||
### Jobs Monitoring
|
||||
- Real-time job progress tracking
|
||||
- Detailed statistics (scanned, indexed, removed, errors)
|
||||
- Job history and logs
|
||||
- Cancel pending jobs
|
||||
### Books & Series
|
||||
- **Formats**: CBZ, CBR, PDF, EPUB
|
||||
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
|
||||
- Series aggregation with missing volume detection
|
||||
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
|
||||
- CBR → CBZ conversion
|
||||
|
||||
### Search
|
||||
- Full-text search across titles, authors, and series
|
||||
- Library filtering
|
||||
- Real-time suggestions
|
||||
### Reading Progress
|
||||
- Per-book tracking: unread / reading / read with current page
|
||||
- Series-level aggregated reading status
|
||||
- Bulk mark-as-read for series
|
||||
|
||||
### Search & Discovery
|
||||
- Full-text search across titles, authors, and series (PostgreSQL `pg_trgm`)
|
||||
- Author listing with book/series counts
|
||||
- Filtering by reading status, series status, format, metadata provider
|
||||
|
||||
### External Metadata
|
||||
- Search, match, approve/reject workflow with confidence scoring
|
||||
- Batch auto-matching and scheduled metadata refresh
|
||||
- Field locking to protect manual edits from sync
|
||||
|
||||
### Notifications
|
||||
- **Telegram**: real-time notifications via Telegram Bot API
|
||||
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
|
||||
- Book thumbnail images included in notifications where applicable
|
||||
- Test connection from settings
|
||||
|
||||
### 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, interactive charts (recharts), and reading progress
|
||||
- Currently reading & recently read sections
|
||||
- Library, book, series, author management
|
||||
- Live job monitoring, metadata search modals, settings panel
|
||||
- Notification settings with per-event toggle configuration
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -118,8 +151,6 @@ Variables marquées **required** doivent être définies. Les autres ont une val
|
||||
| Variable | Description | Défaut |
|
||||
|----------|-------------|--------|
|
||||
| `DATABASE_URL` | **required** — Connexion PostgreSQL | — |
|
||||
| `MEILI_URL` | **required** — URL Meilisearch | — |
|
||||
| `MEILI_MASTER_KEY` | **required** — Clé maître Meilisearch | — |
|
||||
|
||||
### API
|
||||
|
||||
@@ -165,7 +196,6 @@ stripstream-librarian/
|
||||
│ ├── indexer/ # Rust background indexer
|
||||
│ └── backoffice/ # Next.js web UI
|
||||
├── infra/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── migrations/ # SQL database migrations
|
||||
├── libraries/ # Book storage (mounted volume)
|
||||
└── .env # Environment configuration
|
||||
@@ -207,11 +237,6 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
environment:
|
||||
MEILI_MASTER_KEY: your_meili_master_key # required — change this
|
||||
|
||||
api:
|
||||
image: julienfroidefond32/stripstream-api:latest
|
||||
ports:
|
||||
@@ -222,8 +247,6 @@ services:
|
||||
environment:
|
||||
# --- Required ---
|
||||
DATABASE_URL: postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||
MEILI_URL: http://meilisearch:7700
|
||||
MEILI_MASTER_KEY: your_meili_master_key # must match meilisearch above
|
||||
API_BOOTSTRAP_TOKEN: your_bootstrap_token # required — change this
|
||||
# --- Optional (defaults shown) ---
|
||||
# API_LISTEN_ADDR: 0.0.0.0:7080
|
||||
@@ -238,8 +261,6 @@ services:
|
||||
environment:
|
||||
# --- Required ---
|
||||
DATABASE_URL: postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||
MEILI_URL: http://meilisearch:7700
|
||||
MEILI_MASTER_KEY: your_meili_master_key # must match meilisearch above
|
||||
# --- Optional (defaults shown) ---
|
||||
# INDEXER_LISTEN_ADDR: 0.0.0.0:7081
|
||||
# INDEXER_SCAN_INTERVAL_SECONDS: 5
|
||||
@@ -266,4 +287,4 @@ volumes:
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
@@ -15,10 +15,12 @@ futures = "0.3"
|
||||
image.workspace = true
|
||||
jpeg-decoder.workspace = true
|
||||
lru.workspace = true
|
||||
notifications = { path = "../../crates/notifications" }
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
parsers = { path = "../../crates/parsers" }
|
||||
rand.workspace = true
|
||||
tokio-stream = "0.1"
|
||||
regex = "1"
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -33,3 +35,4 @@ uuid.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
||||
webp.workspace = true
|
||||
scraper.workspace = true
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install sccache for faster builds
|
||||
RUN cargo install sccache --locked
|
||||
ENV RUSTC_WRAPPER=sccache
|
||||
ENV SCCACHE_DIR=/sccache
|
||||
|
||||
# Copy workspace manifests and create dummy source files to cache dependency builds
|
||||
COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||
echo "" > apps/indexer/src/lib.rs && \
|
||||
echo "" > crates/core/src/lib.rs && \
|
||||
echo "" > crates/notifications/src/lib.rs && \
|
||||
echo "" > crates/parsers/src/lib.rs
|
||||
|
||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
cargo build --release -p api && \
|
||||
cargo install sqlx-cli --no-default-features --features postgres --locked
|
||||
|
||||
# Copy real source code and build
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/notifications/src crates/notifications/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
# Build with sccache (cache persisted between builds via Docker cache mount)
|
||||
RUN --mount=type=cache,target=/sccache \
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
cargo build --release -p api && \
|
||||
cargo install sqlx-cli --no-default-features --features postgres --locked
|
||||
cp /app/target/release/api /usr/local/bin/api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -42,7 +59,7 @@ RUN ARCH=$(dpkg --print-architecture) && \
|
||||
cp /tmp/lib/libpdfium.so /usr/local/lib/ && \
|
||||
rm -rf /tmp/pdfium.tgz /tmp/lib /tmp/include && \
|
||||
ldconfig
|
||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||
COPY --from=builder /usr/local/bin/api /usr/local/bin/api
|
||||
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||
COPY infra/migrations /app/migrations
|
||||
COPY apps/api/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::info;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -14,7 +15,14 @@ pub async fn request_counter(
|
||||
next: Next,
|
||||
) -> Response {
|
||||
state.metrics.requests_total.fetch_add(1, Ordering::Relaxed);
|
||||
next.run(req).await
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let start = std::time::Instant::now();
|
||||
let response = next.run(req).await;
|
||||
let status = response.status().as_u16();
|
||||
let elapsed = start.elapsed();
|
||||
info!("{} {} {} {}ms", method, uri.path(), status, elapsed.as_millis());
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn read_rate_limit(
|
||||
|
||||
178
apps/api/src/authors.rs
Normal file
178
apps/api/src/authors.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use axum::{extract::{Query, State}, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListAuthorsQuery {
|
||||
#[schema(value_type = Option<String>, example = "batman")]
|
||||
pub q: Option<String>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 20)]
|
||||
pub limit: Option<i64>,
|
||||
/// Sort order: "name" (default), "books" (most books first)
|
||||
#[schema(value_type = Option<String>, example = "books")]
|
||||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AuthorItem {
|
||||
pub name: String,
|
||||
pub book_count: i64,
|
||||
pub series_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AuthorsPageResponse {
|
||||
pub items: Vec<AuthorItem>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
/// List all unique authors with book/series counts
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/authors",
|
||||
tag = "authors",
|
||||
params(
|
||||
("q" = Option<String>, Query, description = "Search by author name"),
|
||||
("page" = Option<i64>, Query, description = "Page number (1-based)"),
|
||||
("limit" = Option<i64>, Query, description = "Items per page (max 100)"),
|
||||
("sort" = Option<String>, Query, description = "Sort: name (default) or books"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = AuthorsPageResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_authors(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAuthorsQuery>,
|
||||
) -> Result<Json<AuthorsPageResponse>, ApiError> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * limit;
|
||||
let sort = query.sort.as_deref().unwrap_or("name");
|
||||
|
||||
let order_clause = match sort {
|
||||
"books" => "book_count DESC, name ASC",
|
||||
_ => "name ASC",
|
||||
};
|
||||
|
||||
let q_pattern = query.q.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| format!("%{s}%"));
|
||||
|
||||
// Aggregate unique authors from books.authors + books.author + series_metadata.authors
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH all_authors AS (
|
||||
SELECT DISTINCT UNNEST(
|
||||
COALESCE(
|
||||
NULLIF(authors, '{{}}'),
|
||||
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
||||
)
|
||||
) AS name
|
||||
FROM books
|
||||
UNION
|
||||
SELECT DISTINCT UNNEST(authors) AS name
|
||||
FROM series_metadata
|
||||
WHERE authors != '{{}}'
|
||||
),
|
||||
filtered AS (
|
||||
SELECT name FROM all_authors
|
||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||
),
|
||||
book_counts AS (
|
||||
SELECT
|
||||
f.name AS author_name,
|
||||
COUNT(DISTINCT b.id) AS book_count
|
||||
FROM filtered f
|
||||
LEFT JOIN books b ON (
|
||||
f.name = ANY(
|
||||
COALESCE(
|
||||
NULLIF(b.authors, '{{}}'),
|
||||
CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END
|
||||
)
|
||||
)
|
||||
)
|
||||
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
|
||||
f.name,
|
||||
COALESCE(bc.book_count, 0) AS book_count,
|
||||
COALESCE(sc.series_count, 0) AS series_count
|
||||
FROM filtered f
|
||||
LEFT JOIN book_counts bc ON bc.author_name = f.name
|
||||
LEFT JOIN series_counts sc ON sc.author_name = f.name
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
);
|
||||
|
||||
let count_sql = r#"
|
||||
WITH all_authors AS (
|
||||
SELECT DISTINCT UNNEST(
|
||||
COALESCE(
|
||||
NULLIF(authors, '{}'),
|
||||
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
||||
)
|
||||
) AS name
|
||||
FROM books
|
||||
UNION
|
||||
SELECT DISTINCT UNNEST(authors) AS name
|
||||
FROM series_metadata
|
||||
WHERE authors != '{}'
|
||||
)
|
||||
SELECT COUNT(*) AS total
|
||||
FROM all_authors
|
||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||
"#;
|
||||
|
||||
let (rows, count_row) = tokio::join!(
|
||||
sqlx::query(&sql)
|
||||
.bind(q_pattern.as_deref())
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool),
|
||||
sqlx::query(count_sql)
|
||||
.bind(q_pattern.as_deref())
|
||||
.fetch_one(&state.pool)
|
||||
);
|
||||
|
||||
let rows = rows.map_err(|e| ApiError::internal(format!("authors query failed: {e}")))?;
|
||||
let total: i64 = count_row
|
||||
.map_err(|e| ApiError::internal(format!("authors count failed: {e}")))?
|
||||
.get("total");
|
||||
|
||||
let items: Vec<AuthorItem> = rows
|
||||
.iter()
|
||||
.map(|r| AuthorItem {
|
||||
name: r.get("name"),
|
||||
book_count: r.get("book_count"),
|
||||
series_count: r.get("series_count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(AuthorsPageResponse {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
@@ -19,10 +19,19 @@ pub struct ListBooksQuery {
|
||||
pub series: Option<String>,
|
||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||
pub reading_status: Option<String>,
|
||||
/// Filter by exact author name (matches in authors array or scalar author field)
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub author: Option<String>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 50)]
|
||||
pub limit: Option<i64>,
|
||||
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||
#[schema(value_type = Option<String>, example = "latest")]
|
||||
pub sort: Option<String>,
|
||||
/// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name
|
||||
#[schema(value_type = Option<String>, example = "linked")]
|
||||
pub metadata_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -35,6 +44,7 @@ pub struct BookItem {
|
||||
pub format: Option<String>,
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub authors: Vec<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
@@ -66,6 +76,7 @@ pub struct BookDetails {
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub authors: Vec<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
@@ -79,6 +90,12 @@ pub struct BookDetails {
|
||||
pub reading_current_page: Option<i32>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub reading_last_read_at: Option<DateTime<Utc>>,
|
||||
pub summary: Option<String>,
|
||||
pub isbn: Option<String>,
|
||||
pub publish_date: Option<String>,
|
||||
/// Fields locked from external metadata sync
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub locked_fields: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// List books with optional filtering and pagination
|
||||
@@ -88,11 +105,13 @@ pub struct BookDetails {
|
||||
tag = "books",
|
||||
params(
|
||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
|
||||
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
|
||||
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
|
||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
||||
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = BooksPage),
|
||||
@@ -123,40 +142,66 @@ pub async fn list_books(
|
||||
let rs_cond = if reading_statuses.is_some() {
|
||||
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
||||
} else { String::new() };
|
||||
let author_cond = if query.author.is_some() {
|
||||
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR EXISTS (SELECT 1 FROM series_metadata sm WHERE sm.library_id = b.library_id AND sm.name = b.series AND ${p} = ANY(sm.authors)))")
|
||||
} else { String::new() };
|
||||
let metadata_cond = match query.metadata_provider.as_deref() {
|
||||
Some("unlinked") => "AND eml.id IS NULL".to_string(),
|
||||
Some("linked") => "AND eml.id IS NOT NULL".to_string(),
|
||||
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let metadata_links_cte = r#"
|
||||
metadata_links AS (
|
||||
SELECT DISTINCT ON (eml.series_name, eml.library_id)
|
||||
eml.series_name, eml.library_id, eml.provider, eml.id
|
||||
FROM external_metadata_links eml
|
||||
WHERE eml.status = 'approved'
|
||||
ORDER BY eml.series_name, eml.library_id, eml.created_at DESC
|
||||
)"#;
|
||||
|
||||
let count_sql = format!(
|
||||
r#"SELECT COUNT(*) FROM books b
|
||||
r#"WITH {metadata_links_cte}
|
||||
SELECT COUNT(*) FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
AND ($3::text IS NULL OR b.format = $3)
|
||||
{series_cond}
|
||||
{rs_cond}"#
|
||||
{rs_cond}
|
||||
{author_cond}
|
||||
{metadata_cond}"#
|
||||
);
|
||||
|
||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||
"b.updated_at DESC".to_string()
|
||||
} else {
|
||||
"b.volume NULLS LAST, REGEXP_REPLACE(LOWER(b.title), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(b.title), '\\d+'))[1]::int, 0), b.title ASC".to_string()
|
||||
};
|
||||
|
||||
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
||||
WITH {metadata_links_cte}
|
||||
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
AND ($3::text IS NULL OR b.format = $3)
|
||||
{series_cond}
|
||||
{rs_cond}
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'),
|
||||
COALESCE(
|
||||
(REGEXP_MATCH(LOWER(b.title), '\d+'))[1]::int,
|
||||
0
|
||||
),
|
||||
b.title ASC
|
||||
{author_cond}
|
||||
{metadata_cond}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||
"#
|
||||
);
|
||||
@@ -180,6 +225,16 @@ pub async fn list_books(
|
||||
count_builder = count_builder.bind(statuses.clone());
|
||||
data_builder = data_builder.bind(statuses.clone());
|
||||
}
|
||||
if let Some(ref author) = query.author {
|
||||
count_builder = count_builder.bind(author.clone());
|
||||
data_builder = data_builder.bind(author.clone());
|
||||
}
|
||||
if let Some(ref mp) = query.metadata_provider {
|
||||
if mp != "linked" && mp != "unlinked" {
|
||||
count_builder = count_builder.bind(mp.clone());
|
||||
data_builder = data_builder.bind(mp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
|
||||
@@ -200,6 +255,7 @@ pub async fn list_books(
|
||||
format: row.get("format"),
|
||||
title: row.get("title"),
|
||||
author: row.get("author"),
|
||||
authors: row.get::<Vec<String>, _>("authors"),
|
||||
series: row.get("series"),
|
||||
volume: row.get("volume"),
|
||||
language: row.get("language"),
|
||||
@@ -242,7 +298,7 @@ pub async fn get_book(
|
||||
) -> Result<Json<BookDetails>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
|
||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
|
||||
bf.abs_path, bf.format, bf.parse_status,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
@@ -271,6 +327,7 @@ pub async fn get_book(
|
||||
kind: row.get("kind"),
|
||||
title: row.get("title"),
|
||||
author: row.get("author"),
|
||||
authors: row.get::<Vec<String>, _>("authors"),
|
||||
series: row.get("series"),
|
||||
volume: row.get("volume"),
|
||||
language: row.get("language"),
|
||||
@@ -282,550 +339,16 @@ pub async fn get_book(
|
||||
reading_status: row.get("reading_status"),
|
||||
reading_current_page: row.get("reading_current_page"),
|
||||
reading_last_read_at: row.get("reading_last_read_at"),
|
||||
summary: row.get("summary"),
|
||||
isbn: row.get("isbn"),
|
||||
publish_date: row.get("publish_date"),
|
||||
locked_fields: Some(row.get::<serde_json::Value, _>("locked_fields")),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SeriesItem {
|
||||
pub name: String,
|
||||
pub book_count: i64,
|
||||
pub books_read_count: i64,
|
||||
#[schema(value_type = String)]
|
||||
pub first_book_id: Uuid,
|
||||
#[schema(value_type = String)]
|
||||
pub library_id: Uuid,
|
||||
}
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SeriesPage {
|
||||
pub items: Vec<SeriesItem>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListSeriesQuery {
|
||||
#[schema(value_type = Option<String>, example = "dragon")]
|
||||
pub q: Option<String>,
|
||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||
pub reading_status: Option<String>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 50)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// List all series in a library with pagination
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/libraries/{library_id}/series",
|
||||
tag = "books",
|
||||
params(
|
||||
("library_id" = String, Path, description = "Library UUID"),
|
||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = SeriesPage),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_series(
|
||||
State(state): State<AppState>,
|
||||
Path(library_id): Path<Uuid>,
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> Result<Json<SeriesPage>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
|
||||
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
||||
});
|
||||
|
||||
let series_status_expr = r#"CASE
|
||||
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
||||
WHEN sc.books_read_count = 0 THEN 'unread'
|
||||
ELSE 'reading'
|
||||
END"#;
|
||||
|
||||
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
|
||||
let mut p: usize = 1;
|
||||
|
||||
let q_cond = if query.q.is_some() {
|
||||
p += 1; format!("AND sc.name ILIKE ${p}")
|
||||
} else { String::new() };
|
||||
|
||||
let count_rs_cond = if reading_statuses.is_some() {
|
||||
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
||||
} else { String::new() };
|
||||
|
||||
// q_cond et count_rs_cond partagent le même p — le count_sql les réutilise directement
|
||||
let count_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
|
||||
FROM books WHERE library_id = $1
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT sb.name,
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
GROUP BY sb.name
|
||||
)
|
||||
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {count_rs_cond}
|
||||
"#
|
||||
);
|
||||
|
||||
// DATA: mêmes params dans le même ordre, puis limit/offset à la fin
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||
title ASC
|
||||
) as rn
|
||||
FROM books
|
||||
WHERE library_id = $1
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT
|
||||
sb.name,
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
GROUP BY sb.name
|
||||
)
|
||||
SELECT
|
||||
sc.name,
|
||||
sc.book_count,
|
||||
sc.books_read_count,
|
||||
sb.id as first_book_id
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
WHERE TRUE
|
||||
{q_cond}
|
||||
{count_rs_cond}
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||
COALESCE(
|
||||
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
||||
0
|
||||
),
|
||||
sc.name ASC
|
||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||
"#
|
||||
);
|
||||
|
||||
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
||||
|
||||
let mut count_builder = sqlx::query(&count_sql).bind(library_id);
|
||||
let mut data_builder = sqlx::query(&data_sql).bind(library_id);
|
||||
|
||||
if let Some(ref pat) = q_pattern {
|
||||
count_builder = count_builder.bind(pat);
|
||||
data_builder = data_builder.bind(pat);
|
||||
}
|
||||
if let Some(ref statuses) = reading_statuses {
|
||||
count_builder = count_builder.bind(statuses.clone());
|
||||
data_builder = data_builder.bind(statuses.clone());
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
|
||||
let (count_row, rows) = tokio::try_join!(
|
||||
count_builder.fetch_one(&state.pool),
|
||||
data_builder.fetch_all(&state.pool),
|
||||
)?;
|
||||
let total: i64 = count_row.get(0);
|
||||
|
||||
let mut items: Vec<SeriesItem> = rows
|
||||
.iter()
|
||||
.map(|row| SeriesItem {
|
||||
name: row.get("name"),
|
||||
book_count: row.get("book_count"),
|
||||
books_read_count: row.get("books_read_count"),
|
||||
first_book_id: row.get("first_book_id"),
|
||||
library_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SeriesPage {
|
||||
items: std::mem::take(&mut items),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListAllSeriesQuery {
|
||||
#[schema(value_type = Option<String>, example = "dragon")]
|
||||
pub q: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub library_id: Option<Uuid>,
|
||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||
pub reading_status: Option<String>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 50)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// List all series across libraries with optional filtering and pagination
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/series",
|
||||
tag = "books",
|
||||
params(
|
||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = SeriesPage),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_all_series(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAllSeriesQuery>,
|
||||
) -> Result<Json<SeriesPage>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
|
||||
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
||||
});
|
||||
|
||||
let series_status_expr = r#"CASE
|
||||
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
||||
WHEN sc.books_read_count = 0 THEN 'unread'
|
||||
ELSE 'reading'
|
||||
END"#;
|
||||
|
||||
let mut p: usize = 0;
|
||||
|
||||
let lib_cond = if query.library_id.is_some() {
|
||||
p += 1; format!("WHERE library_id = ${p}")
|
||||
} else {
|
||||
"WHERE TRUE".to_string()
|
||||
};
|
||||
|
||||
let q_cond = if query.q.is_some() {
|
||||
p += 1; format!("AND sc.name ILIKE ${p}")
|
||||
} else { String::new() };
|
||||
|
||||
let rs_cond = if reading_statuses.is_some() {
|
||||
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
||||
} else { String::new() };
|
||||
|
||||
let count_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
|
||||
FROM books {lib_cond}
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT sb.name,
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
GROUP BY sb.name
|
||||
)
|
||||
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
|
||||
"#
|
||||
);
|
||||
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
||||
id,
|
||||
library_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||
title ASC
|
||||
) as rn
|
||||
FROM books
|
||||
{lib_cond}
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT
|
||||
sb.name,
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
GROUP BY sb.name
|
||||
)
|
||||
SELECT
|
||||
sc.name,
|
||||
sc.book_count,
|
||||
sc.books_read_count,
|
||||
sb.id as first_book_id,
|
||||
sb.library_id
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
WHERE TRUE
|
||||
{q_cond}
|
||||
{rs_cond}
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||
COALESCE(
|
||||
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
||||
0
|
||||
),
|
||||
sc.name ASC
|
||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||
"#
|
||||
);
|
||||
|
||||
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
||||
|
||||
let mut count_builder = sqlx::query(&count_sql);
|
||||
let mut data_builder = sqlx::query(&data_sql);
|
||||
|
||||
if let Some(lib_id) = query.library_id {
|
||||
count_builder = count_builder.bind(lib_id);
|
||||
data_builder = data_builder.bind(lib_id);
|
||||
}
|
||||
if let Some(ref pat) = q_pattern {
|
||||
count_builder = count_builder.bind(pat);
|
||||
data_builder = data_builder.bind(pat);
|
||||
}
|
||||
if let Some(ref statuses) = reading_statuses {
|
||||
count_builder = count_builder.bind(statuses.clone());
|
||||
data_builder = data_builder.bind(statuses.clone());
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
|
||||
let (count_row, rows) = tokio::try_join!(
|
||||
count_builder.fetch_one(&state.pool),
|
||||
data_builder.fetch_all(&state.pool),
|
||||
)?;
|
||||
let total: i64 = count_row.get(0);
|
||||
|
||||
let items: Vec<SeriesItem> = rows
|
||||
.iter()
|
||||
.map(|row| SeriesItem {
|
||||
name: row.get("name"),
|
||||
book_count: row.get("book_count"),
|
||||
books_read_count: row.get("books_read_count"),
|
||||
first_book_id: row.get("first_book_id"),
|
||||
library_id: row.get("library_id"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SeriesPage {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct OngoingQuery {
|
||||
#[schema(value_type = Option<i64>, example = 10)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// List ongoing series (partially read, sorted by most recent activity)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/series/ongoing",
|
||||
tag = "books",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = Vec<SeriesItem>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn ongoing_series(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<OngoingQuery>,
|
||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
WITH series_stats AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
||||
COUNT(*) AS book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
|
||||
MAX(brp.last_read_at) AS last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
HAVING (
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
|
||||
)
|
||||
),
|
||||
first_books AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(series, ''), 'unclassified') AS name,
|
||||
id,
|
||||
library_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||
title ASC
|
||||
) AS rn
|
||||
FROM books
|
||||
)
|
||||
SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id, fb.library_id
|
||||
FROM series_stats ss
|
||||
JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1
|
||||
ORDER BY ss.last_read_at DESC NULLS LAST
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<SeriesItem> = rows
|
||||
.iter()
|
||||
.map(|row| SeriesItem {
|
||||
name: row.get("name"),
|
||||
book_count: row.get("book_count"),
|
||||
books_read_count: row.get("books_read_count"),
|
||||
first_book_id: row.get("first_book_id"),
|
||||
library_id: row.get("library_id"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// List next unread book for each ongoing series (sorted by most recent activity)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/books/ongoing",
|
||||
tag = "books",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = Vec<BookItem>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn ongoing_books(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<OngoingQuery>,
|
||||
) -> Result<Json<Vec<BookItem>>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
WITH ongoing_series AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
||||
MAX(brp.last_read_at) AS series_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
HAVING (
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
|
||||
)
|
||||
),
|
||||
next_books AS (
|
||||
SELECT
|
||||
b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume,
|
||||
b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at,
|
||||
os.series_last_read_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
ORDER BY b.volume NULLS LAST, b.title
|
||||
) AS rn
|
||||
FROM books b
|
||||
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
WHERE COALESCE(brp.status, 'unread') != 'read'
|
||||
)
|
||||
SELECT id, library_id, kind, format, title, author, series, volume, language, page_count,
|
||||
thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at
|
||||
FROM next_books
|
||||
WHERE rn = 1
|
||||
ORDER BY series_last_read_at DESC NULLS LAST
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<BookItem> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||
BookItem {
|
||||
id: row.get("id"),
|
||||
library_id: row.get("library_id"),
|
||||
kind: row.get("kind"),
|
||||
format: row.get("format"),
|
||||
title: row.get("title"),
|
||||
author: row.get("author"),
|
||||
series: row.get("series"),
|
||||
volume: row.get("volume"),
|
||||
language: row.get("language"),
|
||||
page_count: row.get("page_count"),
|
||||
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))),
|
||||
updated_at: row.get("updated_at"),
|
||||
reading_status: row.get("reading_status"),
|
||||
reading_current_page: row.get("reading_current_page"),
|
||||
reading_last_read_at: row.get("reading_last_read_at"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
fn remap_libraries_path(path: &str) -> String {
|
||||
pub(crate) fn remap_libraries_path(path: &str) -> String {
|
||||
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
||||
if path.starts_with("/libraries/") {
|
||||
return path.replacen("/libraries", &root, 1);
|
||||
@@ -843,6 +366,8 @@ fn unmap_libraries_path(path: &str) -> String {
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
// ─── Convert CBR → CBZ ───────────────────────────────────────────────────────
|
||||
|
||||
/// Enqueue a CBR → CBZ conversion job for a single book
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -932,6 +457,119 @@ pub async fn convert_book(
|
||||
Ok(Json(crate::index_jobs::map_row(job_row)))
|
||||
}
|
||||
|
||||
// ─── Metadata editing ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateBookRequest {
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub isbn: Option<String>,
|
||||
pub publish_date: Option<String>,
|
||||
/// Fields locked from external metadata sync
|
||||
#[serde(default)]
|
||||
pub locked_fields: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Update metadata for a specific book
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/books/{id}",
|
||||
tag = "books",
|
||||
params(("id" = String, Path, description = "Book UUID")),
|
||||
request_body = UpdateBookRequest,
|
||||
responses(
|
||||
(status = 200, body = BookDetails),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Book not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_book(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateBookRequest>,
|
||||
) -> Result<Json<BookDetails>, ApiError> {
|
||||
let title = body.title.trim().to_string();
|
||||
if title.is_empty() {
|
||||
return Err(ApiError::bad_request("title cannot be empty"));
|
||||
}
|
||||
let author = body.author.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let authors: Vec<String> = body.authors.iter()
|
||||
.map(|a| a.trim().to_string())
|
||||
.filter(|a| !a.is_empty())
|
||||
.collect();
|
||||
let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
|
||||
let summary = body.summary.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let isbn = body.isbn.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let publish_date = body.publish_date.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
UPDATE books
|
||||
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7,
|
||||
summary = $8, isbn = $9, publish_date = $10, locked_fields = $11, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
||||
summary, isbn, publish_date,
|
||||
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
|
||||
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
|
||||
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&title)
|
||||
.bind(&author)
|
||||
.bind(&authors)
|
||||
.bind(&series)
|
||||
.bind(body.volume)
|
||||
.bind(&language)
|
||||
.bind(&summary)
|
||||
.bind(&isbn)
|
||||
.bind(&publish_date)
|
||||
.bind(&locked_fields)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
|
||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||
|
||||
Ok(Json(BookDetails {
|
||||
id: row.get("id"),
|
||||
library_id: row.get("library_id"),
|
||||
kind: row.get("kind"),
|
||||
title: row.get("title"),
|
||||
author: row.get("author"),
|
||||
authors: row.get::<Vec<String>, _>("authors"),
|
||||
series: row.get("series"),
|
||||
volume: row.get("volume"),
|
||||
language: row.get("language"),
|
||||
page_count: row.get("page_count"),
|
||||
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)),
|
||||
file_path: None,
|
||||
file_format: None,
|
||||
file_parse_status: None,
|
||||
reading_status: row.get("reading_status"),
|
||||
reading_current_page: row.get("reading_current_page"),
|
||||
reading_last_read_at: row.get("reading_last_read_at"),
|
||||
summary: row.get("summary"),
|
||||
isbn: row.get("isbn"),
|
||||
publish_date: row.get("publish_date"),
|
||||
locked_fields: Some(locked_fields),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Thumbnail ────────────────────────────────────────────────────────────────
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
@@ -993,12 +631,17 @@ pub async fn get_thumbnail(
|
||||
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||
};
|
||||
|
||||
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
if let Ok(v) = HeaderValue::from_str(&etag_value) {
|
||||
headers.insert(header::ETAG, v);
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, headers, Body::from(data)))
|
||||
}
|
||||
|
||||
@@ -83,3 +83,9 @@ impl From<std::io::Error> for ApiError {
|
||||
Self::internal(format!("IO error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ApiError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
Self::internal(format!("HTTP client error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct RebuildRequest {
|
||||
pub library_id: Option<Uuid>,
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
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)]
|
||||
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
|
||||
) -> Result<Json<IndexJobResponse>, ApiError> {
|
||||
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 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();
|
||||
|
||||
sqlx::query(
|
||||
|
||||
398
apps/api/src/komga.rs
Normal file
398
apps/api/src/komga.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use axum::{extract::State, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Komga API types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBooksResponse {
|
||||
content: Vec<KomgaBook>,
|
||||
#[serde(rename = "totalPages")]
|
||||
total_pages: i32,
|
||||
number: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBook {
|
||||
name: String,
|
||||
#[serde(rename = "seriesTitle")]
|
||||
series_title: String,
|
||||
metadata: KomgaBookMetadata,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBookMetadata {
|
||||
title: String,
|
||||
}
|
||||
|
||||
// ─── Request / Response ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct KomgaSyncRequest {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct KomgaSyncResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
pub newly_marked: i64,
|
||||
pub matched_books: Vec<String>,
|
||||
pub newly_marked_books: Vec<String>,
|
||||
pub unmatched: Vec<String>,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct KomgaSyncReportSummary {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
pub newly_marked: i64,
|
||||
pub unmatched_count: i32,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sync read books from a Komga server
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/komga/sync",
|
||||
tag = "komga",
|
||||
request_body = KomgaSyncRequest,
|
||||
responses(
|
||||
(status = 200, body = KomgaSyncResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Komga connection or sync error"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn sync_komga_read_books(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<KomgaSyncRequest>,
|
||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||
let url = body.url.trim_end_matches('/').to_string();
|
||||
if url.is_empty() {
|
||||
return Err(ApiError::bad_request("url is required"));
|
||||
}
|
||||
|
||||
// Build HTTP client with basic auth
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
// Paginate through all READ books from Komga
|
||||
let mut komga_books: Vec<(String, String)> = Vec::new(); // (series_title, title)
|
||||
let mut page = 0;
|
||||
let page_size = 100;
|
||||
let max_pages = 500;
|
||||
|
||||
loop {
|
||||
let resp = client
|
||||
.post(format!("{url}/api/v1/books/list?page={page}&size={page_size}"))
|
||||
.basic_auth(&body.username, Some(&body.password))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({ "condition": { "readStatus": { "operator": "is", "value": "READ" } } }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Komga request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::internal(format!(
|
||||
"Komga returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let data: KomgaBooksResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to parse Komga response: {e}")))?;
|
||||
|
||||
for book in &data.content {
|
||||
let title = if !book.metadata.title.is_empty() {
|
||||
&book.metadata.title
|
||||
} else {
|
||||
&book.name
|
||||
};
|
||||
komga_books.push((book.series_title.clone(), title.clone()));
|
||||
}
|
||||
|
||||
if data.number >= data.total_pages - 1 || page >= max_pages {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
let total_komga_read = komga_books.len() as i64;
|
||||
|
||||
// Build local lookup maps
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, title, COALESCE(series, '') as series, LOWER(title) as title_lower, LOWER(COALESCE(series, '')) as series_lower FROM books",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
type BookEntry = (Uuid, String, String);
|
||||
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
|
||||
let mut primary_map: HashMap<(String, String), Vec<BookEntry>> = HashMap::new();
|
||||
// Secondary: title_lower -> Vec<(Uuid, title, series)>
|
||||
let mut secondary_map: HashMap<String, Vec<BookEntry>> = HashMap::new();
|
||||
|
||||
for row in &rows {
|
||||
let id: Uuid = row.get("id");
|
||||
let title: String = row.get("title");
|
||||
let series: String = row.get("series");
|
||||
let title_lower: String = row.get("title_lower");
|
||||
let series_lower: String = row.get("series_lower");
|
||||
let entry = (id, title, series);
|
||||
|
||||
primary_map
|
||||
.entry((series_lower, title_lower.clone()))
|
||||
.or_default()
|
||||
.push(entry.clone());
|
||||
secondary_map.entry(title_lower).or_default().push(entry);
|
||||
}
|
||||
|
||||
// Match Komga books to local books
|
||||
let mut matched_entries: Vec<(Uuid, String)> = Vec::new(); // (id, display_title)
|
||||
let mut unmatched: Vec<String> = Vec::new();
|
||||
|
||||
for (series_title, title) in &komga_books {
|
||||
let title_lower = title.to_lowercase();
|
||||
let series_lower = series_title.to_lowercase();
|
||||
|
||||
let found = if let Some(entries) = primary_map.get(&(series_lower.clone(), title_lower.clone())) {
|
||||
Some(entries)
|
||||
} else {
|
||||
secondary_map.get(&title_lower)
|
||||
};
|
||||
|
||||
if let Some(entries) = found {
|
||||
for (id, local_title, local_series) in entries {
|
||||
let display = if local_series.is_empty() {
|
||||
local_title.clone()
|
||||
} else {
|
||||
format!("{local_series} - {local_title}")
|
||||
};
|
||||
matched_entries.push((*id, display));
|
||||
}
|
||||
} else if series_title.is_empty() {
|
||||
unmatched.push(title.clone());
|
||||
} else {
|
||||
unmatched.push(format!("{series_title} - {title}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by ID
|
||||
matched_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
matched_entries.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let matched_ids: Vec<Uuid> = matched_entries.iter().map(|(id, _)| *id).collect();
|
||||
let matched = matched_ids.len() as i64;
|
||||
let mut already_read: i64 = 0;
|
||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||
|
||||
if !matched_ids.is_empty() {
|
||||
// Get already-read book IDs
|
||||
let ar_rows = sqlx::query(
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for row in &ar_rows {
|
||||
already_read_ids.insert(row.get("book_id"));
|
||||
}
|
||||
already_read = already_read_ids.len() as i64;
|
||||
|
||||
// Bulk upsert all matched books as read
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE book_reading_progress.status != 'read'
|
||||
"#,
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let newly_marked = matched - already_read;
|
||||
|
||||
// Build matched_books and newly_marked_books lists
|
||||
let mut newly_marked_books: Vec<String> = Vec::new();
|
||||
let mut matched_books: Vec<String> = Vec::new();
|
||||
for (id, title) in &matched_entries {
|
||||
if !already_read_ids.contains(id) {
|
||||
newly_marked_books.push(title.clone());
|
||||
}
|
||||
matched_books.push(title.clone());
|
||||
}
|
||||
// Sort: newly marked first, then alphabetical
|
||||
let newly_marked_set: std::collections::HashSet<&str> =
|
||||
newly_marked_books.iter().map(|s| s.as_str()).collect();
|
||||
matched_books.sort_by(|a, b| {
|
||||
let a_new = newly_marked_set.contains(a.as_str());
|
||||
let b_new = newly_marked_set.contains(b.as_str());
|
||||
b_new.cmp(&a_new).then(a.cmp(b))
|
||||
});
|
||||
newly_marked_books.sort();
|
||||
|
||||
// Save sync report
|
||||
let unmatched_json = serde_json::to_value(&unmatched).unwrap_or_default();
|
||||
let matched_books_json = serde_json::to_value(&matched_books).unwrap_or_default();
|
||||
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
||||
let report_row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(total_komga_read)
|
||||
.bind(matched)
|
||||
.bind(already_read)
|
||||
.bind(newly_marked)
|
||||
.bind(&matched_books_json)
|
||||
.bind(&newly_marked_books_json)
|
||||
.bind(&unmatched_json)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: report_row.get("id"),
|
||||
komga_url: url,
|
||||
total_komga_read,
|
||||
matched,
|
||||
already_read,
|
||||
newly_marked,
|
||||
matched_books,
|
||||
newly_marked_books,
|
||||
unmatched,
|
||||
created_at: report_row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List Komga sync reports (most recent first)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/komga/reports",
|
||||
tag = "komga",
|
||||
responses(
|
||||
(status = 200, body = Vec<KomgaSyncReportSummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_sync_reports(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
|
||||
jsonb_array_length(unmatched) as unmatched_count, created_at
|
||||
FROM komga_sync_reports
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let reports: Vec<KomgaSyncReportSummary> = rows
|
||||
.iter()
|
||||
.map(|row| KomgaSyncReportSummary {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
newly_marked: row.get("newly_marked"),
|
||||
unmatched_count: row.get("unmatched_count"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(reports))
|
||||
}
|
||||
|
||||
/// Get a specific sync report with full unmatched list
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/komga/reports/{id}",
|
||||
tag = "komga",
|
||||
params(("id" = String, Path, description = "Report UUID")),
|
||||
responses(
|
||||
(status = 200, body = KomgaSyncResponse),
|
||||
(status = 404, description = "Report not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_sync_report(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||
FROM komga_sync_reports
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::not_found("report not found"))?;
|
||||
|
||||
let matched_books_json: serde_json::Value = row.try_get("matched_books").unwrap_or(serde_json::Value::Array(vec![]));
|
||||
let matched_books: Vec<String> = serde_json::from_value(matched_books_json).unwrap_or_default();
|
||||
let newly_marked_books_json: serde_json::Value = row.try_get("newly_marked_books").unwrap_or(serde_json::Value::Array(vec![]));
|
||||
let newly_marked_books: Vec<String> = serde_json::from_value(newly_marked_books_json).unwrap_or_default();
|
||||
let unmatched_json: serde_json::Value = row.get("unmatched");
|
||||
let unmatched: Vec<String> = serde_json::from_value(unmatched_json).unwrap_or_default();
|
||||
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
newly_marked: row.get("newly_marked"),
|
||||
matched_books,
|
||||
newly_marked_books,
|
||||
unmatched,
|
||||
created_at: row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
@@ -21,6 +21,15 @@ pub struct LibraryResponse {
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub watcher_enabled: bool,
|
||||
pub metadata_provider: Option<String>,
|
||||
pub fallback_metadata_provider: Option<String>,
|
||||
pub metadata_refresh_mode: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub series_count: i64,
|
||||
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
||||
#[schema(value_type = Vec<String>)]
|
||||
pub thumbnail_book_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -39,14 +48,27 @@ pub struct CreateLibraryRequest {
|
||||
responses(
|
||||
(status = 200, body = Vec<LibraryResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled,
|
||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
||||
"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(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
|
||||
COALESCE((
|
||||
SELECT ARRAY_AGG(first_id ORDER BY series_name)
|
||||
FROM (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
|
||||
b.id as first_id
|
||||
FROM books b
|
||||
WHERE b.library_id = l.id
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
|
||||
b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5
|
||||
) sub
|
||||
), ARRAY[]::uuid[]) as thumbnail_book_ids
|
||||
FROM libraries l ORDER BY l.created_at DESC"
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
@@ -60,10 +82,16 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count: row.get("book_count"),
|
||||
series_count: row.get("series_count"),
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
watcher_enabled: row.get("watcher_enabled"),
|
||||
metadata_provider: row.get("metadata_provider"),
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -111,10 +139,16 @@ pub async fn create_library(
|
||||
root_path,
|
||||
enabled: true,
|
||||
book_count: 0,
|
||||
series_count: 0,
|
||||
monitor_enabled: false,
|
||||
scan_mode: "manual".to_string(),
|
||||
next_scan_at: None,
|
||||
watcher_enabled: false,
|
||||
metadata_provider: None,
|
||||
fallback_metadata_provider: None,
|
||||
metadata_refresh_mode: "manual".to_string(),
|
||||
next_metadata_refresh_at: None,
|
||||
thumbnail_book_ids: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -186,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
|
||||
(status = 200, body = IndexJobResponse),
|
||||
(status = 404, description = "Library not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
@@ -206,7 +239,8 @@ pub async fn scan_library(
|
||||
}
|
||||
|
||||
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
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -235,6 +269,8 @@ pub struct UpdateMonitoringRequest {
|
||||
#[schema(value_type = String, example = "hourly")]
|
||||
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
|
||||
pub watcher_enabled: Option<bool>,
|
||||
#[schema(value_type = Option<String>, example = "daily")]
|
||||
pub metadata_refresh_mode: Option<String>, // 'manual', 'hourly', 'daily', 'weekly'
|
||||
}
|
||||
|
||||
/// Update monitoring settings for a library
|
||||
@@ -265,6 +301,12 @@ pub async fn update_monitoring(
|
||||
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
|
||||
}
|
||||
|
||||
// Validate metadata_refresh_mode
|
||||
let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual");
|
||||
if !valid_modes.contains(&metadata_refresh_mode) {
|
||||
return Err(ApiError::bad_request("metadata_refresh_mode must be one of: manual, hourly, daily, weekly"));
|
||||
}
|
||||
|
||||
// Calculate next_scan_at if monitoring is enabled
|
||||
let next_scan_at = if input.monitor_enabled {
|
||||
let interval_minutes = match input.scan_mode.as_str() {
|
||||
@@ -278,16 +320,31 @@ pub async fn update_monitoring(
|
||||
None
|
||||
};
|
||||
|
||||
// Calculate next_metadata_refresh_at
|
||||
let next_metadata_refresh_at = if metadata_refresh_mode != "manual" {
|
||||
let interval_minutes = match metadata_refresh_mode {
|
||||
"hourly" => 60,
|
||||
"daily" => 1440,
|
||||
"weekly" => 10080,
|
||||
_ => 1440,
|
||||
};
|
||||
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled"
|
||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(input.monitor_enabled)
|
||||
.bind(input.scan_mode)
|
||||
.bind(next_scan_at)
|
||||
.bind(watcher_enabled)
|
||||
.bind(metadata_refresh_mode)
|
||||
.bind(next_metadata_refresh_at)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -300,15 +357,121 @@ pub async fn update_monitoring(
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT b.id FROM books b
|
||||
WHERE b.library_id = $1
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(LibraryResponse {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count,
|
||||
series_count,
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
watcher_enabled: row.get("watcher_enabled"),
|
||||
metadata_provider: row.get("metadata_provider"),
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateMetadataProviderRequest {
|
||||
pub metadata_provider: Option<String>,
|
||||
pub fallback_metadata_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// Update the metadata provider for a library
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/libraries/{id}/metadata-provider",
|
||||
tag = "libraries",
|
||||
params(
|
||||
("id" = String, Path, description = "Library UUID"),
|
||||
),
|
||||
request_body = UpdateMetadataProviderRequest,
|
||||
responses(
|
||||
(status = 200, body = LibraryResponse),
|
||||
(status = 404, description = "Library not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_metadata_provider(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(library_id): AxumPath<Uuid>,
|
||||
Json(input): Json<UpdateMetadataProviderRequest>,
|
||||
) -> Result<Json<LibraryResponse>, ApiError> {
|
||||
let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(provider)
|
||||
.bind(fallback)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = result else {
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
};
|
||||
|
||||
let book_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM books WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT b.id FROM books b
|
||||
WHERE b.library_id = $1
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(LibraryResponse {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count,
|
||||
series_count,
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
watcher_enabled: row.get("watcher_enabled"),
|
||||
metadata_provider: row.get("metadata_provider"),
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
mod auth;
|
||||
mod authors;
|
||||
mod books;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod index_jobs;
|
||||
mod komga;
|
||||
mod libraries;
|
||||
mod metadata;
|
||||
mod metadata_batch;
|
||||
mod metadata_refresh;
|
||||
mod metadata_providers;
|
||||
mod api_middleware;
|
||||
mod openapi;
|
||||
mod pages;
|
||||
mod prowlarr;
|
||||
mod qbittorrent;
|
||||
mod reading_progress;
|
||||
mod search;
|
||||
mod series;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod stats;
|
||||
mod telegram;
|
||||
mod thumbnails;
|
||||
mod tokens;
|
||||
|
||||
@@ -66,8 +77,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let state = AppState {
|
||||
pool,
|
||||
bootstrap_token: Arc::from(config.api_bootstrap_token),
|
||||
meili_url: Arc::from(config.meili_url),
|
||||
meili_master_key: Arc::from(config.meili_master_key),
|
||||
page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))),
|
||||
page_render_limit: Arc::new(Semaphore::new(concurrent_renders)),
|
||||
metrics: Arc::new(Metrics::new()),
|
||||
@@ -79,11 +88,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
let admin_routes = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
|
||||
.route("/libraries", axum::routing::post(libraries::create_library))
|
||||
.route("/libraries/:id", delete(libraries::delete_library))
|
||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
||||
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
||||
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
||||
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
||||
@@ -97,6 +108,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
||||
.route("/telegram/test", get(telegram::test_telegram))
|
||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||
.route("/komga/reports", get(komga::list_sync_reports))
|
||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||
.route("/metadata/search", axum::routing::post(metadata::search_metadata))
|
||||
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
|
||||
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
|
||||
.route("/metadata/reject/:id", axum::routing::post(metadata::reject_metadata))
|
||||
.route("/metadata/links", get(metadata::get_metadata_links))
|
||||
.route("/metadata/missing/:id", get(metadata::get_missing_books))
|
||||
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
|
||||
.route("/metadata/batch", axum::routing::post(metadata_batch::start_batch))
|
||||
.route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report))
|
||||
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
|
||||
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
|
||||
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
|
||||
.merge(settings::settings_routes())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -104,16 +135,23 @@ async fn main() -> anyhow::Result<()> {
|
||||
));
|
||||
|
||||
let read_routes = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries))
|
||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||
.route("/books", get(books::list_books))
|
||||
.route("/books/ongoing", get(books::ongoing_books))
|
||||
.route("/books/ongoing", get(series::ongoing_books))
|
||||
.route("/books/:id", get(books::get_book))
|
||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||
.route("/libraries/:library_id/series", get(books::list_series))
|
||||
.route("/series", get(books::list_all_series))
|
||||
.route("/series/ongoing", get(books::ongoing_series))
|
||||
.route("/libraries/:library_id/series", get(series::list_series))
|
||||
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
|
||||
.route("/series", get(series::list_all_series))
|
||||
.route("/series/ongoing", get(series::ongoing_series))
|
||||
.route("/series/statuses", get(series::series_statuses))
|
||||
.route("/series/provider-statuses", get(series::provider_statuses))
|
||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||
.route("/authors", get(authors::list_authors))
|
||||
.route("/stats", get(stats::get_stats))
|
||||
.route("/search", get(search::search_books))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
|
||||
1097
apps/api/src/metadata.rs
Normal file
1097
apps/api/src/metadata.rs
Normal file
File diff suppressed because it is too large
Load Diff
1145
apps/api/src/metadata_batch.rs
Normal file
1145
apps/api/src/metadata_batch.rs
Normal file
File diff suppressed because it is too large
Load Diff
342
apps/api/src/metadata_providers/anilist.rs
Normal file
342
apps/api/src/metadata_providers/anilist.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||
|
||||
pub struct AniListProvider;
|
||||
|
||||
impl MetadataProvider for AniListProvider {
|
||||
fn name(&self) -> &str {
|
||||
"anilist"
|
||||
}
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let query = query.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||
}
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let external_id = external_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||
}
|
||||
}
|
||||
|
||||
const SEARCH_QUERY: &str = r#"
|
||||
query ($search: String) {
|
||||
Page(perPage: 20) {
|
||||
media(search: $search, type: MANGA, sort: SEARCH_MATCH) {
|
||||
id
|
||||
title { romaji english native }
|
||||
description(asHtml: false)
|
||||
coverImage { large medium }
|
||||
startDate { year }
|
||||
status
|
||||
volumes
|
||||
chapters
|
||||
staff { edges { node { name { full } } role } }
|
||||
siteUrl
|
||||
genres
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const DETAIL_QUERY: &str = r#"
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: MANGA) {
|
||||
id
|
||||
title { romaji english native }
|
||||
description(asHtml: false)
|
||||
coverImage { large medium }
|
||||
startDate { year }
|
||||
status
|
||||
volumes
|
||||
chapters
|
||||
staff { edges { node { name { full } } role } }
|
||||
siteUrl
|
||||
genres
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
async fn graphql_request(
|
||||
client: &reqwest::Client,
|
||||
query: &str,
|
||||
variables: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = client
|
||||
.post("https://graphql.anilist.co")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AniList request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("AniList returned {status}: {text}"));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse AniList response: {e}"))
|
||||
}
|
||||
|
||||
async fn search_series_impl(
|
||||
query: &str,
|
||||
_config: &ProviderConfig,
|
||||
) -> Result<Vec<SeriesCandidate>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
let data = graphql_request(
|
||||
&client,
|
||||
SEARCH_QUERY,
|
||||
serde_json::json!({ "search": query }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let media = match data
|
||||
.get("data")
|
||||
.and_then(|d| d.get("Page"))
|
||||
.and_then(|p| p.get("media"))
|
||||
.and_then(|m| m.as_array())
|
||||
{
|
||||
Some(media) => media,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut candidates: Vec<SeriesCandidate> = media
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m.get("id").and_then(|id| id.as_i64())?;
|
||||
let title_obj = m.get("title")?;
|
||||
let title = title_obj
|
||||
.get("english")
|
||||
.and_then(|t| t.as_str())
|
||||
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))?
|
||||
.to_string();
|
||||
|
||||
let description = m
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(|d| d.replace("\\n", "\n").trim().to_string())
|
||||
.filter(|d| !d.is_empty());
|
||||
|
||||
let cover_url = m
|
||||
.get("coverImage")
|
||||
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let start_year = m
|
||||
.get("startDate")
|
||||
.and_then(|sd| sd.get("year"))
|
||||
.and_then(|y| y.as_i64())
|
||||
.map(|y| y as i32);
|
||||
|
||||
let volumes = m
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32);
|
||||
|
||||
let chapters = m
|
||||
.get("chapters")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32);
|
||||
|
||||
let status = m
|
||||
.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("UNKNOWN")
|
||||
.to_string();
|
||||
|
||||
let site_url = m
|
||||
.get("siteUrl")
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let authors = extract_authors(m);
|
||||
|
||||
let confidence = compute_confidence(&title, &query_lower);
|
||||
|
||||
// Use volumes if known, otherwise fall back to chapters count
|
||||
let (total_volumes, volume_source) = match volumes {
|
||||
Some(v) => (Some(v), "volumes"),
|
||||
None => match chapters {
|
||||
Some(c) => (Some(c), "chapters"),
|
||||
None => (None, "unknown"),
|
||||
},
|
||||
};
|
||||
|
||||
Some(SeriesCandidate {
|
||||
external_id: id.to_string(),
|
||||
title,
|
||||
authors,
|
||||
description,
|
||||
publishers: vec![],
|
||||
start_year,
|
||||
total_volumes,
|
||||
cover_url,
|
||||
external_url: site_url,
|
||||
confidence,
|
||||
metadata_json: serde_json::json!({
|
||||
"status": status,
|
||||
"chapters": chapters,
|
||||
"volumes": volumes,
|
||||
"volume_source": volume_source,
|
||||
}),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||
candidates.truncate(10);
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn get_series_books_impl(
|
||||
external_id: &str,
|
||||
_config: &ProviderConfig,
|
||||
) -> Result<Vec<BookCandidate>, String> {
|
||||
let id: i64 = external_id
|
||||
.parse()
|
||||
.map_err(|_| "invalid AniList ID".to_string())?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
let data = graphql_request(
|
||||
&client,
|
||||
DETAIL_QUERY,
|
||||
serde_json::json!({ "id": id }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let media = match data.get("data").and_then(|d| d.get("Media")) {
|
||||
Some(m) => m,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let title_obj = media.get("title").cloned().unwrap_or(serde_json::json!({}));
|
||||
let title = title_obj
|
||||
.get("english")
|
||||
.and_then(|t| t.as_str())
|
||||
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let volumes = media
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32);
|
||||
|
||||
let chapters = media
|
||||
.get("chapters")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32);
|
||||
|
||||
// Use volumes if known, otherwise fall back to chapters count
|
||||
let total = volumes.or(chapters);
|
||||
|
||||
let cover_url = media
|
||||
.get("coverImage")
|
||||
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let description = media
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(|d| d.replace("\\n", "\n").trim().to_string());
|
||||
|
||||
let authors = extract_authors(media);
|
||||
|
||||
// AniList doesn't have per-volume data — generate entries from volumes count (or chapters as fallback)
|
||||
let mut books = Vec::new();
|
||||
if let Some(total) = total {
|
||||
for vol in 1..=total {
|
||||
books.push(BookCandidate {
|
||||
external_book_id: format!("{}-vol-{}", external_id, vol),
|
||||
title: format!("{} Vol. {}", title, vol),
|
||||
volume_number: Some(vol),
|
||||
authors: authors.clone(),
|
||||
isbn: None,
|
||||
summary: if vol == 1 { description.clone() } else { None },
|
||||
cover_url: if vol == 1 { cover_url.clone() } else { None },
|
||||
page_count: None,
|
||||
language: Some("ja".to_string()),
|
||||
publish_date: None,
|
||||
metadata_json: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
fn extract_authors(media: &serde_json::Value) -> Vec<String> {
|
||||
let mut authors = Vec::new();
|
||||
if let Some(edges) = media
|
||||
.get("staff")
|
||||
.and_then(|s| s.get("edges"))
|
||||
.and_then(|e| e.as_array())
|
||||
{
|
||||
for edge in edges {
|
||||
let role = edge
|
||||
.get("role")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("");
|
||||
let role_lower = role.to_lowercase();
|
||||
if role_lower.contains("story") || role_lower.contains("art") || role_lower.contains("original") {
|
||||
if let Some(name) = edge
|
||||
.get("node")
|
||||
.and_then(|n| n.get("name"))
|
||||
.and_then(|n| n.get("full"))
|
||||
.and_then(|f| f.as_str())
|
||||
{
|
||||
if !authors.contains(&name.to_string()) {
|
||||
authors.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
authors
|
||||
}
|
||||
|
||||
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||
let title_lower = title.to_lowercase();
|
||||
if title_lower == query {
|
||||
1.0
|
||||
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||
0.8
|
||||
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||
0.7
|
||||
} else {
|
||||
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||
let max_len = query.len().max(title_lower.len()).max(1);
|
||||
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||
}
|
||||
}
|
||||
671
apps/api/src/metadata_providers/bedetheque.rs
Normal file
671
apps/api/src/metadata_providers/bedetheque.rs
Normal file
@@ -0,0 +1,671 @@
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||
|
||||
pub struct BedethequeProvider;
|
||||
|
||||
impl MetadataProvider for BedethequeProvider {
|
||||
fn name(&self) -> &str {
|
||||
"bedetheque"
|
||||
}
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let query = query.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||
}
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let external_id = external_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.user_agent("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0")
|
||||
.default_headers({
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
h.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
h.insert(
|
||||
reqwest::header::ACCEPT_LANGUAGE,
|
||||
"fr-FR,fr;q=0.9,en;q=0.5".parse().unwrap(),
|
||||
);
|
||||
h.insert(reqwest::header::REFERER, "https://www.bedetheque.com/".parse().unwrap());
|
||||
h
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// Remove diacritics for URL construction (bedetheque uses ASCII slugs)
|
||||
fn normalize_for_url(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => 'e',
|
||||
'à' | 'â' | 'ä' | 'À' | 'Â' | 'Ä' => 'a',
|
||||
'ù' | 'û' | 'ü' | 'Ù' | 'Û' | 'Ü' => 'u',
|
||||
'ô' | 'ö' | 'Ô' | 'Ö' => 'o',
|
||||
'î' | 'ï' | 'Î' | 'Ï' => 'i',
|
||||
'ç' | 'Ç' => 'c',
|
||||
'ñ' | 'Ñ' => 'n',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
b' ' => result.push('+'),
|
||||
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn search_series_impl(
|
||||
query: &str,
|
||||
_config: &ProviderConfig,
|
||||
) -> Result<Vec<SeriesCandidate>, String> {
|
||||
let client = build_client()?;
|
||||
|
||||
// Use the full-text search page
|
||||
let url = format!(
|
||||
"https://www.bedetheque.com/search/tout?RechTexte={}&RechWhere=0",
|
||||
urlencoded(&normalize_for_url(query))
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Bedetheque request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
return Err(format!("Bedetheque returned {status}"));
|
||||
}
|
||||
|
||||
let html = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Bedetheque response: {e}"))?;
|
||||
|
||||
// Detect IP blacklist
|
||||
if html.contains("<title></title>") || html.contains("<title> </title>") {
|
||||
return Err("Bedetheque: IP may be rate-limited, please retry later".to_string());
|
||||
}
|
||||
|
||||
// Parse HTML in a block so the non-Send Html type is dropped before any .await
|
||||
let candidates = {
|
||||
let document = Html::parse_document(&html);
|
||||
let link_sel =
|
||||
Selector::parse("a[href*='/serie-']").map_err(|e| format!("selector error: {e}"))?;
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for el in document.select(&link_sel) {
|
||||
let href = match el.value().attr("href") {
|
||||
Some(h) => h.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let (series_id, _slug) = match parse_serie_href(&href) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !seen.insert(series_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = el.text().collect::<String>().trim().to_string();
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let confidence = compute_confidence(&title, &query_lower);
|
||||
let cover_url = format!(
|
||||
"https://www.bedetheque.com/cache/thb_series/PlancheS_{}.jpg",
|
||||
series_id
|
||||
);
|
||||
|
||||
candidates.push(SeriesCandidate {
|
||||
external_id: series_id.clone(),
|
||||
title: title.clone(),
|
||||
authors: vec![],
|
||||
description: None,
|
||||
publishers: vec![],
|
||||
start_year: None,
|
||||
total_volumes: None,
|
||||
cover_url: Some(cover_url),
|
||||
external_url: Some(href),
|
||||
confidence,
|
||||
metadata_json: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| {
|
||||
b.confidence
|
||||
.partial_cmp(&a.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
candidates.truncate(10);
|
||||
candidates
|
||||
}; // document is dropped here — safe to .await below
|
||||
|
||||
// For the top candidates, fetch series details to enrich metadata
|
||||
// (limit to top 3 to avoid hammering the site)
|
||||
let mut enriched = Vec::new();
|
||||
for mut c in candidates {
|
||||
if enriched.len() < 3 {
|
||||
if let Ok(details) = fetch_series_details(&client, &c.external_id, c.external_url.as_deref()).await {
|
||||
if let Some(desc) = details.description {
|
||||
c.description = Some(desc);
|
||||
}
|
||||
if !details.authors.is_empty() {
|
||||
c.authors = details.authors;
|
||||
}
|
||||
if !details.publishers.is_empty() {
|
||||
c.publishers = details.publishers;
|
||||
}
|
||||
if let Some(year) = details.start_year {
|
||||
c.start_year = Some(year);
|
||||
}
|
||||
if let Some(count) = details.album_count {
|
||||
c.total_volumes = Some(count);
|
||||
}
|
||||
c.metadata_json = serde_json::json!({
|
||||
"description": c.description,
|
||||
"authors": c.authors,
|
||||
"publishers": c.publishers,
|
||||
"start_year": c.start_year,
|
||||
"genres": details.genres,
|
||||
"status": details.status,
|
||||
"origin": details.origin,
|
||||
"language": details.language,
|
||||
});
|
||||
}
|
||||
}
|
||||
enriched.push(c);
|
||||
}
|
||||
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Parse serie URL to extract (id, slug)
|
||||
fn parse_serie_href(href: &str) -> Option<(String, String)> {
|
||||
// Patterns:
|
||||
// https://www.bedetheque.com/serie-3-BD-Blacksad.html
|
||||
// /serie-3-BD-Blacksad.html
|
||||
let re = regex::Regex::new(r"/serie-(\d+)-[A-Za-z]+-(.+?)(?:__\d+)?\.html").ok()?;
|
||||
let caps = re.captures(href)?;
|
||||
Some((caps[1].to_string(), caps[2].to_string()))
|
||||
}
|
||||
|
||||
struct SeriesDetails {
|
||||
description: Option<String>,
|
||||
authors: Vec<String>,
|
||||
publishers: Vec<String>,
|
||||
start_year: Option<i32>,
|
||||
album_count: Option<i32>,
|
||||
genres: Vec<String>,
|
||||
status: Option<String>,
|
||||
origin: Option<String>,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_series_details(
|
||||
client: &reqwest::Client,
|
||||
series_id: &str,
|
||||
series_url: Option<&str>,
|
||||
) -> Result<SeriesDetails, String> {
|
||||
// Build URL — append __10000 to get all albums on one page
|
||||
let url = match series_url {
|
||||
Some(u) => {
|
||||
// Replace .html with __10000.html
|
||||
u.replace(".html", "__10000.html")
|
||||
}
|
||||
None => format!(
|
||||
"https://www.bedetheque.com/serie-{}-BD-Serie__10000.html",
|
||||
series_id
|
||||
),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch series page: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Series page returned {}", resp.status()));
|
||||
}
|
||||
|
||||
let html = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read series page: {e}"))?;
|
||||
|
||||
let doc = Html::parse_document(&html);
|
||||
let mut details = SeriesDetails {
|
||||
description: None,
|
||||
authors: vec![],
|
||||
publishers: vec![],
|
||||
start_year: None,
|
||||
album_count: None,
|
||||
genres: vec![],
|
||||
status: None,
|
||||
origin: None,
|
||||
language: None,
|
||||
};
|
||||
|
||||
// Description from <meta name="description"> — format: "Tout sur la série {name} : {description}"
|
||||
if let Ok(sel) = Selector::parse(r#"meta[name="description"]"#) {
|
||||
if let Some(el) = doc.select(&sel).next() {
|
||||
if let Some(content) = el.value().attr("content") {
|
||||
let desc = content.trim().to_string();
|
||||
// Strip the "Tout sur la série ... : " prefix
|
||||
let cleaned = if let Some(pos) = desc.find(" : ") {
|
||||
desc[pos + 3..].trim().to_string()
|
||||
} else {
|
||||
desc
|
||||
};
|
||||
if !cleaned.is_empty() {
|
||||
details.description = Some(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract authors from itemprop="author" and itemprop="illustrator" (deduplicated)
|
||||
{
|
||||
let mut authors_set = std::collections::HashSet::new();
|
||||
for attr in ["author", "illustrator"] {
|
||||
if let Ok(sel) = Selector::parse(&format!(r#"[itemprop="{attr}"]"#)) {
|
||||
for el in doc.select(&sel) {
|
||||
let name = el.text().collect::<String>().trim().to_string();
|
||||
// Names are "Last, First" — normalize to "First Last"
|
||||
let normalized = if let Some((last, first)) = name.split_once(',') {
|
||||
format!("{} {}", first.trim(), last.trim())
|
||||
} else {
|
||||
name
|
||||
};
|
||||
if !normalized.is_empty() && is_real_author(&normalized) {
|
||||
authors_set.insert(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
details.authors = authors_set.into_iter().collect();
|
||||
details.authors.sort();
|
||||
}
|
||||
|
||||
// Extract publishers from itemprop="publisher" (deduplicated)
|
||||
{
|
||||
let mut publishers_set = std::collections::HashSet::new();
|
||||
if let Ok(sel) = Selector::parse(r#"[itemprop="publisher"]"#) {
|
||||
for el in doc.select(&sel) {
|
||||
let name = el.text().collect::<String>().trim().to_string();
|
||||
if !name.is_empty() {
|
||||
publishers_set.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
details.publishers = publishers_set.into_iter().collect();
|
||||
details.publishers.sort();
|
||||
}
|
||||
|
||||
// Extract series-level info from <li><label>X :</label>value</li> blocks
|
||||
// Genre: <li><label>Genre :</label><span class="style-serie">Animalier, Aventure, Humour</span></li>
|
||||
if let Ok(sel) = Selector::parse("span.style-serie") {
|
||||
if let Some(el) = doc.select(&sel).next() {
|
||||
let text = el.text().collect::<String>();
|
||||
details.genres = text
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
// Parution: <li><label>Parution :</label><span class="parution-serie">Série finie</span></li>
|
||||
if let Ok(sel) = Selector::parse("span.parution-serie") {
|
||||
if let Some(el) = doc.select(&sel).next() {
|
||||
let text = el.text().collect::<String>().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
details.status = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Origine and Langue from page text (no dedicated CSS class)
|
||||
let page_text = doc.root_element().text().collect::<String>();
|
||||
|
||||
if let Some(val) = extract_info_value(&page_text, "Origine") {
|
||||
let val = val.lines().next().unwrap_or(val).trim();
|
||||
if !val.is_empty() {
|
||||
details.origin = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(val) = extract_info_value(&page_text, "Langue") {
|
||||
let val = val.lines().next().unwrap_or(val).trim();
|
||||
if !val.is_empty() {
|
||||
details.language = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Album count from serie-info text (e.g. "Tomes : 8")
|
||||
if let Ok(re) = regex::Regex::new(r"Tomes?\s*:\s*(\d+)") {
|
||||
if let Some(caps) = re.captures(&page_text) {
|
||||
if let Ok(n) = caps[1].parse::<i32>() {
|
||||
details.album_count = Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start year from first <meta itemprop="datePublished" content="YYYY-MM-DD">
|
||||
if let Ok(sel) = Selector::parse(r#"[itemprop="datePublished"]"#) {
|
||||
if let Some(el) = doc.select(&sel).next() {
|
||||
if let Some(content) = el.value().attr("content") {
|
||||
// content is "YYYY-MM-DD"
|
||||
if let Some(year_str) = content.split('-').next() {
|
||||
if let Ok(year) = year_str.parse::<i32>() {
|
||||
details.start_year = Some(year);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
/// Extract value after a label like "Scénario : Jean-Claude" → "Jean-Claude"
|
||||
fn extract_info_value<'a>(text: &'a str, label: &str) -> Option<&'a str> {
|
||||
// Handle both "Label :" and "Label:"
|
||||
let patterns = [
|
||||
format!("{} :", label),
|
||||
format!("{}:", label),
|
||||
format!("{} :", &label.to_lowercase()),
|
||||
];
|
||||
for pat in &patterns {
|
||||
if let Some(pos) = text.find(pat.as_str()) {
|
||||
let val = text[pos + pat.len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get series books
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_series_books_impl(
|
||||
external_id: &str,
|
||||
_config: &ProviderConfig,
|
||||
) -> Result<Vec<BookCandidate>, String> {
|
||||
let client = build_client()?;
|
||||
|
||||
// We need to find the series URL — try a direct fetch
|
||||
// external_id is the numeric series ID
|
||||
// We try to fetch the series page to get the album list
|
||||
let url = format!(
|
||||
"https://www.bedetheque.com/serie-{}-BD-Serie__10000.html",
|
||||
external_id
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch series: {e}"))?;
|
||||
|
||||
// If the generic slug fails, try without the slug part (bedetheque redirects)
|
||||
let html = if resp.status().is_success() {
|
||||
resp.text().await.map_err(|e| format!("Failed to read: {e}"))?
|
||||
} else {
|
||||
// Try alternative URL pattern
|
||||
let alt_url = format!(
|
||||
"https://www.bedetheque.com/serie-{}__10000.html",
|
||||
external_id
|
||||
);
|
||||
let resp2 = client
|
||||
.get(&alt_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch series (alt): {e}"))?;
|
||||
if !resp2.status().is_success() {
|
||||
return Err(format!("Series page not found for id {external_id}"));
|
||||
}
|
||||
resp2.text().await.map_err(|e| format!("Failed to read: {e}"))?
|
||||
};
|
||||
|
||||
if html.contains("<title></title>") {
|
||||
return Err("Bedetheque: IP may be rate-limited".to_string());
|
||||
}
|
||||
|
||||
let doc = Html::parse_document(&html);
|
||||
let mut books = Vec::new();
|
||||
|
||||
// Each album block starts before a .album-main div.
|
||||
// The cover image (<img itemprop="image">) is OUTSIDE .album-main (sibling),
|
||||
// so we iterate over a broader parent. But the simplest approach: parse all
|
||||
// itemprop elements relative to each .album-main, plus pick covers separately.
|
||||
let album_sel = Selector::parse(".album-main").map_err(|e| format!("selector: {e}"))?;
|
||||
|
||||
// Pre-collect cover images — they appear in <img itemprop="image"> before each .album-main
|
||||
// and link to an album URL containing the book ID
|
||||
let cover_sel = Selector::parse(r#"img[itemprop="image"]"#).map_err(|e| format!("selector: {e}"))?;
|
||||
let covers: Vec<String> = doc.select(&cover_sel)
|
||||
.filter_map(|el| el.value().attr("src").map(|s| {
|
||||
if s.starts_with("http") { s.to_string() } else { format!("https://www.bedetheque.com{}", s) }
|
||||
}))
|
||||
.collect();
|
||||
|
||||
static RE_TOME: std::sync::LazyLock<regex::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)-Tome-\d+-").unwrap());
|
||||
static RE_BOOK_ID: std::sync::LazyLock<regex::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::Regex::new(r"-(\d+)\.html").unwrap());
|
||||
static RE_VOLUME: std::sync::LazyLock<regex::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)Tome-(\d+)-").unwrap());
|
||||
|
||||
for (idx, album_el) in doc.select(&album_sel).enumerate() {
|
||||
// Title from <a class="titre" title="..."> — the title attribute is clean
|
||||
let title_sel = Selector::parse("a.titre").ok();
|
||||
let title_el = title_sel.as_ref().and_then(|s| album_el.select(s).next());
|
||||
let title = title_el
|
||||
.and_then(|el| el.value().attr("title"))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// External book ID from album URL (e.g. "...-1063.html")
|
||||
let album_url = title_el.and_then(|el| el.value().attr("href")).unwrap_or("");
|
||||
|
||||
// Only keep main tomes — their URLs contain "Tome-{N}-"
|
||||
// Skip hors-série (HS), intégrales (INT/INTFL), romans, coffrets, etc.
|
||||
if !RE_TOME.is_match(album_url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let external_book_id = RE_BOOK_ID
|
||||
.captures(album_url)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
|
||||
let volume_number = RE_VOLUME
|
||||
.captures(album_url)
|
||||
.and_then(|c| c[1].parse::<i32>().ok())
|
||||
.or_else(|| extract_volume_from_title(&title));
|
||||
|
||||
// Authors from itemprop="author" and itemprop="illustrator"
|
||||
let mut authors = Vec::new();
|
||||
let author_sel = Selector::parse(r#"[itemprop="author"]"#).ok();
|
||||
let illustrator_sel = Selector::parse(r#"[itemprop="illustrator"]"#).ok();
|
||||
for sel in [&author_sel, &illustrator_sel].into_iter().flatten() {
|
||||
for el in album_el.select(sel) {
|
||||
let name = el.text().collect::<String>().trim().to_string();
|
||||
// Names are "Last, First" format — normalize to "First Last"
|
||||
let normalized = if let Some((last, first)) = name.split_once(',') {
|
||||
format!("{} {}", first.trim(), last.trim())
|
||||
} else {
|
||||
name
|
||||
};
|
||||
if !normalized.is_empty() && is_real_author(&normalized) && !authors.contains(&normalized) {
|
||||
authors.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ISBN from <span itemprop="isbn">
|
||||
let isbn = Selector::parse(r#"[itemprop="isbn"]"#)
|
||||
.ok()
|
||||
.and_then(|s| album_el.select(&s).next())
|
||||
.map(|el| el.text().collect::<String>().trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Page count from <span itemprop="numberOfPages">
|
||||
let page_count = Selector::parse(r#"[itemprop="numberOfPages"]"#)
|
||||
.ok()
|
||||
.and_then(|s| album_el.select(&s).next())
|
||||
.and_then(|el| el.text().collect::<String>().trim().parse::<i32>().ok());
|
||||
|
||||
// Publish date from <meta itemprop="datePublished" content="YYYY-MM-DD">
|
||||
let publish_date = Selector::parse(r#"[itemprop="datePublished"]"#)
|
||||
.ok()
|
||||
.and_then(|s| album_el.select(&s).next())
|
||||
.and_then(|el| el.value().attr("content").map(|c| c.trim().to_string()))
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Cover from pre-collected covers (same index)
|
||||
let cover_url = covers.get(idx).cloned();
|
||||
|
||||
books.push(BookCandidate {
|
||||
external_book_id,
|
||||
title,
|
||||
volume_number,
|
||||
authors,
|
||||
isbn,
|
||||
summary: None,
|
||||
cover_url,
|
||||
page_count,
|
||||
language: Some("fr".to_string()),
|
||||
publish_date,
|
||||
metadata_json: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
|
||||
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
/// Filter out placeholder author names from Bédéthèque
|
||||
fn is_real_author(name: &str) -> bool {
|
||||
!name.starts_with('<') && !name.ends_with('>') && name != "Collectif"
|
||||
}
|
||||
|
||||
fn extract_volume_from_title(title: &str) -> Option<i32> {
|
||||
let patterns = [
|
||||
r"(?i)(?:tome|t\.)\s*(\d+)",
|
||||
r"(?i)(?:vol(?:ume)?\.?)\s*(\d+)",
|
||||
r"#\s*(\d+)",
|
||||
];
|
||||
for pattern in &patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
if let Some(caps) = re.captures(title) {
|
||||
if let Ok(n) = caps[1].parse::<i32>() {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Normalize a title by removing French articles (leading or in parentheses)
|
||||
/// and extra whitespace/punctuation, so that "Les Légendaires - Résistance"
|
||||
/// and "Légendaires (Les) - Résistance" produce the same canonical form.
|
||||
fn normalize_title(s: &str) -> String {
|
||||
let lower = s.to_lowercase();
|
||||
// Remove articles in parentheses: "(les)", "(la)", "(le)", "(l')", "(un)", "(une)", "(des)"
|
||||
let re_parens = regex::Regex::new(r"\s*\((?:les?|la|l'|une?|des|du|d')\)").unwrap();
|
||||
let cleaned = re_parens.replace_all(&lower, "");
|
||||
// Remove leading articles: "les ", "la ", "le ", "l'", "un ", "une ", "des ", "du ", "d'"
|
||||
let re_leading = regex::Regex::new(r"^(?:les?|la|l'|une?|des|du|d')\s+").unwrap();
|
||||
let cleaned = re_leading.replace(&cleaned, "");
|
||||
// Collapse multiple spaces/dashes into single
|
||||
let re_spaces = regex::Regex::new(r"\s+").unwrap();
|
||||
re_spaces.replace_all(cleaned.trim(), " ").to_string()
|
||||
}
|
||||
|
||||
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||
let title_lower = title.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
if title_lower == query_lower {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Try with normalized forms (handles Bedetheque's "Name (Article)" convention)
|
||||
let title_norm = normalize_title(title);
|
||||
let query_norm = normalize_title(query);
|
||||
if title_norm == query_norm {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
if title_lower.starts_with(&query_lower) || query_lower.starts_with(&title_lower)
|
||||
|| title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm)
|
||||
{
|
||||
0.85
|
||||
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower)
|
||||
|| title_norm.contains(&query_norm) || query_norm.contains(&title_norm)
|
||||
{
|
||||
0.7
|
||||
} else {
|
||||
let common: usize = query_lower
|
||||
.chars()
|
||||
.filter(|c| title_lower.contains(*c))
|
||||
.count();
|
||||
let max_len = query_lower.len().max(title_lower.len()).max(1);
|
||||
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||
}
|
||||
}
|
||||
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||
|
||||
pub struct ComicVineProvider;
|
||||
|
||||
impl MetadataProvider for ComicVineProvider {
|
||||
fn name(&self) -> &str {
|
||||
"comicvine"
|
||||
}
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let query = query.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||
}
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let external_id = external_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.user_agent("StripstreamLibrarian/1.0")
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
async fn search_series_impl(
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Vec<SeriesCandidate>, String> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(|| "ComicVine requires an API key. Configure it in Settings > Integrations.".to_string())?;
|
||||
|
||||
let client = build_client()?;
|
||||
|
||||
let url = format!(
|
||||
"https://comicvine.gamespot.com/api/search/?api_key={}&format=json&resources=volume&query={}&limit=20",
|
||||
api_key,
|
||||
urlencoded(query)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ComicVine request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ComicVine returned {status}: {text}"));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
|
||||
|
||||
let results = match data.get("results").and_then(|r| r.as_array()) {
|
||||
Some(results) => results,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut candidates: Vec<SeriesCandidate> = results
|
||||
.iter()
|
||||
.filter_map(|vol| {
|
||||
let name = vol.get("name").and_then(|n| n.as_str())?.to_string();
|
||||
let id = vol.get("id").and_then(|id| id.as_i64())?;
|
||||
let description = vol
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(strip_html);
|
||||
let publisher = vol
|
||||
.get("publisher")
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(String::from);
|
||||
let start_year = vol
|
||||
.get("start_year")
|
||||
.and_then(|y| y.as_str())
|
||||
.and_then(|y| y.parse::<i32>().ok());
|
||||
let count_of_issues = vol
|
||||
.get("count_of_issues")
|
||||
.and_then(|c| c.as_i64())
|
||||
.map(|c| c as i32);
|
||||
let cover_url = vol
|
||||
.get("image")
|
||||
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
let site_url = vol
|
||||
.get("site_detail_url")
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let confidence = compute_confidence(&name, &query_lower);
|
||||
|
||||
Some(SeriesCandidate {
|
||||
external_id: id.to_string(),
|
||||
title: name,
|
||||
authors: vec![],
|
||||
description,
|
||||
publishers: publisher.into_iter().collect(),
|
||||
start_year,
|
||||
total_volumes: count_of_issues,
|
||||
cover_url,
|
||||
external_url: site_url,
|
||||
confidence,
|
||||
metadata_json: serde_json::json!({}),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||
candidates.truncate(10);
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn get_series_books_impl(
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Vec<BookCandidate>, String> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(|| "ComicVine requires an API key".to_string())?;
|
||||
|
||||
let client = build_client()?;
|
||||
|
||||
let url = format!(
|
||||
"https://comicvine.gamespot.com/api/issues/?api_key={}&format=json&filter=volume:{}&sort=issue_number:asc&limit=100&field_list=id,name,issue_number,description,image,cover_date,site_detail_url",
|
||||
api_key,
|
||||
external_id
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ComicVine request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ComicVine returned {status}: {text}"));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
|
||||
|
||||
let results = match data.get("results").and_then(|r| r.as_array()) {
|
||||
Some(results) => results,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let books: Vec<BookCandidate> = results
|
||||
.iter()
|
||||
.filter_map(|issue| {
|
||||
let id = issue.get("id").and_then(|id| id.as_i64())?;
|
||||
let name = issue
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let issue_number = issue
|
||||
.get("issue_number")
|
||||
.and_then(|n| n.as_str())
|
||||
.and_then(|n| n.parse::<f64>().ok())
|
||||
.map(|n| n as i32);
|
||||
let description = issue
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(strip_html);
|
||||
let cover_url = issue
|
||||
.get("image")
|
||||
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(String::from);
|
||||
let cover_date = issue
|
||||
.get("cover_date")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(String::from);
|
||||
|
||||
Some(BookCandidate {
|
||||
external_book_id: id.to_string(),
|
||||
title: name,
|
||||
volume_number: issue_number,
|
||||
authors: vec![],
|
||||
isbn: None,
|
||||
summary: description,
|
||||
cover_url,
|
||||
page_count: None,
|
||||
language: None,
|
||||
publish_date: cover_date,
|
||||
metadata_json: serde_json::json!({}),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
fn strip_html(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_tag = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => result.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||
let title_lower = title.to_lowercase();
|
||||
if title_lower == query {
|
||||
1.0
|
||||
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||
0.8
|
||||
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||
0.7
|
||||
} else {
|
||||
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||
let max_len = query.len().max(title_lower.len()).max(1);
|
||||
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
472
apps/api/src/metadata_providers/google_books.rs
Normal file
472
apps/api/src/metadata_providers/google_books.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||
|
||||
pub struct GoogleBooksProvider;
|
||||
|
||||
impl MetadataProvider for GoogleBooksProvider {
|
||||
fn name(&self) -> &str {
|
||||
"google_books"
|
||||
}
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let query = query.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||
}
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let external_id = external_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_series_impl(
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Vec<SeriesCandidate>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
let search_query = format!("intitle:{}", query);
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=20&printType=books&langRestrict={}",
|
||||
urlencoded(&search_query),
|
||||
urlencoded(&config.language),
|
||||
);
|
||||
if let Some(ref key) = config.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Google Books request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Google Books returned {status}: {text}"));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Google Books response: {e}"))?;
|
||||
|
||||
let items = match data.get("items").and_then(|i| i.as_array()) {
|
||||
Some(items) => items,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
// Group volumes by series name to produce series candidates
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut series_map: std::collections::HashMap<String, SeriesCandidateBuilder> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for item in items {
|
||||
let volume_info = match item.get("volumeInfo") {
|
||||
Some(vi) => vi,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let title = volume_info
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let authors: Vec<String> = volume_info
|
||||
.get("authors")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let publisher = volume_info
|
||||
.get("publisher")
|
||||
.and_then(|p| p.as_str())
|
||||
.map(String::from);
|
||||
let published_date = volume_info
|
||||
.get("publishedDate")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(String::from);
|
||||
let description = volume_info
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(String::from);
|
||||
|
||||
// Extract series info from title or seriesInfo
|
||||
let series_name = volume_info
|
||||
.get("seriesInfo")
|
||||
.and_then(|si| si.get("title"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| extract_series_name(&title));
|
||||
|
||||
let cover_url = volume_info
|
||||
.get("imageLinks")
|
||||
.and_then(|il| {
|
||||
il.get("thumbnail")
|
||||
.or_else(|| il.get("smallThumbnail"))
|
||||
})
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.replace("http://", "https://"));
|
||||
|
||||
let google_id = item
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let entry = series_map
|
||||
.entry(series_name.clone())
|
||||
.or_insert_with(|| SeriesCandidateBuilder {
|
||||
title: series_name.clone(),
|
||||
authors: vec![],
|
||||
description: None,
|
||||
publishers: vec![],
|
||||
start_year: None,
|
||||
volume_count: 0,
|
||||
cover_url: None,
|
||||
external_id: google_id.clone(),
|
||||
external_url: None,
|
||||
metadata_json: serde_json::json!({}),
|
||||
});
|
||||
|
||||
entry.volume_count += 1;
|
||||
|
||||
// Merge authors
|
||||
for a in &authors {
|
||||
if !entry.authors.contains(a) {
|
||||
entry.authors.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Set description if not yet set
|
||||
if entry.description.is_none() {
|
||||
entry.description = description;
|
||||
}
|
||||
|
||||
// Merge publisher
|
||||
if let Some(ref pub_name) = publisher {
|
||||
if !entry.publishers.contains(pub_name) {
|
||||
entry.publishers.push(pub_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract year
|
||||
if let Some(ref date) = published_date {
|
||||
if let Some(year) = extract_year(date) {
|
||||
if entry.start_year.is_none() || entry.start_year.unwrap() > year {
|
||||
entry.start_year = Some(year);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entry.cover_url.is_none() {
|
||||
entry.cover_url = cover_url;
|
||||
}
|
||||
|
||||
entry.external_url = Some(format!(
|
||||
"https://books.google.com/books?id={}",
|
||||
google_id
|
||||
));
|
||||
}
|
||||
|
||||
let mut candidates: Vec<SeriesCandidate> = series_map
|
||||
.into_values()
|
||||
.map(|b| {
|
||||
let confidence = compute_confidence(&b.title, &query_lower);
|
||||
SeriesCandidate {
|
||||
external_id: b.external_id,
|
||||
title: b.title,
|
||||
authors: b.authors,
|
||||
description: b.description,
|
||||
publishers: b.publishers,
|
||||
start_year: b.start_year,
|
||||
total_volumes: if b.volume_count > 1 {
|
||||
Some(b.volume_count)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
cover_url: b.cover_url,
|
||||
external_url: b.external_url,
|
||||
confidence,
|
||||
metadata_json: b.metadata_json,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||
candidates.truncate(10);
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn get_series_books_impl(
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Vec<BookCandidate>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
// First fetch the volume to get its series info
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes/{}",
|
||||
external_id
|
||||
);
|
||||
if let Some(ref key) = config.api_key {
|
||||
url.push_str(&format!("?key={}", key));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Google Books request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Google Books returned {status}: {text}"));
|
||||
}
|
||||
|
||||
let volume: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Google Books response: {e}"))?;
|
||||
|
||||
let volume_info = volume.get("volumeInfo").cloned().unwrap_or(serde_json::json!({}));
|
||||
let title = volume_info
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Search for more volumes in this series
|
||||
let series_name = extract_series_name(title);
|
||||
let search_query = format!("intitle:{}", series_name);
|
||||
let mut search_url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=40&printType=books&langRestrict={}",
|
||||
urlencoded(&search_query),
|
||||
urlencoded(&config.language),
|
||||
);
|
||||
if let Some(ref key) = config.api_key {
|
||||
search_url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Google Books search failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
// Return just the single volume as a book
|
||||
return Ok(vec![volume_to_book_candidate(&volume)]);
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse search response: {e}"))?;
|
||||
|
||||
let items = match data.get("items").and_then(|i| i.as_array()) {
|
||||
Some(items) => items,
|
||||
None => return Ok(vec![volume_to_book_candidate(&volume)]),
|
||||
};
|
||||
|
||||
let mut books: Vec<BookCandidate> = items
|
||||
.iter()
|
||||
.map(volume_to_book_candidate)
|
||||
.collect();
|
||||
|
||||
// Sort by volume number
|
||||
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
fn volume_to_book_candidate(item: &serde_json::Value) -> BookCandidate {
|
||||
let volume_info = item.get("volumeInfo").cloned().unwrap_or(serde_json::json!({}));
|
||||
let title = volume_info
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let authors: Vec<String> = volume_info
|
||||
.get("authors")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let isbn = volume_info
|
||||
.get("industryIdentifiers")
|
||||
.and_then(|ids| ids.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|id| {
|
||||
id.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "ISBN_13" || t == "ISBN_10")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|id| id.get("identifier").and_then(|i| i.as_str()))
|
||||
})
|
||||
.map(String::from);
|
||||
let summary = volume_info
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(String::from);
|
||||
let cover_url = volume_info
|
||||
.get("imageLinks")
|
||||
.and_then(|il| il.get("thumbnail").or_else(|| il.get("smallThumbnail")))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.replace("http://", "https://"));
|
||||
let page_count = volume_info
|
||||
.get("pageCount")
|
||||
.and_then(|p| p.as_i64())
|
||||
.map(|p| p as i32);
|
||||
let language = volume_info
|
||||
.get("language")
|
||||
.and_then(|l| l.as_str())
|
||||
.map(String::from);
|
||||
let publish_date = volume_info
|
||||
.get("publishedDate")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(String::from);
|
||||
let google_id = item
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let volume_number = extract_volume_number(&title);
|
||||
|
||||
BookCandidate {
|
||||
external_book_id: google_id,
|
||||
title,
|
||||
volume_number,
|
||||
authors,
|
||||
isbn,
|
||||
summary,
|
||||
cover_url,
|
||||
page_count,
|
||||
language,
|
||||
publish_date,
|
||||
metadata_json: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_series_name(title: &str) -> String {
|
||||
// Remove trailing volume indicators like "Vol. 1", "Tome 2", "#3", "- Volume 1"
|
||||
let re_patterns = [
|
||||
r"(?i)\s*[-–—]\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||
r"(?i)\s*,?\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||
r"\s*\(\d+\)\s*$",
|
||||
r"\s+\d+\s*$",
|
||||
];
|
||||
|
||||
let mut result = title.to_string();
|
||||
for pattern in &re_patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
let cleaned = re.replace(&result, "").to_string();
|
||||
if !cleaned.is_empty() {
|
||||
result = cleaned;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn extract_volume_number(title: &str) -> Option<i32> {
|
||||
let patterns = [
|
||||
r"(?i)(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*(\d+)",
|
||||
r"\((\d+)\)\s*$",
|
||||
r"\b(\d+)\s*$",
|
||||
];
|
||||
|
||||
for pattern in &patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
if let Some(caps) = re.captures(title) {
|
||||
if let Some(num) = caps.get(1).and_then(|m| m.as_str().parse::<i32>().ok()) {
|
||||
return Some(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_year(date: &str) -> Option<i32> {
|
||||
date.get(..4).and_then(|s| s.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||
let title_lower = title.to_lowercase();
|
||||
if title_lower == query {
|
||||
1.0
|
||||
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||
0.8
|
||||
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||
0.7
|
||||
} else {
|
||||
// Simple character overlap ratio
|
||||
let common: usize = query
|
||||
.chars()
|
||||
.filter(|c| title_lower.contains(*c))
|
||||
.count();
|
||||
let max_len = query.len().max(title_lower.len()).max(1);
|
||||
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
_ => {
|
||||
result.push_str(&format!("%{:02X}", byte));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct SeriesCandidateBuilder {
|
||||
title: String,
|
||||
authors: Vec<String>,
|
||||
description: Option<String>,
|
||||
publishers: Vec<String>,
|
||||
start_year: Option<i32>,
|
||||
volume_count: i32,
|
||||
cover_url: Option<String>,
|
||||
external_id: String,
|
||||
external_url: Option<String>,
|
||||
metadata_json: serde_json::Value,
|
||||
}
|
||||
295
apps/api/src/metadata_providers/mod.rs
Normal file
295
apps/api/src/metadata_providers/mod.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
pub mod anilist;
|
||||
pub mod bedetheque;
|
||||
pub mod comicvine;
|
||||
pub mod google_books;
|
||||
pub mod open_library;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration passed to providers (API keys, etc.)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_key: Option<String>,
|
||||
/// Preferred language for metadata results (ISO 639-1: "en", "fr", "es"). Defaults to "en".
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// A candidate series returned by a provider search
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SeriesCandidate {
|
||||
pub external_id: String,
|
||||
pub title: String,
|
||||
pub authors: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub publishers: Vec<String>,
|
||||
pub start_year: Option<i32>,
|
||||
pub total_volumes: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
pub external_url: Option<String>,
|
||||
pub confidence: f32,
|
||||
pub metadata_json: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A candidate book within a series
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookCandidate {
|
||||
pub external_book_id: String,
|
||||
pub title: String,
|
||||
pub volume_number: Option<i32>,
|
||||
pub authors: Vec<String>,
|
||||
pub isbn: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub page_count: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
pub publish_date: Option<String>,
|
||||
pub metadata_json: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Trait that all metadata providers must implement
|
||||
pub trait MetadataProvider: Send + Sync {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
>;
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
>;
|
||||
}
|
||||
|
||||
/// Factory function to get a provider by name
|
||||
pub fn get_provider(name: &str) -> Option<Box<dyn MetadataProvider>> {
|
||||
match name {
|
||||
"google_books" => Some(Box::new(google_books::GoogleBooksProvider)),
|
||||
"open_library" => Some(Box::new(open_library::OpenLibraryProvider)),
|
||||
"comicvine" => Some(Box::new(comicvine::ComicVineProvider)),
|
||||
"anilist" => Some(Box::new(anilist::AniListProvider)),
|
||||
"bedetheque" => Some(Box::new(bedetheque::BedethequeProvider)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end provider tests
|
||||
//
|
||||
// These tests hit real external APIs — run them explicitly with:
|
||||
// cargo test -p api providers_e2e -- --ignored --nocapture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod providers_e2e {
|
||||
use super::*;
|
||||
|
||||
fn config_fr() -> ProviderConfig {
|
||||
ProviderConfig { api_key: None, language: "fr".to_string() }
|
||||
}
|
||||
|
||||
fn config_en() -> ProviderConfig {
|
||||
ProviderConfig { api_key: None, language: "en".to_string() }
|
||||
}
|
||||
|
||||
fn print_candidate(name: &str, c: &SeriesCandidate) {
|
||||
println!("\n=== {name} — best candidate ===");
|
||||
println!(" title: {:?}", c.title);
|
||||
println!(" external_id: {:?}", c.external_id);
|
||||
println!(" authors: {:?}", c.authors);
|
||||
println!(" description: {:?}", c.description.as_deref().map(|d| &d[..d.len().min(120)]));
|
||||
println!(" publishers: {:?}", c.publishers);
|
||||
println!(" start_year: {:?}", c.start_year);
|
||||
println!(" total_volumes: {:?}", c.total_volumes);
|
||||
println!(" cover_url: {}", c.cover_url.is_some());
|
||||
println!(" external_url: {}", c.external_url.is_some());
|
||||
println!(" confidence: {:.2}", c.confidence);
|
||||
println!(" metadata_json: {}", serde_json::to_string_pretty(&c.metadata_json).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn print_books(name: &str, books: &[BookCandidate]) {
|
||||
println!("\n=== {name} — {} books ===", books.len());
|
||||
for (i, b) in books.iter().take(5).enumerate() {
|
||||
println!(
|
||||
" [{}] vol={:?} title={:?} authors={} isbn={:?} pages={:?} lang={:?} date={:?} cover={}",
|
||||
i, b.volume_number, b.title, b.authors.len(), b.isbn, b.page_count, b.language, b.publish_date, b.cover_url.is_some()
|
||||
);
|
||||
}
|
||||
if books.len() > 5 { println!(" ... and {} more", books.len() - 5); }
|
||||
|
||||
let with_vol = books.iter().filter(|b| b.volume_number.is_some()).count();
|
||||
let with_isbn = books.iter().filter(|b| b.isbn.is_some()).count();
|
||||
let with_authors = books.iter().filter(|b| !b.authors.is_empty()).count();
|
||||
let with_date = books.iter().filter(|b| b.publish_date.is_some()).count();
|
||||
let with_cover = books.iter().filter(|b| b.cover_url.is_some()).count();
|
||||
let with_pages = books.iter().filter(|b| b.page_count.is_some()).count();
|
||||
println!(" --- field coverage ---");
|
||||
println!(" volume_number: {with_vol}/{}", books.len());
|
||||
println!(" isbn: {with_isbn}/{}", books.len());
|
||||
println!(" authors: {with_authors}/{}", books.len());
|
||||
println!(" publish_date: {with_date}/{}", books.len());
|
||||
println!(" cover_url: {with_cover}/{}", books.len());
|
||||
println!(" page_count: {with_pages}/{}", books.len());
|
||||
}
|
||||
|
||||
// --- Google Books ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn google_books_search_and_books() {
|
||||
let p = get_provider("google_books").unwrap();
|
||||
let cfg = config_en();
|
||||
|
||||
let candidates = p.search_series("Blacksad", &cfg).await.unwrap();
|
||||
assert!(!candidates.is_empty(), "google_books: no results for Blacksad");
|
||||
print_candidate("google_books", &candidates[0]);
|
||||
|
||||
let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap();
|
||||
print_books("google_books", &books);
|
||||
assert!(!books.is_empty(), "google_books: no books returned");
|
||||
}
|
||||
|
||||
// --- Open Library ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn open_library_search_and_books() {
|
||||
let p = get_provider("open_library").unwrap();
|
||||
let cfg = config_en();
|
||||
|
||||
let candidates = p.search_series("Sandman Neil Gaiman", &cfg).await.unwrap();
|
||||
assert!(!candidates.is_empty(), "open_library: no results for Sandman");
|
||||
print_candidate("open_library", &candidates[0]);
|
||||
|
||||
let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap();
|
||||
print_books("open_library", &books);
|
||||
assert!(!books.is_empty(), "open_library: no books returned");
|
||||
}
|
||||
|
||||
// --- AniList ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn anilist_search_finished() {
|
||||
let p = get_provider("anilist").unwrap();
|
||||
let cfg = config_fr();
|
||||
|
||||
let candidates = p.search_series("Death Note", &cfg).await.unwrap();
|
||||
assert!(!candidates.is_empty(), "anilist: no results for Death Note");
|
||||
print_candidate("anilist (finished)", &candidates[0]);
|
||||
|
||||
let best = &candidates[0];
|
||||
assert!(best.total_volumes.is_some(), "anilist: finished series should have total_volumes");
|
||||
assert!(best.description.is_some(), "anilist: should have description");
|
||||
assert!(!best.authors.is_empty(), "anilist: should have authors");
|
||||
|
||||
let status = best.metadata_json.get("status").and_then(|s| s.as_str());
|
||||
assert_eq!(status, Some("FINISHED"), "anilist: Death Note should be FINISHED");
|
||||
|
||||
let books = p.get_series_books(&best.external_id, &cfg).await.unwrap();
|
||||
print_books("anilist (Death Note)", &books);
|
||||
assert!(books.len() >= 12, "anilist: Death Note should have ≥12 volumes, got {}", books.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn anilist_search_ongoing() {
|
||||
let p = get_provider("anilist").unwrap();
|
||||
let cfg = config_fr();
|
||||
|
||||
let candidates = p.search_series("One Piece", &cfg).await.unwrap();
|
||||
assert!(!candidates.is_empty(), "anilist: no results for One Piece");
|
||||
print_candidate("anilist (ongoing)", &candidates[0]);
|
||||
|
||||
let best = &candidates[0];
|
||||
let status = best.metadata_json.get("status").and_then(|s| s.as_str());
|
||||
assert_eq!(status, Some("RELEASING"), "anilist: One Piece should be RELEASING");
|
||||
|
||||
let volume_source = best.metadata_json.get("volume_source").and_then(|s| s.as_str());
|
||||
println!(" volume_source: {:?}", volume_source);
|
||||
println!(" total_volumes: {:?}", best.total_volumes);
|
||||
}
|
||||
|
||||
// --- Bédéthèque ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn bedetheque_search_and_books() {
|
||||
let p = get_provider("bedetheque").unwrap();
|
||||
let cfg = config_fr();
|
||||
|
||||
let candidates = p.search_series("De Cape et de Crocs", &cfg).await.unwrap();
|
||||
assert!(!candidates.is_empty(), "bedetheque: no results");
|
||||
print_candidate("bedetheque", &candidates[0]);
|
||||
|
||||
let best = &candidates[0];
|
||||
assert!(best.description.is_some(), "bedetheque: should have description");
|
||||
assert!(!best.authors.is_empty(), "bedetheque: should have authors");
|
||||
assert!(!best.publishers.is_empty(), "bedetheque: should have publishers");
|
||||
assert!(best.start_year.is_some(), "bedetheque: should have start_year");
|
||||
assert!(best.total_volumes.is_some(), "bedetheque: should have total_volumes");
|
||||
|
||||
// Enriched metadata_json
|
||||
let mj = &best.metadata_json;
|
||||
assert!(mj.get("genres").and_then(|g| g.as_array()).map(|a| !a.is_empty()).unwrap_or(false), "bedetheque: should have genres");
|
||||
assert!(mj.get("status").and_then(|s| s.as_str()).is_some(), "bedetheque: should have status");
|
||||
|
||||
let books = p.get_series_books(&best.external_id, &cfg).await.unwrap();
|
||||
print_books("bedetheque", &books);
|
||||
assert!(books.len() >= 12, "bedetheque: De Cape et de Crocs should have ≥12 volumes, got {}", books.len());
|
||||
}
|
||||
|
||||
// --- ComicVine (needs API key) ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn comicvine_no_key() {
|
||||
let p = get_provider("comicvine").unwrap();
|
||||
let cfg = config_en();
|
||||
|
||||
let result = p.search_series("Batman", &cfg).await;
|
||||
println!("\n=== comicvine (no key) ===");
|
||||
match result {
|
||||
Ok(c) => println!(" returned {} candidates (unexpected without key)", c.len()),
|
||||
Err(e) => println!(" expected error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cross-provider comparison ---
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn cross_provider_blacksad() {
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!(" Cross-provider comparison: Blacksad");
|
||||
println!("{}\n", "=".repeat(60));
|
||||
|
||||
let providers: Vec<(&str, ProviderConfig)> = vec![
|
||||
("google_books", config_en()),
|
||||
("open_library", config_en()),
|
||||
("anilist", config_fr()),
|
||||
("bedetheque", config_fr()),
|
||||
];
|
||||
|
||||
for (name, cfg) in &providers {
|
||||
let p = get_provider(name).unwrap();
|
||||
match p.search_series("Blacksad", cfg).await {
|
||||
Ok(candidates) if !candidates.is_empty() => {
|
||||
let b = &candidates[0];
|
||||
println!("[{name}] title={:?} authors={} desc={} pubs={} year={:?} vols={:?} cover={} url={} conf={:.2}",
|
||||
b.title, b.authors.len(), b.description.is_some(), b.publishers.len(),
|
||||
b.start_year, b.total_volumes, b.cover_url.is_some(), b.external_url.is_some(), b.confidence);
|
||||
}
|
||||
Ok(_) => println!("[{name}] no results"),
|
||||
Err(e) => println!("[{name}] error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
351
apps/api/src/metadata_providers/open_library.rs
Normal file
351
apps/api/src/metadata_providers/open_library.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||
|
||||
pub struct OpenLibraryProvider;
|
||||
|
||||
impl MetadataProvider for OpenLibraryProvider {
|
||||
fn name(&self) -> &str {
|
||||
"open_library"
|
||||
}
|
||||
|
||||
fn search_series(
|
||||
&self,
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let query = query.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||
}
|
||||
|
||||
fn get_series_books(
|
||||
&self,
|
||||
external_id: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||
> {
|
||||
let external_id = external_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_series_impl(
|
||||
query: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Vec<SeriesCandidate>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
// Open Library uses 3-letter language codes
|
||||
let ol_lang = match config.language.as_str() {
|
||||
"fr" => "fre",
|
||||
"es" => "spa",
|
||||
_ => "eng",
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://openlibrary.org/search.json?title={}&limit=20&language={}",
|
||||
urlencoded(query),
|
||||
ol_lang,
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Open Library request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Open Library returned {status}: {text}"));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Open Library response: {e}"))?;
|
||||
|
||||
let docs = match data.get("docs").and_then(|d| d.as_array()) {
|
||||
Some(docs) => docs,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut series_map: std::collections::HashMap<String, SeriesCandidateBuilder> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for doc in docs {
|
||||
let title = doc
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let authors: Vec<String> = doc
|
||||
.get("author_name")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let publishers: Vec<String> = doc
|
||||
.get("publisher")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
let mut pubs: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
|
||||
pubs.truncate(3);
|
||||
pubs
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let first_publish_year = doc
|
||||
.get("first_publish_year")
|
||||
.and_then(|y| y.as_i64())
|
||||
.map(|y| y as i32);
|
||||
let cover_i = doc.get("cover_i").and_then(|c| c.as_i64());
|
||||
let cover_url = cover_i.map(|id| format!("https://covers.openlibrary.org/b/id/{}-M.jpg", id));
|
||||
let key = doc
|
||||
.get("key")
|
||||
.and_then(|k| k.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let series_name = extract_series_name(&title);
|
||||
|
||||
let entry = series_map
|
||||
.entry(series_name.clone())
|
||||
.or_insert_with(|| SeriesCandidateBuilder {
|
||||
title: series_name.clone(),
|
||||
authors: vec![],
|
||||
description: None,
|
||||
publishers: vec![],
|
||||
start_year: None,
|
||||
volume_count: 0,
|
||||
cover_url: None,
|
||||
external_id: key.clone(),
|
||||
external_url: if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("https://openlibrary.org{}", key))
|
||||
},
|
||||
});
|
||||
|
||||
entry.volume_count += 1;
|
||||
|
||||
for a in &authors {
|
||||
if !entry.authors.contains(a) {
|
||||
entry.authors.push(a.clone());
|
||||
}
|
||||
}
|
||||
for p in &publishers {
|
||||
if !entry.publishers.contains(p) {
|
||||
entry.publishers.push(p.clone());
|
||||
}
|
||||
}
|
||||
if (entry.start_year.is_none() || first_publish_year.is_some_and(|y| entry.start_year.unwrap() > y))
|
||||
&& first_publish_year.is_some()
|
||||
{
|
||||
entry.start_year = first_publish_year;
|
||||
}
|
||||
if entry.cover_url.is_none() {
|
||||
entry.cover_url = cover_url;
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<SeriesCandidate> = series_map
|
||||
.into_values()
|
||||
.map(|b| {
|
||||
let confidence = compute_confidence(&b.title, &query_lower);
|
||||
SeriesCandidate {
|
||||
external_id: b.external_id,
|
||||
title: b.title,
|
||||
authors: b.authors,
|
||||
description: b.description,
|
||||
publishers: b.publishers,
|
||||
start_year: b.start_year,
|
||||
total_volumes: if b.volume_count > 1 { Some(b.volume_count) } else { None },
|
||||
cover_url: b.cover_url,
|
||||
external_url: b.external_url,
|
||||
confidence,
|
||||
metadata_json: serde_json::json!({}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||
candidates.truncate(10);
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn get_series_books_impl(
|
||||
external_id: &str,
|
||||
_config: &ProviderConfig,
|
||||
) -> Result<Vec<BookCandidate>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
// Fetch the work to get its title for series search
|
||||
let url = format!("https://openlibrary.org{}.json", external_id);
|
||||
let resp = client.get(&url).send().await.map_err(|e| format!("Open Library request failed: {e}"))?;
|
||||
|
||||
let work: serde_json::Value = if resp.status().is_success() {
|
||||
resp.json().await.map_err(|e| format!("Failed to parse response: {e}"))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let title = work.get("title").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let series_name = extract_series_name(title);
|
||||
|
||||
// Search for editions of this series
|
||||
let search_url = format!(
|
||||
"https://openlibrary.org/search.json?title={}&limit=40",
|
||||
urlencoded(&series_name)
|
||||
);
|
||||
let resp = client.get(&search_url).send().await.map_err(|e| format!("Open Library search failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
let docs = match data.get("docs").and_then(|d| d.as_array()) {
|
||||
Some(docs) => docs,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let mut books: Vec<BookCandidate> = docs
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
let authors: Vec<String> = doc
|
||||
.get("author_name")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let isbn = doc
|
||||
.get("isbn")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let page_count = doc
|
||||
.get("number_of_pages_median")
|
||||
.and_then(|n| n.as_i64())
|
||||
.map(|n| n as i32);
|
||||
let cover_i = doc.get("cover_i").and_then(|c| c.as_i64());
|
||||
let cover_url = cover_i.map(|id| format!("https://covers.openlibrary.org/b/id/{}-M.jpg", id));
|
||||
let language = doc
|
||||
.get("language")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let publish_date = doc
|
||||
.get("first_publish_year")
|
||||
.and_then(|y| y.as_i64())
|
||||
.map(|y| y.to_string());
|
||||
let key = doc.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();
|
||||
let volume_number = extract_volume_number(&title);
|
||||
|
||||
BookCandidate {
|
||||
external_book_id: key,
|
||||
title,
|
||||
volume_number,
|
||||
authors,
|
||||
isbn,
|
||||
summary: None,
|
||||
cover_url,
|
||||
page_count,
|
||||
language,
|
||||
publish_date,
|
||||
metadata_json: serde_json::json!({}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
fn extract_series_name(title: &str) -> String {
|
||||
let re_patterns = [
|
||||
r"(?i)\s*[-–—]\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||
r"(?i)\s*,?\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||
r"\s*\(\d+\)\s*$",
|
||||
r"\s+\d+\s*$",
|
||||
];
|
||||
let mut result = title.to_string();
|
||||
for pattern in &re_patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
let cleaned = re.replace(&result, "").to_string();
|
||||
if !cleaned.is_empty() {
|
||||
result = cleaned;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn extract_volume_number(title: &str) -> Option<i32> {
|
||||
let patterns = [
|
||||
r"(?i)(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*(\d+)",
|
||||
r"\((\d+)\)\s*$",
|
||||
r"\b(\d+)\s*$",
|
||||
];
|
||||
for pattern in &patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
if let Some(caps) = re.captures(title) {
|
||||
if let Some(num) = caps.get(1).and_then(|m| m.as_str().parse::<i32>().ok()) {
|
||||
return Some(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||
let title_lower = title.to_lowercase();
|
||||
if title_lower == query {
|
||||
1.0
|
||||
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||
0.8
|
||||
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||
0.7
|
||||
} else {
|
||||
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||
let max_len = query.len().max(title_lower.len()).max(1);
|
||||
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct SeriesCandidateBuilder {
|
||||
title: String,
|
||||
authors: Vec<String>,
|
||||
description: Option<String>,
|
||||
publishers: Vec<String>,
|
||||
start_year: Option<i32>,
|
||||
volume_count: i32,
|
||||
cover_url: Option<String>,
|
||||
external_id: String,
|
||||
external_url: Option<String>,
|
||||
}
|
||||
825
apps/api/src/metadata_refresh.rs
Normal file
825
apps/api/src/metadata_refresh.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{error::ApiError, metadata_providers, state::AppState};
|
||||
use crate::metadata_batch::{load_provider_config_from_pool, is_job_cancelled, update_progress};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct MetadataRefreshRequest {
|
||||
pub library_id: String,
|
||||
}
|
||||
|
||||
/// A single field change: old → new
|
||||
#[derive(Serialize, Clone)]
|
||||
struct FieldDiff {
|
||||
field: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
old: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
new: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Per-book changes
|
||||
#[derive(Serialize, Clone)]
|
||||
struct BookDiff {
|
||||
book_id: String,
|
||||
title: String,
|
||||
volume: Option<i32>,
|
||||
changes: Vec<FieldDiff>,
|
||||
}
|
||||
|
||||
/// Per-series change report
|
||||
#[derive(Serialize, Clone)]
|
||||
struct SeriesRefreshResult {
|
||||
series_name: String,
|
||||
provider: String,
|
||||
status: String, // "updated", "unchanged", "error"
|
||||
series_changes: Vec<FieldDiff>,
|
||||
book_changes: Vec<BookDiff>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Response DTO for the report endpoint
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MetadataRefreshReportDto {
|
||||
#[schema(value_type = String)]
|
||||
pub job_id: Uuid,
|
||||
pub status: String,
|
||||
pub total_links: i64,
|
||||
pub refreshed: i64,
|
||||
pub unchanged: i64,
|
||||
pub errors: i64,
|
||||
pub changes: serde_json::Value,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /metadata/refresh — Trigger a metadata refresh job
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/metadata/refresh",
|
||||
tag = "metadata",
|
||||
request_body = MetadataRefreshRequest,
|
||||
responses(
|
||||
(status = 200, description = "Job created"),
|
||||
(status = 400, description = "Bad request"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn start_refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<MetadataRefreshRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let library_id: Uuid = body
|
||||
.library_id
|
||||
.parse()
|
||||
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
|
||||
|
||||
// Verify library exists
|
||||
sqlx::query("SELECT 1 FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("library not found"))?;
|
||||
|
||||
// Check no existing running metadata_refresh job for this library
|
||||
let existing: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM index_jobs WHERE library_id = $1 AND type = 'metadata_refresh' AND status IN ('pending', 'running') LIMIT 1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(existing_id) = existing {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"id": existing_id.to_string(),
|
||||
"status": "already_running",
|
||||
})));
|
||||
}
|
||||
|
||||
// Check there are approved links to refresh
|
||||
let link_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if link_count == 0 {
|
||||
return Err(ApiError::bad_request("No approved metadata links to refresh for this library"));
|
||||
}
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Spawn the background processing task
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": job_id.to_string(),
|
||||
"status": "pending",
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /metadata/refresh/:id/report — Refresh report from stats_json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/metadata/refresh/{id}/report",
|
||||
tag = "metadata",
|
||||
params(("id" = String, Path, description = "Job UUID")),
|
||||
responses(
|
||||
(status = 200, body = MetadataRefreshReportDto),
|
||||
(status = 404, description = "Job not found"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_refresh_report(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(job_id): AxumPath<Uuid>,
|
||||
) -> Result<Json<MetadataRefreshReportDto>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT status, stats_json, total_files FROM index_jobs WHERE id = $1 AND type = 'metadata_refresh'",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("job not found"))?;
|
||||
|
||||
let job_status: String = row.get("status");
|
||||
let stats: Option<serde_json::Value> = row.get("stats_json");
|
||||
let total_files: Option<i32> = row.get("total_files");
|
||||
|
||||
let (refreshed, unchanged, errors, changes) = if let Some(ref s) = stats {
|
||||
(
|
||||
s.get("refreshed").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
s.get("unchanged").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
s.get("errors").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
s.get("changes").cloned().unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
} else {
|
||||
(0, 0, 0, serde_json::json!([]))
|
||||
};
|
||||
|
||||
Ok(Json(MetadataRefreshReportDto {
|
||||
job_id,
|
||||
status: job_status,
|
||||
total_links: total_files.unwrap_or(0) as i64,
|
||||
refreshed,
|
||||
unchanged,
|
||||
errors,
|
||||
changes,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn process_metadata_refresh(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
) -> Result<(), String> {
|
||||
// Set job to running
|
||||
sqlx::query("UPDATE index_jobs SET status = 'running', started_at = NOW() WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get all approved links for this library
|
||||
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, series_name, provider, external_id
|
||||
FROM external_metadata_links
|
||||
WHERE library_id = $1 AND status = 'approved'
|
||||
ORDER BY series_name
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let total = links.len() as i32;
|
||||
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.bind(total)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut processed = 0i32;
|
||||
let mut refreshed = 0i32;
|
||||
let mut unchanged = 0i32;
|
||||
let mut errors = 0i32;
|
||||
let mut all_results: Vec<SeriesRefreshResult> = Vec::new();
|
||||
|
||||
for (link_id, series_name, provider_name, external_id) in &links {
|
||||
// Check cancellation
|
||||
if is_job_cancelled(pool, job_id).await {
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'cancelled', finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match refresh_link(pool, *link_id, library_id, series_name, provider_name, external_id).await {
|
||||
Ok(result) => {
|
||||
if result.status == "updated" {
|
||||
refreshed += 1;
|
||||
info!("[METADATA_REFRESH] job={job_id} updated series='{series_name}' via {provider_name}");
|
||||
} else {
|
||||
unchanged += 1;
|
||||
}
|
||||
all_results.push(result);
|
||||
}
|
||||
Err(e) => {
|
||||
errors += 1;
|
||||
warn!("[METADATA_REFRESH] job={job_id} error on series='{series_name}': {e}");
|
||||
all_results.push(SeriesRefreshResult {
|
||||
series_name: series_name.clone(),
|
||||
provider: provider_name.clone(),
|
||||
status: "error".to_string(),
|
||||
series_changes: vec![],
|
||||
book_changes: vec![],
|
||||
error: Some(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processed += 1;
|
||||
update_progress(pool, job_id, processed, total, series_name).await;
|
||||
|
||||
// Rate limit: 1s delay between provider calls
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||
}
|
||||
|
||||
// Only keep series that have changes or errors (filter out "unchanged")
|
||||
let changes_only: Vec<&SeriesRefreshResult> = all_results
|
||||
.iter()
|
||||
.filter(|r| r.status != "unchanged")
|
||||
.collect();
|
||||
|
||||
// Build stats summary
|
||||
let stats = serde_json::json!({
|
||||
"total_links": total,
|
||||
"refreshed": refreshed,
|
||||
"unchanged": unchanged,
|
||||
"errors": errors,
|
||||
"changes": changes_only,
|
||||
});
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, stats_json = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(stats)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Refresh a single approved metadata link: re-fetch from provider, compare, sync, return diff
|
||||
async fn refresh_link(
|
||||
pool: &PgPool,
|
||||
link_id: Uuid,
|
||||
library_id: Uuid,
|
||||
series_name: &str,
|
||||
provider_name: &str,
|
||||
external_id: &str,
|
||||
) -> Result<SeriesRefreshResult, String> {
|
||||
let provider = metadata_providers::get_provider(provider_name)
|
||||
.ok_or_else(|| format!("Unknown provider: {provider_name}"))?;
|
||||
|
||||
let config = load_provider_config_from_pool(pool, provider_name).await;
|
||||
|
||||
let mut series_changes: Vec<FieldDiff> = Vec::new();
|
||||
let mut book_changes: Vec<BookDiff> = Vec::new();
|
||||
|
||||
// ── Series-level refresh ──────────────────────────────────────────────
|
||||
let candidates = provider
|
||||
.search_series(series_name, &config)
|
||||
.await
|
||||
.map_err(|e| format!("provider search error: {e}"))?;
|
||||
|
||||
let candidate = candidates
|
||||
.iter()
|
||||
.find(|c| c.external_id == external_id)
|
||||
.or_else(|| candidates.first());
|
||||
|
||||
if let Some(candidate) = candidate {
|
||||
// Update link metadata_json
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE external_metadata_links
|
||||
SET metadata_json = $2,
|
||||
total_volumes_external = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(link_id)
|
||||
.bind(&candidate.metadata_json)
|
||||
.bind(candidate.total_volumes)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Diff + sync series metadata
|
||||
series_changes = sync_series_with_diff(pool, library_id, series_name, candidate).await?;
|
||||
}
|
||||
|
||||
// ── Book-level refresh ────────────────────────────────────────────────
|
||||
let books = provider
|
||||
.get_series_books(external_id, &config)
|
||||
.await
|
||||
.map_err(|e| format!("provider books error: {e}"))?;
|
||||
|
||||
// Delete existing external_book_metadata for this link
|
||||
sqlx::query("DELETE FROM external_book_metadata WHERE link_id = $1")
|
||||
.bind(link_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Pre-fetch local books
|
||||
let local_books: Vec<(Uuid, Option<i32>, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, volume, title FROM books
|
||||
WHERE library_id = $1
|
||||
AND COALESCE(NULLIF(series, ''), 'unclassified') = $2
|
||||
ORDER BY volume NULLS LAST,
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
|
||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||
title ASC
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let local_books_with_pos: Vec<(Uuid, i32, String)> = local_books
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (id, vol, title))| (*id, vol.unwrap_or((idx + 1) as i32), title.clone()))
|
||||
.collect();
|
||||
|
||||
let mut matched_local_ids = std::collections::HashSet::new();
|
||||
|
||||
for (ext_idx, book) in books.iter().enumerate() {
|
||||
let ext_vol = book.volume_number.unwrap_or((ext_idx + 1) as i32);
|
||||
|
||||
// Match by volume number
|
||||
let mut local_book_id: Option<Uuid> = local_books_with_pos
|
||||
.iter()
|
||||
.find(|(id, v, _)| *v == ext_vol && !matched_local_ids.contains(id))
|
||||
.map(|(id, _, _)| *id);
|
||||
|
||||
// Match by title containment
|
||||
if local_book_id.is_none() {
|
||||
let ext_title_lower = book.title.to_lowercase();
|
||||
local_book_id = local_books_with_pos
|
||||
.iter()
|
||||
.find(|(id, _, local_title)| {
|
||||
if matched_local_ids.contains(id) {
|
||||
return false;
|
||||
}
|
||||
let local_lower = local_title.to_lowercase();
|
||||
local_lower.contains(&ext_title_lower) || ext_title_lower.contains(&local_lower)
|
||||
})
|
||||
.map(|(id, _, _)| *id);
|
||||
}
|
||||
|
||||
if let Some(id) = local_book_id {
|
||||
matched_local_ids.insert(id);
|
||||
}
|
||||
|
||||
// Insert external_book_metadata
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO external_book_metadata
|
||||
(link_id, book_id, external_book_id, volume_number, title, authors, isbn, summary, cover_url, page_count, language, publish_date, metadata_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
"#,
|
||||
)
|
||||
.bind(link_id)
|
||||
.bind(local_book_id)
|
||||
.bind(&book.external_book_id)
|
||||
.bind(book.volume_number)
|
||||
.bind(&book.title)
|
||||
.bind(&book.authors)
|
||||
.bind(&book.isbn)
|
||||
.bind(&book.summary)
|
||||
.bind(&book.cover_url)
|
||||
.bind(book.page_count)
|
||||
.bind(&book.language)
|
||||
.bind(&book.publish_date)
|
||||
.bind(&book.metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Diff + push metadata to matched local book
|
||||
if let Some(book_id) = local_book_id {
|
||||
let diffs = sync_book_with_diff(pool, book_id, book).await?;
|
||||
if !diffs.is_empty() {
|
||||
let local_title = local_books_with_pos
|
||||
.iter()
|
||||
.find(|(id, _, _)| *id == book_id)
|
||||
.map(|(_, _, t)| t.clone())
|
||||
.unwrap_or_default();
|
||||
book_changes.push(BookDiff {
|
||||
book_id: book_id.to_string(),
|
||||
title: local_title,
|
||||
volume: book.volume_number,
|
||||
changes: diffs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update synced_at on the link
|
||||
sqlx::query("UPDATE external_metadata_links SET synced_at = NOW(), updated_at = NOW() WHERE id = $1")
|
||||
.bind(link_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let has_changes = !series_changes.is_empty() || !book_changes.is_empty();
|
||||
|
||||
Ok(SeriesRefreshResult {
|
||||
series_name: series_name.to_string(),
|
||||
provider: provider_name.to_string(),
|
||||
status: if has_changes { "updated".to_string() } else { "unchanged".to_string() },
|
||||
series_changes,
|
||||
book_changes,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compare old/new for a nullable string field. Returns Some(FieldDiff) only if value actually changed.
|
||||
fn diff_opt_str(field: &str, old: Option<&str>, new: Option<&str>) -> Option<FieldDiff> {
|
||||
let new_val = new.filter(|s| !s.is_empty());
|
||||
// Only report a change if there is a new non-empty value AND it differs from old
|
||||
match (old, new_val) {
|
||||
(Some(o), Some(n)) if o != n => Some(FieldDiff {
|
||||
field: field.to_string(),
|
||||
old: Some(serde_json::Value::String(o.to_string())),
|
||||
new: Some(serde_json::Value::String(n.to_string())),
|
||||
}),
|
||||
(None, Some(n)) => Some(FieldDiff {
|
||||
field: field.to_string(),
|
||||
old: None,
|
||||
new: Some(serde_json::Value::String(n.to_string())),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_opt_i32(field: &str, old: Option<i32>, new: Option<i32>) -> Option<FieldDiff> {
|
||||
match (old, new) {
|
||||
(Some(o), Some(n)) if o != n => Some(FieldDiff {
|
||||
field: field.to_string(),
|
||||
old: Some(serde_json::json!(o)),
|
||||
new: Some(serde_json::json!(n)),
|
||||
}),
|
||||
(None, Some(n)) => Some(FieldDiff {
|
||||
field: field.to_string(),
|
||||
old: None,
|
||||
new: Some(serde_json::json!(n)),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_str_vec(field: &str, old: &[String], new: &[String]) -> Option<FieldDiff> {
|
||||
if new.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if old != new {
|
||||
Some(FieldDiff {
|
||||
field: field.to_string(),
|
||||
old: Some(serde_json::json!(old)),
|
||||
new: Some(serde_json::json!(new)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series sync with diff tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn sync_series_with_diff(
|
||||
pool: &PgPool,
|
||||
library_id: Uuid,
|
||||
series_name: &str,
|
||||
candidate: &metadata_providers::SeriesCandidate,
|
||||
) -> Result<Vec<FieldDiff>, String> {
|
||||
let new_description = candidate.metadata_json
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.or(candidate.description.as_deref());
|
||||
let new_authors = &candidate.authors;
|
||||
let new_publishers = &candidate.publishers;
|
||||
let new_start_year = candidate.start_year;
|
||||
let new_total_volumes = candidate.total_volumes;
|
||||
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
|
||||
Some(crate::metadata::normalize_series_status(pool, raw).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let new_status = new_status.as_deref();
|
||||
|
||||
// Fetch existing series metadata for diffing
|
||||
let existing = sqlx::query(
|
||||
r#"SELECT description, publishers, start_year, total_volumes, status, authors, locked_fields
|
||||
FROM series_metadata WHERE library_id = $1 AND name = $2"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let locked = existing
|
||||
.as_ref()
|
||||
.map(|r| r.get::<serde_json::Value, _>("locked_fields"))
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let is_locked = |field: &str| -> bool {
|
||||
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
};
|
||||
|
||||
// Build diffs (only for unlocked fields that actually change)
|
||||
let mut diffs: Vec<FieldDiff> = Vec::new();
|
||||
|
||||
if !is_locked("description") {
|
||||
let old_desc: Option<String> = existing.as_ref().and_then(|r| r.get("description"));
|
||||
if let Some(d) = diff_opt_str("description", old_desc.as_deref(), new_description) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("authors") {
|
||||
let old_authors: Vec<String> = existing.as_ref().map(|r| r.get("authors")).unwrap_or_default();
|
||||
if let Some(d) = diff_str_vec("authors", &old_authors, new_authors) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("publishers") {
|
||||
let old_publishers: Vec<String> = existing.as_ref().map(|r| r.get("publishers")).unwrap_or_default();
|
||||
if let Some(d) = diff_str_vec("publishers", &old_publishers, new_publishers) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("start_year") {
|
||||
let old_year: Option<i32> = existing.as_ref().and_then(|r| r.get("start_year"));
|
||||
if let Some(d) = diff_opt_i32("start_year", old_year, new_start_year) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("total_volumes") {
|
||||
let old_vols: Option<i32> = existing.as_ref().and_then(|r| r.get("total_volumes"));
|
||||
if let Some(d) = diff_opt_i32("total_volumes", old_vols, new_total_volumes) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("status") {
|
||||
let old_status: Option<String> = existing.as_ref().and_then(|r| r.get("status"));
|
||||
if let Some(d) = diff_opt_str("status", old_status.as_deref(), new_status) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Now do the actual upsert
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO series_metadata (library_id, name, description, publishers, start_year, total_volumes, status, authors, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
ON CONFLICT (library_id, name)
|
||||
DO UPDATE SET
|
||||
description = CASE
|
||||
WHEN (series_metadata.locked_fields->>'description')::boolean IS TRUE THEN series_metadata.description
|
||||
ELSE COALESCE(NULLIF(EXCLUDED.description, ''), series_metadata.description)
|
||||
END,
|
||||
publishers = CASE
|
||||
WHEN (series_metadata.locked_fields->>'publishers')::boolean IS TRUE THEN series_metadata.publishers
|
||||
WHEN array_length(EXCLUDED.publishers, 1) > 0 THEN EXCLUDED.publishers
|
||||
ELSE series_metadata.publishers
|
||||
END,
|
||||
start_year = CASE
|
||||
WHEN (series_metadata.locked_fields->>'start_year')::boolean IS TRUE THEN series_metadata.start_year
|
||||
ELSE COALESCE(EXCLUDED.start_year, series_metadata.start_year)
|
||||
END,
|
||||
total_volumes = CASE
|
||||
WHEN (series_metadata.locked_fields->>'total_volumes')::boolean IS TRUE THEN series_metadata.total_volumes
|
||||
ELSE COALESCE(EXCLUDED.total_volumes, series_metadata.total_volumes)
|
||||
END,
|
||||
status = CASE
|
||||
WHEN (series_metadata.locked_fields->>'status')::boolean IS TRUE THEN series_metadata.status
|
||||
ELSE COALESCE(EXCLUDED.status, series_metadata.status)
|
||||
END,
|
||||
authors = CASE
|
||||
WHEN (series_metadata.locked_fields->>'authors')::boolean IS TRUE THEN series_metadata.authors
|
||||
WHEN array_length(EXCLUDED.authors, 1) > 0 THEN EXCLUDED.authors
|
||||
ELSE series_metadata.authors
|
||||
END,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.bind(new_description)
|
||||
.bind(new_publishers)
|
||||
.bind(new_start_year)
|
||||
.bind(new_total_volumes)
|
||||
.bind(new_status)
|
||||
.bind(new_authors)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book sync with diff tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn sync_book_with_diff(
|
||||
pool: &PgPool,
|
||||
book_id: Uuid,
|
||||
ext_book: &metadata_providers::BookCandidate,
|
||||
) -> Result<Vec<FieldDiff>, String> {
|
||||
// Fetch current book state
|
||||
let current = sqlx::query(
|
||||
"SELECT summary, isbn, publish_date, language, authors, locked_fields FROM books WHERE id = $1",
|
||||
)
|
||||
.bind(book_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let locked = current.get::<serde_json::Value, _>("locked_fields");
|
||||
let is_locked = |field: &str| -> bool {
|
||||
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
};
|
||||
|
||||
// Build diffs
|
||||
let mut diffs: Vec<FieldDiff> = Vec::new();
|
||||
|
||||
if !is_locked("summary") {
|
||||
let old: Option<String> = current.get("summary");
|
||||
if let Some(d) = diff_opt_str("summary", old.as_deref(), ext_book.summary.as_deref()) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("isbn") {
|
||||
let old: Option<String> = current.get("isbn");
|
||||
if let Some(d) = diff_opt_str("isbn", old.as_deref(), ext_book.isbn.as_deref()) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("publish_date") {
|
||||
let old: Option<String> = current.get("publish_date");
|
||||
if let Some(d) = diff_opt_str("publish_date", old.as_deref(), ext_book.publish_date.as_deref()) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("language") {
|
||||
let old: Option<String> = current.get("language");
|
||||
if let Some(d) = diff_opt_str("language", old.as_deref(), ext_book.language.as_deref()) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
if !is_locked("authors") {
|
||||
let old: Vec<String> = current.get("authors");
|
||||
if let Some(d) = diff_str_vec("authors", &old, &ext_book.authors) {
|
||||
diffs.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Do the actual update
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE books SET
|
||||
summary = CASE
|
||||
WHEN (locked_fields->>'summary')::boolean IS TRUE THEN summary
|
||||
ELSE COALESCE(NULLIF($2, ''), summary)
|
||||
END,
|
||||
isbn = CASE
|
||||
WHEN (locked_fields->>'isbn')::boolean IS TRUE THEN isbn
|
||||
ELSE COALESCE(NULLIF($3, ''), isbn)
|
||||
END,
|
||||
publish_date = CASE
|
||||
WHEN (locked_fields->>'publish_date')::boolean IS TRUE THEN publish_date
|
||||
ELSE COALESCE(NULLIF($4, ''), publish_date)
|
||||
END,
|
||||
language = CASE
|
||||
WHEN (locked_fields->>'language')::boolean IS TRUE THEN language
|
||||
ELSE COALESCE(NULLIF($5, ''), language)
|
||||
END,
|
||||
authors = CASE
|
||||
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN authors
|
||||
WHEN CARDINALITY($6::text[]) > 0 THEN $6
|
||||
ELSE authors
|
||||
END,
|
||||
author = CASE
|
||||
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN author
|
||||
WHEN CARDINALITY($6::text[]) > 0 THEN $6[1]
|
||||
ELSE author
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(book_id)
|
||||
.bind(&ext_book.summary)
|
||||
.bind(&ext_book.isbn)
|
||||
.bind(&ext_book.publish_date)
|
||||
.bind(&ext_book.language)
|
||||
.bind(&ext_book.authors)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
@@ -10,11 +10,14 @@ use utoipa::OpenApi;
|
||||
crate::reading_progress::update_reading_progress,
|
||||
crate::reading_progress::mark_series_read,
|
||||
crate::books::get_thumbnail,
|
||||
crate::books::list_series,
|
||||
crate::books::list_all_series,
|
||||
crate::books::ongoing_series,
|
||||
crate::books::ongoing_books,
|
||||
crate::series::list_series,
|
||||
crate::series::list_all_series,
|
||||
crate::series::ongoing_series,
|
||||
crate::series::ongoing_books,
|
||||
crate::books::convert_book,
|
||||
crate::books::update_book,
|
||||
crate::series::get_series_metadata,
|
||||
crate::series::update_series,
|
||||
crate::pages::get_page,
|
||||
crate::search::search_books,
|
||||
crate::index_jobs::enqueue_rebuild,
|
||||
@@ -32,16 +35,43 @@ use utoipa::OpenApi;
|
||||
crate::libraries::delete_library,
|
||||
crate::libraries::scan_library,
|
||||
crate::libraries::update_monitoring,
|
||||
crate::libraries::update_metadata_provider,
|
||||
crate::tokens::list_tokens,
|
||||
crate::tokens::create_token,
|
||||
crate::tokens::revoke_token,
|
||||
crate::tokens::delete_token,
|
||||
crate::authors::list_authors,
|
||||
crate::stats::get_stats,
|
||||
crate::settings::get_settings,
|
||||
crate::settings::get_setting,
|
||||
crate::settings::update_setting,
|
||||
crate::settings::clear_cache,
|
||||
crate::settings::get_cache_stats,
|
||||
crate::settings::get_thumbnail_stats,
|
||||
crate::metadata::search_metadata,
|
||||
crate::metadata::create_metadata_match,
|
||||
crate::metadata::approve_metadata,
|
||||
crate::metadata::reject_metadata,
|
||||
crate::metadata::get_metadata_links,
|
||||
crate::metadata::get_missing_books,
|
||||
crate::metadata::delete_metadata_link,
|
||||
crate::series::series_statuses,
|
||||
crate::series::provider_statuses,
|
||||
crate::settings::list_status_mappings,
|
||||
crate::settings::upsert_status_mapping,
|
||||
crate::settings::delete_status_mapping,
|
||||
crate::prowlarr::search_prowlarr,
|
||||
crate::prowlarr::test_prowlarr,
|
||||
crate::qbittorrent::add_torrent,
|
||||
crate::qbittorrent::test_qbittorrent,
|
||||
crate::metadata_batch::start_batch,
|
||||
crate::metadata_batch::get_batch_report,
|
||||
crate::metadata_batch::get_batch_results,
|
||||
crate::metadata_refresh::start_refresh,
|
||||
crate::metadata_refresh::get_refresh_report,
|
||||
crate::komga::sync_komga_read_books,
|
||||
crate::komga::list_sync_reports,
|
||||
crate::komga::get_sync_report,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
@@ -53,10 +83,14 @@ use utoipa::OpenApi;
|
||||
crate::reading_progress::UpdateReadingProgressRequest,
|
||||
crate::reading_progress::MarkSeriesReadRequest,
|
||||
crate::reading_progress::MarkSeriesReadResponse,
|
||||
crate::books::SeriesItem,
|
||||
crate::books::SeriesPage,
|
||||
crate::books::ListAllSeriesQuery,
|
||||
crate::books::OngoingQuery,
|
||||
crate::series::SeriesItem,
|
||||
crate::series::SeriesPage,
|
||||
crate::series::ListAllSeriesQuery,
|
||||
crate::series::OngoingQuery,
|
||||
crate::books::UpdateBookRequest,
|
||||
crate::series::SeriesMetadata,
|
||||
crate::series::UpdateSeriesRequest,
|
||||
crate::series::UpdateSeriesResponse,
|
||||
crate::pages::PageQuery,
|
||||
crate::search::SearchQuery,
|
||||
crate::search::SearchResponse,
|
||||
@@ -71,6 +105,7 @@ use utoipa::OpenApi;
|
||||
crate::libraries::LibraryResponse,
|
||||
crate::libraries::CreateLibraryRequest,
|
||||
crate::libraries::UpdateMonitoringRequest,
|
||||
crate::libraries::UpdateMetadataProviderRequest,
|
||||
crate::tokens::CreateTokenRequest,
|
||||
crate::tokens::TokenResponse,
|
||||
crate::tokens::CreatedTokenResponse,
|
||||
@@ -78,6 +113,50 @@ use utoipa::OpenApi;
|
||||
crate::settings::ClearCacheResponse,
|
||||
crate::settings::CacheStats,
|
||||
crate::settings::ThumbnailStats,
|
||||
crate::settings::StatusMappingDto,
|
||||
crate::settings::UpsertStatusMappingRequest,
|
||||
crate::authors::ListAuthorsQuery,
|
||||
crate::authors::AuthorItem,
|
||||
crate::authors::AuthorsPageResponse,
|
||||
crate::stats::StatsResponse,
|
||||
crate::stats::StatsOverview,
|
||||
crate::stats::ReadingStatusStats,
|
||||
crate::stats::FormatCount,
|
||||
crate::stats::LanguageCount,
|
||||
crate::stats::LibraryStats,
|
||||
crate::stats::TopSeries,
|
||||
crate::stats::MonthlyAdditions,
|
||||
crate::stats::MetadataStats,
|
||||
crate::stats::ProviderCount,
|
||||
crate::metadata::ApproveRequest,
|
||||
crate::metadata::ApproveResponse,
|
||||
crate::metadata::SyncReport,
|
||||
crate::metadata::SeriesSyncReport,
|
||||
crate::metadata::BookSyncReport,
|
||||
crate::metadata::FieldChange,
|
||||
crate::metadata::MetadataSearchRequest,
|
||||
crate::metadata::SeriesCandidateDto,
|
||||
crate::metadata::MetadataMatchRequest,
|
||||
crate::metadata::ExternalMetadataLinkDto,
|
||||
crate::metadata::MissingBooksDto,
|
||||
crate::metadata::MissingBookItem,
|
||||
crate::qbittorrent::QBittorrentAddRequest,
|
||||
crate::qbittorrent::QBittorrentAddResponse,
|
||||
crate::qbittorrent::QBittorrentTestResponse,
|
||||
crate::prowlarr::ProwlarrSearchRequest,
|
||||
crate::prowlarr::ProwlarrRelease,
|
||||
crate::prowlarr::ProwlarrCategory,
|
||||
crate::prowlarr::ProwlarrSearchResponse,
|
||||
crate::prowlarr::MissingVolumeInput,
|
||||
crate::prowlarr::ProwlarrTestResponse,
|
||||
crate::metadata_batch::MetadataBatchRequest,
|
||||
crate::metadata_batch::MetadataBatchReportDto,
|
||||
crate::metadata_batch::MetadataBatchResultDto,
|
||||
crate::metadata_refresh::MetadataRefreshRequest,
|
||||
crate::metadata_refresh::MetadataRefreshReportDto,
|
||||
crate::komga::KomgaSyncRequest,
|
||||
crate::komga::KomgaSyncResponse,
|
||||
crate::komga::KomgaSyncReportSummary,
|
||||
ErrorResponse,
|
||||
)
|
||||
),
|
||||
@@ -85,12 +164,20 @@ use utoipa::OpenApi;
|
||||
("Bearer" = [])
|
||||
),
|
||||
tags(
|
||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
||||
(name = "books", description = "Book browsing, details and management"),
|
||||
(name = "series", description = "Series browsing, filtering and management"),
|
||||
(name = "search", description = "Full-text search across books and series"),
|
||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
||||
(name = "authors", description = "Author browsing and listing"),
|
||||
(name = "stats", description = "Collection statistics and dashboard data"),
|
||||
(name = "libraries", description = "Library listing, scanning, and management (create/delete/settings: Admin only)"),
|
||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||
(name = "metadata", description = "External metadata providers and matching (Admin only)"),
|
||||
(name = "komga", description = "Komga read-status sync (Admin only)"),
|
||||
(name = "tokens", description = "API token management (Admin only)"),
|
||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||
(name = "qbittorrent", description = "qBittorrent download client integration (Admin only)"),
|
||||
),
|
||||
modifiers(&SecurityAddon)
|
||||
)]
|
||||
|
||||
@@ -277,7 +277,17 @@ pub async fn get_page(
|
||||
let cache_dir2 = cache_dir_path.clone();
|
||||
let format2 = format;
|
||||
tokio::spawn(async move {
|
||||
prefetch_page(state2, book_id, &abs_path2, next_page, format2, quality, width, filter, timeout_secs, &cache_dir2).await;
|
||||
prefetch_page(state2, &PrefetchParams {
|
||||
book_id,
|
||||
abs_path: &abs_path2,
|
||||
page: next_page,
|
||||
format: format2,
|
||||
quality,
|
||||
width,
|
||||
filter,
|
||||
timeout_secs,
|
||||
cache_dir: &cache_dir2,
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,19 +300,30 @@ pub async fn get_page(
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
|
||||
async fn prefetch_page(
|
||||
state: AppState,
|
||||
struct PrefetchParams<'a> {
|
||||
book_id: Uuid,
|
||||
abs_path: &str,
|
||||
abs_path: &'a str,
|
||||
page: u32,
|
||||
format: OutputFormat,
|
||||
quality: u8,
|
||||
width: u32,
|
||||
filter: image::imageops::FilterType,
|
||||
timeout_secs: u64,
|
||||
cache_dir: &Path,
|
||||
) {
|
||||
cache_dir: &'a Path,
|
||||
}
|
||||
|
||||
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
|
||||
async fn prefetch_page(state: AppState, params: &PrefetchParams<'_>) {
|
||||
let book_id = params.book_id;
|
||||
let page = params.page;
|
||||
let format = params.format;
|
||||
let quality = params.quality;
|
||||
let width = params.width;
|
||||
let filter = params.filter;
|
||||
let timeout_secs = params.timeout_secs;
|
||||
let abs_path = params.abs_path;
|
||||
let cache_dir = params.cache_dir;
|
||||
|
||||
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
|
||||
// Already in memory cache?
|
||||
if state.page_cache.lock().await.contains(&mem_key) {
|
||||
@@ -330,6 +351,7 @@ async fn prefetch_page(
|
||||
Some(ref e) if e == "cbz" => "cbz",
|
||||
Some(ref e) if e == "cbr" => "cbr",
|
||||
Some(ref e) if e == "pdf" => "pdf",
|
||||
Some(ref e) if e == "epub" => "epub",
|
||||
_ => return,
|
||||
}
|
||||
.to_string();
|
||||
@@ -458,6 +480,7 @@ fn render_page(
|
||||
"cbz" => parsers::BookFormat::Cbz,
|
||||
"cbr" => parsers::BookFormat::Cbr,
|
||||
"pdf" => parsers::BookFormat::Pdf,
|
||||
"epub" => parsers::BookFormat::Epub,
|
||||
_ => return Err(ApiError::bad_request("unsupported source format")),
|
||||
};
|
||||
|
||||
|
||||
363
apps/api/src/prowlarr.rs
Normal file
363
apps/api/src/prowlarr.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct MissingVolumeInput {
|
||||
pub volume_number: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ProwlarrSearchRequest {
|
||||
pub series_name: String,
|
||||
pub volume_number: Option<i32>,
|
||||
pub custom_query: Option<String>,
|
||||
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrRawRelease {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
pub size: i64,
|
||||
pub download_url: Option<String>,
|
||||
pub indexer: Option<String>,
|
||||
pub seeders: Option<i32>,
|
||||
pub leechers: Option<i32>,
|
||||
pub publish_date: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrRelease {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
pub size: i64,
|
||||
pub download_url: Option<String>,
|
||||
pub indexer: Option<String>,
|
||||
pub seeders: Option<i32>,
|
||||
pub leechers: Option<i32>,
|
||||
pub publish_date: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrCategory {
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ProwlarrSearchResponse {
|
||||
pub results: Vec<ProwlarrRelease>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ProwlarrTestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub indexer_count: Option<i32>,
|
||||
}
|
||||
|
||||
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProwlarrConfig {
|
||||
url: String,
|
||||
api_key: String,
|
||||
categories: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
async fn load_prowlarr_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<(String, String, Vec<i32>), ApiError> {
|
||||
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'prowlarr'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::bad_request("Prowlarr is not configured"))?;
|
||||
let value: serde_json::Value = row.get("value");
|
||||
let config: ProwlarrConfig = serde_json::from_value(value)
|
||||
.map_err(|e| ApiError::internal(format!("invalid prowlarr config: {e}")))?;
|
||||
|
||||
if config.url.is_empty() || config.api_key.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"Prowlarr URL and API key must be configured in settings",
|
||||
));
|
||||
}
|
||||
|
||||
let url = config.url.trim_end_matches('/').to_string();
|
||||
let categories = config.categories.unwrap_or_else(|| vec![7030, 7020]);
|
||||
|
||||
Ok((url, config.api_key, categories))
|
||||
}
|
||||
|
||||
// ─── Volume matching ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract volume numbers from a release title.
|
||||
/// Looks for patterns like: T01, Tome 01, Vol. 01, v01, #01,
|
||||
/// or standalone numbers that appear after common separators.
|
||||
fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
||||
let lower = title.to_lowercase();
|
||||
let mut volumes = Vec::new();
|
||||
|
||||
// Patterns: T01, Tome 01, Tome01, Vol 01, Vol.01, v01, #01
|
||||
let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"];
|
||||
let chars: Vec<char> = lower.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
for prefix in &prefixes {
|
||||
let mut start = 0;
|
||||
while let Some(pos) = lower[start..].find(prefix) {
|
||||
let abs_pos = start + pos;
|
||||
let after = abs_pos + prefix.len();
|
||||
|
||||
// For single-char prefixes (t, v, #), ensure it's at a word boundary
|
||||
if prefix.len() == 1 && *prefix != "#" {
|
||||
if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() {
|
||||
start = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip optional spaces after prefix
|
||||
let mut i = after;
|
||||
while i < len && chars[i] == ' ' {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Read digits
|
||||
let digit_start = i;
|
||||
while i < len && chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i > digit_start {
|
||||
if let Ok(num) = lower[digit_start..i].parse::<i32>() {
|
||||
if !volumes.contains(&num) {
|
||||
volumes.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start = after;
|
||||
}
|
||||
}
|
||||
|
||||
volumes
|
||||
}
|
||||
|
||||
/// Match releases against missing volume numbers.
|
||||
fn match_missing_volumes(
|
||||
releases: Vec<ProwlarrRawRelease>,
|
||||
missing: &[MissingVolumeInput],
|
||||
) -> Vec<ProwlarrRelease> {
|
||||
let missing_numbers: Vec<i32> = missing
|
||||
.iter()
|
||||
.filter_map(|m| m.volume_number)
|
||||
.collect();
|
||||
|
||||
releases
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let matched = if missing_numbers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let title_volumes = extract_volumes_from_title(&r.title);
|
||||
let matched: Vec<i32> = title_volumes
|
||||
.into_iter()
|
||||
.filter(|v| missing_numbers.contains(v))
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(matched)
|
||||
}
|
||||
};
|
||||
|
||||
ProwlarrRelease {
|
||||
guid: r.guid,
|
||||
title: r.title,
|
||||
size: r.size,
|
||||
download_url: r.download_url,
|
||||
indexer: r.indexer,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
publish_date: r.publish_date,
|
||||
protocol: r.protocol,
|
||||
info_url: r.info_url,
|
||||
categories: r.categories,
|
||||
matched_missing_volumes: matched,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Search for releases on Prowlarr
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/prowlarr/search",
|
||||
tag = "prowlarr",
|
||||
request_body = ProwlarrSearchRequest,
|
||||
responses(
|
||||
(status = 200, body = ProwlarrSearchResponse),
|
||||
(status = 400, description = "Bad request or Prowlarr not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Prowlarr connection error"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn search_prowlarr(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ProwlarrSearchRequest>,
|
||||
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
|
||||
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
|
||||
|
||||
let query = if let Some(custom) = &body.custom_query {
|
||||
custom.clone()
|
||||
} else if let Some(vol) = body.volume_number {
|
||||
format!("\"{}\" {}", body.series_name, vol)
|
||||
} else {
|
||||
format!("\"{}\"", body.series_name)
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let mut params: Vec<(&str, String)> = vec![
|
||||
("query", query.clone()),
|
||||
("type", "search".to_string()),
|
||||
];
|
||||
for cat in &categories {
|
||||
params.push(("categories", cat.to_string()));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(format!("{url}/api/v1/search"))
|
||||
.query(¶ms)
|
||||
.header("X-Api-Key", &api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Prowlarr request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::internal(format!(
|
||||
"Prowlarr returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?;
|
||||
|
||||
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
|
||||
|
||||
let raw_releases: Vec<ProwlarrRawRelease> = serde_json::from_str(&raw_text)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to parse Prowlarr response: {e}");
|
||||
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
||||
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
|
||||
})?;
|
||||
|
||||
let results = if let Some(missing) = &body.missing_volumes {
|
||||
match_missing_volumes(raw_releases, missing)
|
||||
} else {
|
||||
raw_releases
|
||||
.into_iter()
|
||||
.map(|r| ProwlarrRelease {
|
||||
guid: r.guid,
|
||||
title: r.title,
|
||||
size: r.size,
|
||||
download_url: r.download_url,
|
||||
indexer: r.indexer,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
publish_date: r.publish_date,
|
||||
protocol: r.protocol,
|
||||
info_url: r.info_url,
|
||||
categories: r.categories,
|
||||
matched_missing_volumes: None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||
}
|
||||
|
||||
/// Test connection to Prowlarr
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/prowlarr/test",
|
||||
tag = "prowlarr",
|
||||
responses(
|
||||
(status = 200, body = ProwlarrTestResponse),
|
||||
(status = 400, description = "Prowlarr not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn test_prowlarr(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ProwlarrTestResponse>, ApiError> {
|
||||
let (url, api_key, _categories) = load_prowlarr_config(&state.pool).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{url}/api/v1/indexer"))
|
||||
.header("X-Api-Key", &api_key)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let indexers: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
|
||||
Ok(Json(ProwlarrTestResponse {
|
||||
success: true,
|
||||
message: format!("Connected successfully ({} indexers)", indexers.len()),
|
||||
indexer_count: Some(indexers.len() as i32),
|
||||
}))
|
||||
}
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
Ok(Json(ProwlarrTestResponse {
|
||||
success: false,
|
||||
message: format!("Prowlarr returned {status}: {text}"),
|
||||
indexer_count: None,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(Json(ProwlarrTestResponse {
|
||||
success: false,
|
||||
message: format!("Connection failed: {e}"),
|
||||
indexer_count: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
218
apps/api/src/qbittorrent.rs
Normal file
218
apps/api/src/qbittorrent.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct QBittorrentAddRequest {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct QBittorrentAddResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct QBittorrentTestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QBittorrentConfig {
|
||||
url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn load_qbittorrent_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<(String, String, String), ApiError> {
|
||||
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'qbittorrent'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::bad_request("qBittorrent is not configured"))?;
|
||||
let value: serde_json::Value = row.get("value");
|
||||
let config: QBittorrentConfig = serde_json::from_value(value)
|
||||
.map_err(|e| ApiError::internal(format!("invalid qbittorrent config: {e}")))?;
|
||||
|
||||
if config.url.is_empty() || config.username.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"qBittorrent URL and username must be configured in settings",
|
||||
));
|
||||
}
|
||||
|
||||
let url = config.url.trim_end_matches('/').to_string();
|
||||
Ok((url, config.username, config.password))
|
||||
}
|
||||
|
||||
// ─── Login helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn qbittorrent_login(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let resp = client
|
||||
.post(format!("{base_url}/api/v2/auth/login"))
|
||||
.form(&[("username", username), ("password", password)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("qBittorrent login request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::internal(format!(
|
||||
"qBittorrent login failed ({status}): {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract SID from Set-Cookie header
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let sid = cookie_header
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("SID="))
|
||||
.ok_or_else(|| ApiError::internal("Failed to get SID cookie from qBittorrent"))?
|
||||
.to_string();
|
||||
|
||||
Ok(sid)
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Add a torrent to qBittorrent
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/qbittorrent/add",
|
||||
tag = "qbittorrent",
|
||||
request_body = QBittorrentAddRequest,
|
||||
responses(
|
||||
(status = 200, body = QBittorrentAddResponse),
|
||||
(status = 400, description = "Bad request or qBittorrent not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "qBittorrent connection error"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn add_torrent(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<QBittorrentAddRequest>,
|
||||
) -> Result<Json<QBittorrentAddResponse>, ApiError> {
|
||||
if body.url.is_empty() {
|
||||
return Err(ApiError::bad_request("url is required"));
|
||||
}
|
||||
|
||||
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let sid = qbittorrent_login(&client, &base_url, &username, &password).await?;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{base_url}/api/v2/torrents/add"))
|
||||
.header("Cookie", format!("SID={sid}"))
|
||||
.form(&[("urls", &body.url)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(Json(QBittorrentAddResponse {
|
||||
success: true,
|
||||
message: "Torrent added to qBittorrent".to_string(),
|
||||
}))
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentAddResponse {
|
||||
success: false,
|
||||
message: format!("qBittorrent returned {status}: {text}"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to qBittorrent
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/qbittorrent/test",
|
||||
tag = "qbittorrent",
|
||||
responses(
|
||||
(status = 200, body = QBittorrentTestResponse),
|
||||
(status = 400, description = "qBittorrent not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn test_qbittorrent(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<QBittorrentTestResponse>, ApiError> {
|
||||
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let sid = match qbittorrent_login(&client, &base_url, &username, &password).await {
|
||||
Ok(sid) => sid,
|
||||
Err(e) => {
|
||||
return Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("Login failed: {}", e.message),
|
||||
version: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/v2/app/version"))
|
||||
.header("Cookie", format!("SID={sid}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let version = r.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentTestResponse {
|
||||
success: true,
|
||||
message: format!("Connected successfully ({})", version.trim()),
|
||||
version: Some(version.trim().to_string()),
|
||||
}))
|
||||
}
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("qBittorrent returned {status}: {text}"),
|
||||
version: None,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(Json(QBittorrentTestResponse {
|
||||
success: false,
|
||||
message: format!("Connection failed: {e}"),
|
||||
version: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,15 @@ pub struct SearchResponse {
|
||||
pub processing_time_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Search books across all libraries using Meilisearch
|
||||
/// Search books across all libraries
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/search",
|
||||
tag = "books",
|
||||
tag = "search",
|
||||
params(
|
||||
("q" = String, Query, description = "Search query (books via Meilisearch + series via ILIKE)"),
|
||||
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf, epub)"),
|
||||
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
||||
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
|
||||
),
|
||||
@@ -65,34 +65,38 @@ pub async fn search_books(
|
||||
return Err(ApiError::bad_request("q is required"));
|
||||
}
|
||||
|
||||
let mut filters: Vec<String> = Vec::new();
|
||||
if let Some(library_id) = query.library_id.as_deref() {
|
||||
filters.push(format!("library_id = '{}'", library_id.replace('"', "")));
|
||||
}
|
||||
let kind_filter = query.r#type.as_deref().or(query.kind.as_deref());
|
||||
if let Some(kind) = kind_filter {
|
||||
filters.push(format!("kind = '{}'", kind.replace('"', "")));
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"q": query.q,
|
||||
"limit": query.limit.unwrap_or(20).clamp(1, 100),
|
||||
"filter": if filters.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(filters.join(" AND ")) }
|
||||
});
|
||||
|
||||
let limit_val = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
let limit_val = query.limit.unwrap_or(20).clamp(1, 100) as i64;
|
||||
let q_pattern = format!("%{}%", query.q);
|
||||
let library_id_uuid: Option<uuid::Uuid> = query.library_id.as_deref()
|
||||
let library_id_uuid: Option<Uuid> = query.library_id.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let kind_filter: Option<&str> = query.r#type.as_deref().or(query.kind.as_deref());
|
||||
|
||||
// Recherche Meilisearch (books) + séries PG en parallèle
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/indexes/books/search", state.meili_url.trim_end_matches('/'));
|
||||
let meili_fut = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", state.meili_master_key))
|
||||
.json(&body)
|
||||
.send();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Book search via PostgreSQL ILIKE on title, authors, series
|
||||
let books_sql = r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.title,
|
||||
COALESCE(b.authors, CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END) as authors,
|
||||
b.series, b.volume, b.language
|
||||
FROM books b
|
||||
LEFT JOIN series_metadata sm
|
||||
ON sm.library_id = b.library_id
|
||||
AND sm.name = COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
WHERE (
|
||||
b.title ILIKE $1
|
||||
OR b.series ILIKE $1
|
||||
OR EXISTS (SELECT 1 FROM unnest(
|
||||
COALESCE(b.authors, CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)
|
||||
|| COALESCE(sm.authors, ARRAY[]::text[])
|
||||
) AS a WHERE a ILIKE $1)
|
||||
)
|
||||
AND ($2::uuid IS NULL OR b.library_id = $2)
|
||||
AND ($3::text IS NULL OR b.kind = $3)
|
||||
ORDER BY
|
||||
CASE WHEN b.title ILIKE $1 THEN 0 ELSE 1 END,
|
||||
b.title ASC
|
||||
LIMIT $4
|
||||
"#;
|
||||
|
||||
let series_sql = r#"
|
||||
WITH sorted_books AS (
|
||||
@@ -108,7 +112,7 @@ pub async fn search_books(
|
||||
title ASC
|
||||
) as rn
|
||||
FROM books
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1)
|
||||
WHERE ($2::uuid IS NULL OR library_id = $2)
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT
|
||||
@@ -123,39 +127,49 @@ pub async fn search_books(
|
||||
SELECT sc.library_id, sc.name, sc.book_count, sc.books_read_count, sb.id as first_book_id
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.library_id = sc.library_id AND sb.name = sc.name AND sb.rn = 1
|
||||
WHERE sc.name ILIKE $2
|
||||
WHERE sc.name ILIKE $1
|
||||
ORDER BY sc.name ASC
|
||||
LIMIT $3
|
||||
LIMIT $4
|
||||
"#;
|
||||
|
||||
let series_fut = sqlx::query(series_sql)
|
||||
.bind(library_id_uuid)
|
||||
let (books_rows, series_rows) = tokio::join!(
|
||||
sqlx::query(books_sql)
|
||||
.bind(&q_pattern)
|
||||
.bind(limit_val as i64)
|
||||
.fetch_all(&state.pool);
|
||||
.bind(library_id_uuid)
|
||||
.bind(kind_filter)
|
||||
.bind(limit_val)
|
||||
.fetch_all(&state.pool),
|
||||
sqlx::query(series_sql)
|
||||
.bind(&q_pattern)
|
||||
.bind(library_id_uuid)
|
||||
.bind(kind_filter) // unused in series query but keeps bind positions consistent
|
||||
.bind(limit_val)
|
||||
.fetch_all(&state.pool)
|
||||
);
|
||||
|
||||
let (meili_resp, series_rows) = tokio::join!(meili_fut, series_fut);
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
// Traitement Meilisearch
|
||||
let meili_resp = meili_resp.map_err(|e| ApiError::internal(format!("meili request failed: {e}")))?;
|
||||
let (hits, estimated_total_hits, processing_time_ms) = if !meili_resp.status().is_success() {
|
||||
let body = meili_resp.text().await.unwrap_or_default();
|
||||
if body.contains("index_not_found") {
|
||||
(serde_json::json!([]), Some(0u64), Some(0u64))
|
||||
} else {
|
||||
return Err(ApiError::internal(format!("meili error: {body}")));
|
||||
}
|
||||
} else {
|
||||
let payload: serde_json::Value = meili_resp.json().await
|
||||
.map_err(|e| ApiError::internal(format!("invalid meili response: {e}")))?;
|
||||
(
|
||||
payload.get("hits").cloned().unwrap_or_else(|| serde_json::json!([])),
|
||||
payload.get("estimatedTotalHits").and_then(|v| v.as_u64()),
|
||||
payload.get("processingTimeMs").and_then(|v| v.as_u64()),
|
||||
)
|
||||
};
|
||||
// Build book hits as JSON array (same shape as before)
|
||||
let books_rows = books_rows.map_err(|e| ApiError::internal(format!("book search failed: {e}")))?;
|
||||
let hits: Vec<serde_json::Value> = books_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
serde_json::json!({
|
||||
"id": row.get::<Uuid, _>("id").to_string(),
|
||||
"library_id": row.get::<Uuid, _>("library_id").to_string(),
|
||||
"kind": row.get::<String, _>("kind"),
|
||||
"title": row.get::<String, _>("title"),
|
||||
"authors": row.get::<Vec<String>, _>("authors"),
|
||||
"series": row.get::<Option<String>, _>("series"),
|
||||
"volume": row.get::<Option<i32>, _>("volume"),
|
||||
"language": row.get::<Option<String>, _>("language"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Traitement séries
|
||||
let estimated_total_hits = hits.len() as u64;
|
||||
|
||||
// Series hits
|
||||
let series_hits: Vec<SeriesHit> = series_rows
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
@@ -169,9 +183,9 @@ pub async fn search_books(
|
||||
.collect();
|
||||
|
||||
Ok(Json(SearchResponse {
|
||||
hits,
|
||||
hits: serde_json::Value::Array(hits),
|
||||
series_hits,
|
||||
estimated_total_hits,
|
||||
processing_time_ms,
|
||||
estimated_total_hits: Some(estimated_total_hits),
|
||||
processing_time_ms: Some(elapsed_ms),
|
||||
}))
|
||||
}
|
||||
|
||||
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
@@ -1,11 +1,12 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
extract::{Path as AxumPath, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
||||
@@ -42,6 +43,14 @@ pub fn settings_routes() -> Router<AppState> {
|
||||
.route("/settings/cache/clear", post(clear_cache))
|
||||
.route("/settings/cache/stats", get(get_cache_stats))
|
||||
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
||||
.route(
|
||||
"/settings/status-mappings",
|
||||
get(list_status_mappings).post(upsert_status_mapping),
|
||||
)
|
||||
.route(
|
||||
"/settings/status-mappings/:id",
|
||||
delete(delete_status_mapping),
|
||||
)
|
||||
}
|
||||
|
||||
/// List all settings
|
||||
@@ -324,3 +333,125 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct StatusMappingDto {
|
||||
pub id: String,
|
||||
pub provider_status: String,
|
||||
pub mapped_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct UpsertStatusMappingRequest {
|
||||
pub provider_status: String,
|
||||
pub mapped_status: String,
|
||||
}
|
||||
|
||||
/// List all status mappings
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/settings/status-mappings",
|
||||
tag = "settings",
|
||||
responses(
|
||||
(status = 200, body = Vec<StatusMappingDto>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_status_mappings(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mappings = rows
|
||||
.iter()
|
||||
.map(|row| StatusMappingDto {
|
||||
id: row.get::<Uuid, _>("id").to_string(),
|
||||
provider_status: row.get("provider_status"),
|
||||
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(mappings))
|
||||
}
|
||||
|
||||
/// Create or update a status mapping
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/settings/status-mappings",
|
||||
tag = "settings",
|
||||
request_body = UpsertStatusMappingRequest,
|
||||
responses(
|
||||
(status = 200, body = StatusMappingDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn upsert_status_mapping(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<UpsertStatusMappingRequest>,
|
||||
) -> Result<Json<StatusMappingDto>, ApiError> {
|
||||
let provider_status = body.provider_status.to_lowercase();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO status_mappings (provider_status, mapped_status)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (provider_status)
|
||||
DO UPDATE SET mapped_status = $2, updated_at = NOW()
|
||||
RETURNING id, provider_status, mapped_status
|
||||
"#,
|
||||
)
|
||||
.bind(&provider_status)
|
||||
.bind(&body.mapped_status)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(StatusMappingDto {
|
||||
id: row.get::<Uuid, _>("id").to_string(),
|
||||
provider_status: row.get("provider_status"),
|
||||
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/settings/status-mappings/{id}",
|
||||
tag = "settings",
|
||||
params(("id" = String, Path, description = "Mapping UUID")),
|
||||
responses(
|
||||
(status = 200, body = StatusMappingDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn delete_status_mapping(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<StatusMappingDto>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Json(StatusMappingDto {
|
||||
id: row.get::<Uuid, _>("id").to_string(),
|
||||
provider_status: row.get("provider_status"),
|
||||
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||
})),
|
||||
None => Err(ApiError::not_found("status mapping not found")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ use tokio::sync::{Mutex, RwLock, Semaphore};
|
||||
pub struct AppState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub bootstrap_token: Arc<str>,
|
||||
pub meili_url: Arc<str>,
|
||||
pub meili_master_key: Arc<str>,
|
||||
pub page_cache: Arc<Mutex<LruCache<String, Arc<Vec<u8>>>>>,
|
||||
pub page_render_limit: Arc<Semaphore>,
|
||||
pub metrics: Arc<Metrics>,
|
||||
|
||||
701
apps/api/src/stats.rs
Normal file
701
apps/api/src/stats.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct StatsQuery {
|
||||
/// Granularity: "day", "week" or "month" (default: "month")
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsOverview {
|
||||
pub total_books: i64,
|
||||
pub total_series: i64,
|
||||
pub total_libraries: i64,
|
||||
pub total_pages: i64,
|
||||
pub total_size_bytes: i64,
|
||||
pub total_authors: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingStatusStats {
|
||||
pub unread: i64,
|
||||
pub reading: i64,
|
||||
pub read: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FormatCount {
|
||||
pub format: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct LanguageCount {
|
||||
pub language: Option<String>,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct LibraryStats {
|
||||
pub library_name: String,
|
||||
pub book_count: i64,
|
||||
pub size_bytes: i64,
|
||||
pub read_count: i64,
|
||||
pub reading_count: i64,
|
||||
pub unread_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TopSeries {
|
||||
pub series: String,
|
||||
pub book_count: i64,
|
||||
pub read_count: i64,
|
||||
pub total_pages: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MonthlyAdditions {
|
||||
pub month: String,
|
||||
pub books_added: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MetadataStats {
|
||||
pub total_series: i64,
|
||||
pub series_linked: i64,
|
||||
pub series_unlinked: i64,
|
||||
pub books_with_summary: i64,
|
||||
pub books_with_isbn: i64,
|
||||
pub by_provider: Vec<ProviderCount>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ProviderCount {
|
||||
pub provider: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CurrentlyReadingItem {
|
||||
pub book_id: String,
|
||||
pub title: String,
|
||||
pub series: Option<String>,
|
||||
pub current_page: i32,
|
||||
pub page_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RecentlyReadItem {
|
||||
pub book_id: String,
|
||||
pub title: String,
|
||||
pub series: Option<String>,
|
||||
pub last_read_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MonthlyReading {
|
||||
pub month: String,
|
||||
pub books_read: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct JobTimePoint {
|
||||
pub label: String,
|
||||
pub scan: i64,
|
||||
pub rebuild: i64,
|
||||
pub thumbnail: i64,
|
||||
pub other: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsResponse {
|
||||
pub overview: StatsOverview,
|
||||
pub reading_status: ReadingStatusStats,
|
||||
pub currently_reading: Vec<CurrentlyReadingItem>,
|
||||
pub recently_read: Vec<RecentlyReadItem>,
|
||||
pub reading_over_time: Vec<MonthlyReading>,
|
||||
pub by_format: Vec<FormatCount>,
|
||||
pub by_language: Vec<LanguageCount>,
|
||||
pub by_library: Vec<LibraryStats>,
|
||||
pub top_series: Vec<TopSeries>,
|
||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||
pub jobs_over_time: Vec<JobTimePoint>,
|
||||
pub metadata: MetadataStats,
|
||||
}
|
||||
|
||||
/// Get collection statistics for the dashboard
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats",
|
||||
tag = "stats",
|
||||
params(StatsQuery),
|
||||
responses(
|
||||
(status = 200, body = StatsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
) -> Result<Json<StatsResponse>, ApiError> {
|
||||
let period = query.period.as_deref().unwrap_or("month");
|
||||
// Overview + reading status in one query
|
||||
let overview_row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) AS total_books,
|
||||
COUNT(DISTINCT NULLIF(series, '')) AS total_series,
|
||||
COUNT(DISTINCT library_id) AS total_libraries,
|
||||
COALESCE(SUM(page_count), 0)::BIGINT AS total_pages,
|
||||
(SELECT COUNT(DISTINCT a) FROM (
|
||||
SELECT DISTINCT UNNEST(authors) AS a FROM books WHERE authors != '{}'
|
||||
UNION
|
||||
SELECT DISTINCT author FROM books WHERE author IS NOT NULL AND author != ''
|
||||
) sub) AS total_authors,
|
||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Total size from book_files
|
||||
let size_row = sqlx::query(
|
||||
r#"
|
||||
SELECT COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS total_size_bytes
|
||||
FROM (
|
||||
SELECT DISTINCT ON (book_id) size_bytes
|
||||
FROM book_files
|
||||
ORDER BY book_id, updated_at DESC
|
||||
) bf
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let overview = StatsOverview {
|
||||
total_books: overview_row.get("total_books"),
|
||||
total_series: overview_row.get("total_series"),
|
||||
total_libraries: overview_row.get("total_libraries"),
|
||||
total_pages: overview_row.get("total_pages"),
|
||||
total_size_bytes: size_row.get("total_size_bytes"),
|
||||
total_authors: overview_row.get("total_authors"),
|
||||
};
|
||||
|
||||
let reading_status = ReadingStatusStats {
|
||||
unread: overview_row.get("unread"),
|
||||
reading: overview_row.get("reading"),
|
||||
read: overview_row.get("read"),
|
||||
};
|
||||
|
||||
// By format
|
||||
let format_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT COALESCE(bf.format, b.kind) AS fmt, COUNT(*) AS count
|
||||
FROM books b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT format FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||
) bf ON TRUE
|
||||
GROUP BY fmt
|
||||
ORDER BY count DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let by_format: Vec<FormatCount> = format_rows
|
||||
.iter()
|
||||
.map(|r| FormatCount {
|
||||
format: r.get::<Option<String>, _>("fmt").unwrap_or_else(|| "unknown".to_string()),
|
||||
count: r.get("count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// By language
|
||||
let lang_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT language, COUNT(*) AS count
|
||||
FROM books
|
||||
GROUP BY language
|
||||
ORDER BY count DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let by_language: Vec<LanguageCount> = lang_rows
|
||||
.iter()
|
||||
.map(|r| LanguageCount {
|
||||
language: r.get("language"),
|
||||
count: r.get("count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// By library
|
||||
let lib_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.name AS library_name,
|
||||
COUNT(b.id) AS book_count,
|
||||
COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS size_bytes,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading_count,
|
||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||
FROM libraries l
|
||||
LEFT JOIN books b ON b.library_id = l.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||
) bf ON TRUE
|
||||
GROUP BY l.id, l.name
|
||||
ORDER BY book_count DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let by_library: Vec<LibraryStats> = lib_rows
|
||||
.iter()
|
||||
.map(|r| LibraryStats {
|
||||
library_name: r.get("library_name"),
|
||||
book_count: r.get("book_count"),
|
||||
size_bytes: r.get("size_bytes"),
|
||||
read_count: r.get("read_count"),
|
||||
reading_count: r.get("reading_count"),
|
||||
unread_count: r.get("unread_count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Top series (by book count)
|
||||
let series_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
b.series,
|
||||
COUNT(*) AS book_count,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
WHERE b.series IS NOT NULL AND b.series != ''
|
||||
GROUP BY b.series
|
||||
ORDER BY book_count DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let top_series: Vec<TopSeries> = series_rows
|
||||
.iter()
|
||||
.map(|r| TopSeries {
|
||||
series: r.get("series"),
|
||||
book_count: r.get("book_count"),
|
||||
read_count: r.get("read_count"),
|
||||
total_pages: r.get("total_pages"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Additions over time (with gap filling)
|
||||
let additions_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY created_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||
.iter()
|
||||
.map(|r| MonthlyAdditions {
|
||||
month: r.get("month"),
|
||||
books_added: r.get("books_added"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Metadata stats
|
||||
let meta_row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT NULLIF(series, '')) FROM books) AS total_series,
|
||||
(SELECT COUNT(DISTINCT series_name) FROM external_metadata_links WHERE status = 'approved') AS series_linked,
|
||||
(SELECT COUNT(*) FROM books WHERE summary IS NOT NULL AND summary != '') AS books_with_summary,
|
||||
(SELECT COUNT(*) FROM books WHERE isbn IS NOT NULL AND isbn != '') AS books_with_isbn
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let meta_total_series: i64 = meta_row.get("total_series");
|
||||
let meta_series_linked: i64 = meta_row.get("series_linked");
|
||||
|
||||
let provider_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT provider, COUNT(DISTINCT series_name) AS count
|
||||
FROM external_metadata_links
|
||||
WHERE status = 'approved'
|
||||
GROUP BY provider
|
||||
ORDER BY count DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let by_provider: Vec<ProviderCount> = provider_rows
|
||||
.iter()
|
||||
.map(|r| ProviderCount {
|
||||
provider: r.get("provider"),
|
||||
count: r.get("count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let metadata = MetadataStats {
|
||||
total_series: meta_total_series,
|
||||
series_linked: meta_series_linked,
|
||||
series_unlinked: meta_total_series - meta_series_linked,
|
||||
books_with_summary: meta_row.get("books_with_summary"),
|
||||
books_with_isbn: meta_row.get("books_with_isbn"),
|
||||
by_provider,
|
||||
};
|
||||
|
||||
// Currently reading books
|
||||
let reading_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
||||
ORDER BY brp.updated_at DESC
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let currently_reading: Vec<CurrentlyReadingItem> = reading_rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let id: uuid::Uuid = r.get("book_id");
|
||||
CurrentlyReadingItem {
|
||||
book_id: id.to_string(),
|
||||
title: r.get("title"),
|
||||
series: r.get("series"),
|
||||
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
||||
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Recently read books
|
||||
let recent_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series,
|
||||
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
||||
ORDER BY brp.last_read_at DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let recently_read: Vec<RecentlyReadItem> = recent_rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let id: uuid::Uuid = r.get("book_id");
|
||||
RecentlyReadItem {
|
||||
book_id: id.to_string(),
|
||||
title: r.get("title"),
|
||||
series: r.get("series"),
|
||||
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reading activity over time (with gap filling)
|
||||
let reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY brp.last_read_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||
.iter()
|
||||
.map(|r| MonthlyReading {
|
||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||
books_read: r.get("books_read"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Jobs over time (with gap filling, grouped by type category)
|
||||
let jobs_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
finished_at::date AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY finished_at::date, cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('week', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('month', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
|
||||
.iter()
|
||||
.map(|r| JobTimePoint {
|
||||
label: r.get("label"),
|
||||
scan: r.get("scan"),
|
||||
rebuild: r.get("rebuild"),
|
||||
thumbnail: r.get("thumbnail"),
|
||||
other: r.get("other"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(StatsResponse {
|
||||
overview,
|
||||
reading_status,
|
||||
currently_reading,
|
||||
recently_read,
|
||||
reading_over_time,
|
||||
by_format,
|
||||
by_language,
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time,
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
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}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,9 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer le content-type et les données
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
|
||||
// Retourner l'image avec le bon content-type
|
||||
return new NextResponse(imageBuffer, {
|
||||
return new NextResponse(response.body, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=300",
|
||||
|
||||
17
apps/backoffice/app/api/books/[bookId]/route.ts
Normal file
17
apps/backoffice/app/api/books/[bookId]/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updateBook } from "@/lib/api";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bookId: string }> }
|
||||
) {
|
||||
const { bookId } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await updateBook(bookId, body);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update book";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,25 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const { baseUrl, token } = config();
|
||||
const ifNoneMatch = request.headers.get("if-none-match");
|
||||
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (ifNoneMatch) {
|
||||
fetchHeaders["If-None-Match"] = ifNoneMatch;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: fetchHeaders,
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
|
||||
// Forward 304 Not Modified as-is
|
||||
if (response.status === 304) {
|
||||
return new NextResponse(null, { status: 304 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||
status: response.status
|
||||
@@ -20,14 +35,17 @@ export async function GET(
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
const etag = response.headers.get("etag");
|
||||
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
};
|
||||
if (etag) {
|
||||
headers["ETag"] = etag;
|
||||
}
|
||||
|
||||
return new NextResponse(response.body, { headers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching thumbnail:", error);
|
||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function GET(request: NextRequest) {
|
||||
let lastData: string | null = null;
|
||||
let isActive = true;
|
||||
let consecutiveErrors = 0;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const fetchJobs = async () => {
|
||||
if (!isActive) return;
|
||||
@@ -25,23 +26,28 @@ export async function GET(request: NextRequest) {
|
||||
const data = await response.json();
|
||||
const dataStr = JSON.stringify(data);
|
||||
|
||||
// Send if data changed
|
||||
// Send only if data changed
|
||||
if (dataStr !== lastData && isActive) {
|
||||
lastData = dataStr;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||
);
|
||||
} catch (err) {
|
||||
// Controller closed, ignore
|
||||
} catch {
|
||||
isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Adapt interval: 2s when active jobs exist, 15s when idle
|
||||
const hasActiveJobs = data.some((j: { status: string }) =>
|
||||
j.status === "running" || j.status === "pending" || j.status === "extracting_pages" || j.status === "generating_thumbnails"
|
||||
);
|
||||
const nextInterval = hasActiveJobs ? 2000 : 15000;
|
||||
restartInterval(nextInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
consecutiveErrors++;
|
||||
// Only log first failure and every 30th to avoid spam
|
||||
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
||||
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
||||
}
|
||||
@@ -49,22 +55,18 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
await fetchJobs();
|
||||
const restartInterval = (ms: number) => {
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
intervalId = setInterval(fetchJobs, ms);
|
||||
};
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
if (!isActive) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
// Initial fetch + start polling
|
||||
await fetchJobs();
|
||||
}, 2000);
|
||||
|
||||
// Cleanup
|
||||
request.signal.addEventListener("abort", () => {
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
|
||||
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { getKomgaReport } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await getKomgaReport(id);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listKomgaReports } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await listKomgaReports();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch reports";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/komga/sync/route.ts
Normal file
16
apps/backoffice/app/api/komga/sync/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch("/komga/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to sync with Komga";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, LibraryDto } from "@/lib/api";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<LibraryDto>(`/libraries/${id}/metadata-provider`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update metadata provider";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ export async function PATCH(
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { monitor_enabled, scan_mode, watcher_enabled } = await request.json();
|
||||
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled);
|
||||
const { monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode } = await request.json();
|
||||
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update monitoring settings";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchSeriesMetadata } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; name: string }> }
|
||||
) {
|
||||
const { id, name } = await params;
|
||||
try {
|
||||
const data = await fetchSeriesMetadata(id, name);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch series metadata";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updateSeries } from "@/lib/api";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; name: string }> }
|
||||
) {
|
||||
const { id, name } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await updateSeries(id, name, body);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update series";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
apps/backoffice/app/api/metadata/approve/route.ts
Normal file
17
apps/backoffice/app/api/metadata/approve/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, ...rest } = body;
|
||||
const data = await apiFetch<{ status: string; books_synced: number }>(`/metadata/approve/${id}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(rest),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to approve metadata";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, MetadataBatchReportDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const data = await apiFetch<MetadataBatchReportDto>(`/metadata/batch/${id}/report`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, MetadataBatchResultDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const status = searchParams.get("status") || "";
|
||||
const params = status ? `?status=${status}` : "";
|
||||
const data = await apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${id}/results${params}`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch results";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<{ id: string; status: string }>("/metadata/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to start batch";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
apps/backoffice/app/api/metadata/links/route.ts
Normal file
35
apps/backoffice/app/api/metadata/links/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const libraryId = searchParams.get("library_id") || "";
|
||||
const seriesName = searchParams.get("series_name") || "";
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
if (seriesName) params.set("series_name", seriesName);
|
||||
const data = await apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch metadata links";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const data = await apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete metadata link";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/match/route.ts
Normal file
16
apps/backoffice/app/api/metadata/match/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<ExternalMetadataLinkDto>("/metadata/match", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create metadata match";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
apps/backoffice/app/api/metadata/missing/route.ts
Normal file
17
apps/backoffice/app/api/metadata/missing/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, MissingBooksDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const data = await apiFetch<MissingBooksDto>(`/metadata/missing/${id}`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch missing books";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/refresh/report/route.ts
Normal file
16
apps/backoffice/app/api/metadata/refresh/report/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const jobId = request.nextUrl.searchParams.get("job_id");
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "job_id required" }, { status: 400 });
|
||||
}
|
||||
const data = await apiFetch(`/metadata/refresh/${jobId}/report`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/refresh/route.ts
Normal file
16
apps/backoffice/app/api/metadata/refresh/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<{ id: string; status: string }>("/metadata/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to start refresh";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
15
apps/backoffice/app/api/metadata/reject/route.ts
Normal file
15
apps/backoffice/app/api/metadata/reject/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<{ status: string }>(`/metadata/reject/${body.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reject metadata";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/search/route.ts
Normal file
16
apps/backoffice/app/api/metadata/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, SeriesCandidateDto } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<SeriesCandidateDto[]>("/metadata/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to search metadata";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch("/prowlarr/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to search Prowlarr";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/prowlarr/test/route.ts
Normal file
12
apps/backoffice/app/api/prowlarr/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("/prowlarr/test");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to test Prowlarr connection";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch("/qbittorrent/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to add torrent";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/qbittorrent/test/route.ts
Normal file
12
apps/backoffice/app/api/qbittorrent/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("/qbittorrent/test");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to test qBittorrent";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch<string[]>("/series/provider-statuses");
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json([], { status: 200 });
|
||||
}
|
||||
}
|
||||
11
apps/backoffice/app/api/series/statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/statuses/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch<string[]>("/series/statuses");
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json([], { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const data = await apiFetch<unknown>(`/settings/status-mappings/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to delete status mapping" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal file
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch<unknown>("/settings/status-mappings");
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to fetch status mappings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<unknown>("/settings/status-mappings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to save status mapping" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
135
apps/backoffice/app/authors/[name]/page.tsx
Normal file
135
apps/backoffice/app/authors/[name]/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import { BooksGrid } from "../../components/BookCard";
|
||||
import { OffsetPagination } from "../../components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuthorDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ name: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const { name: encodedName } = await params;
|
||||
const authorName = decodeURIComponent(encodedName);
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
// Fetch books by this author (server-side filtering via API) and series by this author
|
||||
const [booksPage, seriesPage] = await Promise.all([
|
||||
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
||||
),
|
||||
fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch(
|
||||
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||
),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
|
||||
const authorSeries = seriesPage.items;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/authors" className="hover:text-foreground transition-colors">
|
||||
{t("authors.title")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium">{authorName}</span>
|
||||
</nav>
|
||||
|
||||
{/* Author Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-16 h-16 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl font-bold text-accent-foreground">
|
||||
{authorName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{authorName}</h1>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("authors.bookCount", { count: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||
</span>
|
||||
{authorSeries.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("authors.seriesCount", { count: String(authorSeries.length), plural: authorSeries.length !== 1 ? "s" : "" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Series Section */}
|
||||
{authorSeries.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("authors.seriesBy", { name: authorName })}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{authorSeries.map((s) => (
|
||||
<Link
|
||||
key={`${s.library_id}-${s.name}`}
|
||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200">
|
||||
<div className="aspect-[2/3] relative bg-muted/50">
|
||||
<Image
|
||||
src={getBookCoverUrl(s.first_book_id)}
|
||||
alt={s.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||
{s.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("authors.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Books Section */}
|
||||
{booksPage.items.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("authors.booksBy", { name: authorName })}
|
||||
</h2>
|
||||
<BooksGrid books={booksPage.items} />
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
totalItems={booksPage.total}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{booksPage.items.length === 0 && authorSeries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("authors.noResults")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
apps/backoffice/app/authors/page.tsx
Normal file
122
apps/backoffice/app/authors/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuthorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
const authorsPage = await fetchAuthors(
|
||||
searchQuery || undefined,
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
).catch(() => ({ items: [], total: 0, page: 1, limit }) as AuthorsPageDto);
|
||||
|
||||
const totalPages = Math.ceil(authorsPage.total / limit);
|
||||
const hasFilters = searchQuery || sort;
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: t("authors.sortName") },
|
||||
{ value: "books", label: t("authors.sortBooks") },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||
</svg>
|
||||
{t("authors.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<LiveSearchForm
|
||||
basePath="/authors"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("authors.searchPlaceholder") },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{authorsPage.total} {t("authors.title").toLowerCase()}
|
||||
{searchQuery && <> {t("authors.matchingQuery")} "{searchQuery}"</>}
|
||||
</p>
|
||||
|
||||
{/* Authors List */}
|
||||
{authorsPage.items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{authorsPage.items.map((author) => (
|
||||
<Link
|
||||
key={author.name}
|
||||
href={`/authors/${encodeURIComponent(author.name)}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-semibold text-violet-500">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-foreground truncate text-sm group-hover:text-violet-500 transition-colors" title={author.name}>
|
||||
{author.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("authors.bookCount", { count: String(author.book_count), plural: author.book_count !== 1 ? "s" : "" })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("authors.seriesCount", { count: String(author.series_count), plural: author.series_count !== 1 ? "s" : "" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
totalItems={authorsPage.total}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{hasFilters ? t("authors.noResults") : t("authors.noAuthors")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,43 +2,25 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro
|
||||
import { BookPreview } from "../../components/BookPreview";
|
||||
import { ConvertButton } from "../../components/ConvertButton";
|
||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { SafeHtml } from "../../components/SafeHtml";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditBookForm = nextDynamic(
|
||||
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
|
||||
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
|
||||
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
|
||||
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
|
||||
const readingStatusClassNames: Record<ReadingStatus, string> = {
|
||||
unread: "bg-muted/60 text-muted-foreground border border-border",
|
||||
reading: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30",
|
||||
read: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30",
|
||||
};
|
||||
|
||||
function ReadingStatusBadge({
|
||||
status,
|
||||
currentPage,
|
||||
lastReadAt,
|
||||
}: {
|
||||
status: ReadingStatus;
|
||||
currentPage: number | null;
|
||||
lastReadAt: string | null;
|
||||
}) {
|
||||
const { label, className } = readingStatusConfig[status];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${className}`}>
|
||||
{label}
|
||||
{status === "reading" && currentPage != null && ` · p. ${currentPage}`}
|
||||
</span>
|
||||
{lastReadAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(lastReadAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
||||
try {
|
||||
return await apiFetch<BookDto>(`/books/${bookId}`);
|
||||
@@ -62,158 +44,199 @@ export default async function BookDetailPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
const library = libraries.find(l => l.id === book.library_id);
|
||||
const formatBadge = (book.format ?? book.kind).toUpperCase();
|
||||
const formatColor =
|
||||
formatBadge === "CBZ" ? "bg-success/10 text-success border-success/30" :
|
||||
formatBadge === "CBR" ? "bg-warning/10 text-warning border-warning/30" :
|
||||
formatBadge === "PDF" ? "bg-destructive/10 text-destructive border-destructive/30" :
|
||||
"bg-muted/50 text-muted-foreground border-border";
|
||||
const statusLabel = t(`status.${book.reading_status}` as "status.unread" | "status.reading" | "status.read");
|
||||
const statusClassName = readingStatusClassNames[book.reading_status];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
← Back to books
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
{t("bookDetail.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{library && (
|
||||
<>
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series`}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{library.name}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
{book.series && (
|
||||
<>
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{book.series}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-foreground font-medium truncate">{book.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
{/* Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
|
||||
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
width={300}
|
||||
height={440}
|
||||
className="w-auto h-auto max-w-[300px] rounded-lg"
|
||||
unoptimized
|
||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="192px"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
||||
{book.author && (
|
||||
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
||||
<p className="text-base text-muted-foreground mt-1">{book.author}</p>
|
||||
)}
|
||||
</div>
|
||||
<EditBookForm book={book} />
|
||||
</div>
|
||||
|
||||
{/* Series + Volume link */}
|
||||
{book.series && (
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors font-medium"
|
||||
>
|
||||
{book.series}
|
||||
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{book.reading_status && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Lecture :</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<ReadingStatusBadge
|
||||
status={book.reading_status}
|
||||
currentPage={book.reading_current_page ?? null}
|
||||
lastReadAt={book.reading_last_read_at ?? null}
|
||||
/>
|
||||
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Format:</span>
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
book.kind === 'epub' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||
}`}>
|
||||
{book.kind.toUpperCase()}
|
||||
</Link>
|
||||
{book.volume != null && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-semibold">
|
||||
Vol. {book.volume}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{book.volume && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Volume:</span>
|
||||
<span className="text-sm text-foreground">{book.volume}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.language && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Language:</span>
|
||||
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
||||
</div>
|
||||
{/* Reading status + actions */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${statusClassName}`}>
|
||||
{statusLabel}
|
||||
{book.reading_status === "reading" && book.reading_current_page != null && ` · p. ${book.reading_current_page}`}
|
||||
</span>
|
||||
{book.reading_last_read_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(book.reading_last_read_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{book.page_count && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Pages:</span>
|
||||
<span className="text-sm text-foreground">{book.page_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Library:</span>
|
||||
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
||||
</div>
|
||||
|
||||
{book.series && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Series:</span>
|
||||
<span className="text-sm text-foreground">{book.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_format && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">File Format:</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
||||
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
|
||||
</div>
|
||||
|
||||
{/* Metadata pills */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold border ${formatColor}`}>
|
||||
{formatBadge}
|
||||
</span>
|
||||
{book.page_count && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.page_count} {t("dashboard.pages").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{book.language && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.language.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{book.isbn && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-mono font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
ISBN {book.isbn}
|
||||
</span>
|
||||
)}
|
||||
{book.publish_date && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.publish_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{book.summary && (
|
||||
<SafeHtml html={book.summary} className="text-sm text-muted-foreground leading-relaxed" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical info (collapsible) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors select-none flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{t("bookDetail.technicalInfo")}
|
||||
</summary>
|
||||
<div className="mt-3 p-4 rounded-lg bg-muted/30 border border-border/50 space-y-2 text-xs">
|
||||
{book.file_path && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground">{t("bookDetail.file")}</span>
|
||||
<code className="font-mono text-foreground break-all">{book.file_path}</code>
|
||||
</div>
|
||||
)}
|
||||
{book.file_format && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.fileFormat")}</span>
|
||||
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_parse_status && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Parse Status:</span>
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
|
||||
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.parsing")}</span>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
book.file_parse_status === "success" ? "bg-success/10 text-success" :
|
||||
book.file_parse_status === "failed" ? "bg-destructive/10 text-destructive" :
|
||||
"bg-muted/50 text-muted-foreground"
|
||||
}`}>
|
||||
{book.file_parse_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_path && (
|
||||
<div className="flex flex-col py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
|
||||
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Book ID</span>
|
||||
<code className="font-mono text-foreground">{book.id}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
|
||||
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Library ID</span>
|
||||
<code className="font-mono text-foreground">{book.library_id}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
|
||||
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
||||
</div>
|
||||
|
||||
{book.updated_at && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">Updated:</span>
|
||||
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.updatedAt")}</span>
|
||||
<span className="text-foreground">{new Date(book.updated_at).toLocaleString(locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Book Preview */}
|
||||
{book.page_count && book.page_count > 0 && (
|
||||
<div className="mt-8">
|
||||
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,10 +13,14 @@ export default async function BooksPage({
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
||||
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
@@ -38,11 +43,13 @@ export default async function BooksPage({
|
||||
library_id: hit.library_id,
|
||||
kind: hit.kind,
|
||||
title: hit.title,
|
||||
author: hit.author,
|
||||
author: hit.authors?.[0] ?? null,
|
||||
authors: hit.authors ?? [],
|
||||
series: hit.series,
|
||||
volume: hit.volume,
|
||||
language: hit.language,
|
||||
page_count: null,
|
||||
format: null,
|
||||
file_path: null,
|
||||
file_format: null,
|
||||
file_parse_status: null,
|
||||
@@ -50,11 +57,14 @@ export default async function BooksPage({
|
||||
reading_status: "unread" as const,
|
||||
reading_current_page: null,
|
||||
reading_last_read_at: null,
|
||||
summary: null,
|
||||
isbn: null,
|
||||
publish_date: null,
|
||||
}));
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus).catch(() => ({
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
@@ -72,18 +82,37 @@ export default async function BooksPage({
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const libraryOptions = [
|
||||
{ value: "", label: "All libraries" },
|
||||
{ value: "", label: t("books.allLibraries") },
|
||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "", label: "All" },
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "reading", label: "In progress" },
|
||||
{ value: "read", label: "Read" },
|
||||
{ value: "", label: t("common.all") },
|
||||
{ value: "unread", label: t("status.unread") },
|
||||
{ value: "reading", label: t("status.reading") },
|
||||
{ value: "read", label: t("status.read") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus;
|
||||
const formatOptions = [
|
||||
{ value: "", label: t("books.allFormats") },
|
||||
{ value: "cbz", label: "CBZ" },
|
||||
{ value: "cbr", label: "CBR" },
|
||||
{ value: "pdf", label: "PDF" },
|
||||
{ value: "epub", label: "EPUB" },
|
||||
];
|
||||
|
||||
const metadataOptions = [
|
||||
{ value: "", label: t("series.metadataAll") },
|
||||
{ value: "linked", label: t("series.metadataLinked") },
|
||||
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: t("books.sortTitle") },
|
||||
{ value: "latest", label: t("books.sortLatest") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -92,7 +121,7 @@ export default async function BooksPage({
|
||||
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Books
|
||||
{t("books.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -101,9 +130,12 @@ export default async function BooksPage({
|
||||
<LiveSearchForm
|
||||
basePath="/books"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
|
||||
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -112,18 +144,18 @@ export default async function BooksPage({
|
||||
{/* Résultats */}
|
||||
{searchQuery && totalHits !== null ? (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
|
||||
</p>
|
||||
) : !searchQuery && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{total} book{total !== 1 ? 's' : ''}
|
||||
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Séries matchantes */}
|
||||
{seriesHits.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">Series</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t("books.seriesHeading")}</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{seriesHits.map((s) => (
|
||||
<Link
|
||||
@@ -135,18 +167,18 @@ export default async function BooksPage({
|
||||
<div className="aspect-[2/3] relative bg-muted/50">
|
||||
<Image
|
||||
src={getBookCoverUrl(s.first_book_id)}
|
||||
alt={`Cover of ${s.name}`}
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
||||
{t("books.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +191,7 @@ export default async function BooksPage({
|
||||
{/* Grille de livres */}
|
||||
{displayBooks.length > 0 ? (
|
||||
<>
|
||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Books</h2>}
|
||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{!searchQuery && (
|
||||
@@ -172,7 +204,7 @@ export default async function BooksPage({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={searchQuery ? `No books found for "${searchQuery}"` : "No books available"} />
|
||||
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
|
||||
const readingStatusOverlayClasses: Record<ReadingStatus, string | null> = {
|
||||
unread: null,
|
||||
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
|
||||
read: { label: "Lu", className: "bg-green-600/90 text-white" },
|
||||
reading: "bg-amber-500/90 text-white",
|
||||
read: "bg-green-600/90 text-white",
|
||||
};
|
||||
|
||||
interface BookCardProps {
|
||||
@@ -16,7 +17,7 @@ interface BookCardProps {
|
||||
readingStatus?: ReadingStatus;
|
||||
}
|
||||
|
||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const BookImage = memo(function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
@@ -50,16 +51,21 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => setHasError(true)}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||
const status = readingStatus ?? book.reading_status;
|
||||
const overlay = status ? readingStatusOverlay[status] : null;
|
||||
const overlayClass = status ? readingStatusOverlayClasses[status] : null;
|
||||
const statusLabels: Record<ReadingStatus, string> = {
|
||||
unread: t("status.unread"),
|
||||
reading: t("status.reading"),
|
||||
read: t("status.read"),
|
||||
};
|
||||
|
||||
const isRead = status === "read";
|
||||
|
||||
@@ -71,11 +77,11 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
<div className="relative">
|
||||
<BookImage
|
||||
src={coverUrl}
|
||||
alt={`Cover of ${book.title}`}
|
||||
alt={t("books.coverOf", { name: book.title })}
|
||||
/>
|
||||
{overlay && (
|
||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
|
||||
{overlay.label}
|
||||
{overlayClass && status && (
|
||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlayClass}`}>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,14 +108,17 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
|
||||
{/* Meta Tags */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{(book.format ?? book.kind) && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
||||
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||
${(book.format ?? book.kind) === 'epub' ? 'bg-info/10 text-info' : ''}
|
||||
`}>
|
||||
{book.kind}
|
||||
{book.format ?? book.kind}
|
||||
</span>
|
||||
)}
|
||||
{book.language && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
|
||||
{book.language}
|
||||
@@ -119,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface BooksGridProps {
|
||||
books: (BookDto & { coverUrl?: string })[];
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const pages = Array.from({ length: PAGE_SIZE }, (_, i) => offset + i + 1).filter(
|
||||
@@ -16,9 +18,9 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
||||
<div className="bg-card rounded-xl border border-border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Preview
|
||||
{t("bookPreview.preview")}
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
pages {offset + 1}–{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
|
||||
{t("bookPreview.pages", { start: offset + 1, end: Math.min(offset + PAGE_SIZE, pageCount), total: pageCount })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -27,14 +29,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
{t("bookPreview.prev")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
||||
disabled={offset + PAGE_SIZE >= pageCount}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
{t("bookPreview.next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface ConvertButtonProps {
|
||||
bookId: string;
|
||||
@@ -15,6 +16,7 @@ type ConvertState =
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ConvertState>({ type: "idle" });
|
||||
|
||||
const handleConvert = async () => {
|
||||
@@ -23,22 +25,22 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
setState({ type: "error", message: body.error || "Conversion failed" });
|
||||
setState({ type: "error", message: body.error || t("convert.failed") });
|
||||
return;
|
||||
}
|
||||
const job = await res.json();
|
||||
setState({ type: "success", jobId: job.id });
|
||||
} catch (err) {
|
||||
setState({ type: "error", message: err instanceof Error ? err.message : "Unknown error" });
|
||||
setState({ type: "error", message: err instanceof Error ? err.message : t("convert.unknownError") });
|
||||
}
|
||||
};
|
||||
|
||||
if (state.type === "success") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<span>Conversion started.</span>
|
||||
<span>{t("convert.started")}</span>
|
||||
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
|
||||
View job →
|
||||
{t("convert.viewJob")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -52,7 +54,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
className="text-xs text-muted-foreground hover:underline text-left"
|
||||
onClick={() => setState({ type: "idle" })}
|
||||
>
|
||||
Dismiss
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -65,7 +67,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
onClick={handleConvert}
|
||||
disabled={state.type === "loading"}
|
||||
>
|
||||
{state.type === "loading" ? "Converting…" : "Convert to CBZ"}
|
||||
{state.type === "loading" ? t("convert.converting") : t("convert.convertToCbz")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area, Line, LineChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Donut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcDonutChart({
|
||||
data,
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { name: string; value: number; color: string }[];
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={130} height={130}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={32}
|
||||
outerRadius={55}
|
||||
dataKey="value"
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data.map((d, i) => (
|
||||
<Cell key={i} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => value}
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bar chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcBarChart({
|
||||
data,
|
||||
color = "hsl(198 78% 37%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Area / Line chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcAreaChart({
|
||||
data,
|
||||
color = "hsl(142 60% 45%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill="url(#areaGradient)" dot={{ r: 3, fill: color }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Horizontal stacked bar (libraries breakdown)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcStackedBar({
|
||||
data,
|
||||
labels,
|
||||
}: {
|
||||
data: { name: string; read: number; reading: number; unread: number; sizeLabel: string }[];
|
||||
labels: { read: string; reading: string; unread: string; books: string };
|
||||
}) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={data.length * 60 + 30}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value: string) => <span className="text-muted-foreground">{value}</span>}
|
||||
/>
|
||||
<Bar dataKey="read" stackId="a" fill="hsl(142 60% 45%)" name={labels.read} radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="reading" stackId="a" fill="hsl(45 93% 47%)" name={labels.reading} />
|
||||
<Bar dataKey="unread" stackId="a" fill="hsl(220 13% 70%)" name={labels.unread} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Horizontal bar chart (top series)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcHorizontalBar({
|
||||
data,
|
||||
color = "hsl(142 60% 45%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { name: string; value: number; subLabel: string }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-4">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={data.length * 40 + 10}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-line chart (jobs over time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcMultiLineChart({
|
||||
data,
|
||||
lines,
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: Record<string, unknown>[];
|
||||
lines: { key: string; label: string; color: string }[];
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
|
||||
if (data.length === 0 || !hasData)
|
||||
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{lines.map((l) => (
|
||||
<Line
|
||||
key={l.key}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
name={l.label}
|
||||
stroke={l.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: l.color }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
381
apps/backoffice/app/components/EditBookForm.tsx
Normal file
381
apps/backoffice/app/components/EditBookForm.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookDto } from "@/lib/api";
|
||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
function LockButton({
|
||||
locked,
|
||||
onToggle,
|
||||
disabled,
|
||||
}: {
|
||||
locked: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
locked
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||
}`}
|
||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||
>
|
||||
{locked ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditBookFormProps {
|
||||
book: BookDto;
|
||||
}
|
||||
|
||||
export function EditBookForm({ book }: EditBookFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState(book.title);
|
||||
const [authors, setAuthors] = useState<string[]>(book.authors ?? []);
|
||||
const [authorInput, setAuthorInput] = useState("");
|
||||
const [authorInputEl, setAuthorInputEl] = useState<HTMLInputElement | null>(null);
|
||||
const [series, setSeries] = useState(book.series ?? "");
|
||||
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
|
||||
const [language, setLanguage] = useState(book.language ?? "");
|
||||
const [summary, setSummary] = useState(book.summary ?? "");
|
||||
const [isbn, setIsbn] = useState(book.isbn ?? "");
|
||||
const [publishDate, setPublishDate] = useState(book.publish_date ?? "");
|
||||
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(book.locked_fields ?? {});
|
||||
|
||||
const toggleLock = (field: string) => {
|
||||
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||
};
|
||||
|
||||
const addAuthor = () => {
|
||||
const v = authorInput.trim();
|
||||
if (v && !authors.includes(v)) {
|
||||
setAuthors([...authors, v]);
|
||||
}
|
||||
setAuthorInput("");
|
||||
authorInputEl?.focus();
|
||||
};
|
||||
|
||||
const removeAuthor = (idx: number) => {
|
||||
setAuthors(authors.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleAuthorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addAuthor();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setTitle(book.title);
|
||||
setAuthors(book.authors ?? []);
|
||||
setAuthorInput("");
|
||||
setSeries(book.series ?? "");
|
||||
setVolume(book.volume?.toString() ?? "");
|
||||
setLanguage(book.language ?? "");
|
||||
setSummary(book.summary ?? "");
|
||||
setIsbn(book.isbn ?? "");
|
||||
setPublishDate(book.publish_date ?? "");
|
||||
setLockedFields(book.locked_fields ?? {});
|
||||
setError(null);
|
||||
setIsOpen(false);
|
||||
}, [book]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isPending) handleClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, isPending, handleClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setError(null);
|
||||
|
||||
const finalAuthors = authorInput.trim()
|
||||
? [...new Set([...authors, authorInput.trim()])]
|
||||
: authors;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/books/${book.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
author: finalAuthors[0] ?? null,
|
||||
authors: finalAuthors,
|
||||
series: series.trim() || null,
|
||||
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
|
||||
language: language.trim() || null,
|
||||
summary: summary.trim() || null,
|
||||
isbn: isbn.trim() || null,
|
||||
publish_date: publishDate.trim() || null,
|
||||
locked_fields: lockedFields,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? t("editBook.saveError"));
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const modal = isOpen ? createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={() => !isPending && handleClose()}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">{t("editBook.editMetadata")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel required>{t("editBook.title")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.titlePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.authors")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => setAuthorInput(e.target.value)}
|
||||
onKeyDown={handleAuthorKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.addAuthor")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.language")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.languagePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.series")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={series}
|
||||
onChange={(e) => setSeries(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.seriesPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.volume")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.volumePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.isbn")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={isbn}
|
||||
onChange={(e) => setIsbn(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ISBN"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.publishDate")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={publishDate}
|
||||
onChange={(e) => setPublishDate(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.publishDatePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
|
||||
</div>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.descriptionPlaceholder")}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Lock legend */}
|
||||
{Object.values(lockedFields).some(Boolean) && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !title.trim()}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? t("editBook.savingLabel") : t("editBook.saveLabel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<span>✏️</span> {t("editBook.editMetadata")}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
517
apps/backoffice/app/components/EditSeriesForm.tsx
Normal file
517
apps/backoffice/app/components/EditSeriesForm.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
function LockButton({
|
||||
locked,
|
||||
onToggle,
|
||||
disabled,
|
||||
}: {
|
||||
locked: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
locked
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||
}`}
|
||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||
>
|
||||
{locked ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const SERIES_STATUS_VALUES = ["", "ongoing", "ended", "hiatus", "cancelled", "upcoming"] as const;
|
||||
|
||||
interface EditSeriesFormProps {
|
||||
libraryId: string;
|
||||
seriesName: string;
|
||||
currentAuthors: string[];
|
||||
currentPublishers: string[];
|
||||
currentBookAuthor: string | null;
|
||||
currentBookLanguage: string | null;
|
||||
currentDescription: string | null;
|
||||
currentStartYear: number | null;
|
||||
currentTotalVolumes: number | null;
|
||||
currentStatus: string | null;
|
||||
currentLockedFields: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function EditSeriesForm({
|
||||
libraryId,
|
||||
seriesName,
|
||||
currentAuthors,
|
||||
currentPublishers,
|
||||
currentBookAuthor,
|
||||
currentBookLanguage,
|
||||
currentDescription,
|
||||
currentStartYear,
|
||||
currentTotalVolumes,
|
||||
currentStatus,
|
||||
currentLockedFields,
|
||||
}: EditSeriesFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Champs propres à la série
|
||||
const [newName, setNewName] = useState(seriesName === "unclassified" ? "" : seriesName);
|
||||
const [authors, setAuthors] = useState<string[]>(currentAuthors);
|
||||
const [authorInput, setAuthorInput] = useState("");
|
||||
const [authorInputEl, setAuthorInputEl] = useState<HTMLInputElement | null>(null);
|
||||
const [publishers, setPublishers] = useState<string[]>(currentPublishers);
|
||||
const [publisherInput, setPublisherInput] = useState("");
|
||||
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
||||
const [description, setDescription] = useState(currentDescription ?? "");
|
||||
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
||||
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
|
||||
const [status, setStatus] = useState(currentStatus ?? "");
|
||||
|
||||
// Lock states
|
||||
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
|
||||
|
||||
// Propagation aux livres — opt-in via bouton
|
||||
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
||||
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
||||
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
||||
|
||||
const toggleLock = (field: string) => {
|
||||
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||
};
|
||||
|
||||
const addAuthor = () => {
|
||||
const v = authorInput.trim();
|
||||
if (v && !authors.includes(v)) {
|
||||
setAuthors([...authors, v]);
|
||||
}
|
||||
setAuthorInput("");
|
||||
authorInputEl?.focus();
|
||||
};
|
||||
|
||||
const removeAuthor = (idx: number) => {
|
||||
setAuthors(authors.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleAuthorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addAuthor();
|
||||
}
|
||||
};
|
||||
|
||||
const addPublisher = () => {
|
||||
const v = publisherInput.trim();
|
||||
if (v && !publishers.includes(v)) {
|
||||
setPublishers([...publishers, v]);
|
||||
}
|
||||
setPublisherInput("");
|
||||
publisherInputEl?.focus();
|
||||
};
|
||||
|
||||
const removePublisher = (idx: number) => {
|
||||
setPublishers(publishers.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handlePublisherKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addPublisher();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setNewName(seriesName === "unclassified" ? "" : seriesName);
|
||||
setAuthors(currentAuthors);
|
||||
setAuthorInput("");
|
||||
setPublishers(currentPublishers);
|
||||
setPublisherInput("");
|
||||
setDescription(currentDescription ?? "");
|
||||
setStartYear(currentStartYear?.toString() ?? "");
|
||||
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
|
||||
setStatus(currentStatus ?? "");
|
||||
setLockedFields(currentLockedFields);
|
||||
setShowApplyToBooks(false);
|
||||
setBookAuthor(currentBookAuthor ?? "");
|
||||
setBookLanguage(currentBookLanguage ?? "");
|
||||
setError(null);
|
||||
setIsOpen(false);
|
||||
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isPending) handleClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, isPending, handleClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() && seriesName !== "unclassified") return;
|
||||
setError(null);
|
||||
|
||||
const finalAuthors = authorInput.trim()
|
||||
? [...new Set([...authors, authorInput.trim()])]
|
||||
: authors;
|
||||
|
||||
const finalPublishers = publisherInput.trim()
|
||||
? [...new Set([...publishers, publisherInput.trim()])]
|
||||
: publishers;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const effectiveName = newName.trim() || "unclassified";
|
||||
const body: Record<string, unknown> = {
|
||||
new_name: effectiveName,
|
||||
authors: finalAuthors,
|
||||
publishers: finalPublishers,
|
||||
description: description.trim() || null,
|
||||
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
||||
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
|
||||
status: status || null,
|
||||
locked_fields: lockedFields,
|
||||
};
|
||||
if (showApplyToBooks) {
|
||||
body.author = bookAuthor.trim() || null;
|
||||
body.language = bookLanguage.trim() || null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? t("editBook.saveError"));
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
|
||||
if (effectiveName !== seriesName) {
|
||||
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const modal = isOpen ? createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={() => !isPending && handleClose()}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">{t("editSeries.title")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField>
|
||||
<FormLabel required>{t("editSeries.name")}</FormLabel>
|
||||
<FormInput
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editSeries.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editSeries.startYear")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={startYear}
|
||||
onChange={(e) => setStartYear(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editSeries.startYearPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editSeries.totalVolumes")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={totalVolumes}
|
||||
onChange={(e) => setTotalVolumes(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="12"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editSeries.status")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
{SERIES_STATUS_VALUES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v === "" ? t("seriesStatus.notDefined") : t(`seriesStatus.${v}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editSeries.authors")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => setAuthorInput(e.target.value)}
|
||||
onKeyDown={handleAuthorKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.addAuthor")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApplyToBooks(!showApplyToBooks)}
|
||||
disabled={isPending}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
showApplyToBooks
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={t("editSeries.applyToBooksTitle")}
|
||||
>
|
||||
{t("editSeries.applyToBooks")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{showApplyToBooks && (
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||||
<FormField>
|
||||
<FormLabel>{t("editSeries.bookAuthor")}</FormLabel>
|
||||
<FormInput
|
||||
value={bookAuthor}
|
||||
onChange={(e) => setBookAuthor(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editSeries.bookAuthorPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel>{t("editSeries.bookLanguage")}</FormLabel>
|
||||
<FormInput
|
||||
value={bookLanguage}
|
||||
onChange={(e) => setBookLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={t("editBook.languagePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Éditeurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editSeries.publishers")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{publishers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{publishers.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/50 text-secondary-foreground text-xs font-medium"
|
||||
>
|
||||
{p}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePublisher(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={t("editBook.removeAuthor", { name: p })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setPublisherInputEl}
|
||||
value={publisherInput}
|
||||
onChange={(e) => setPublisherInput(e.target.value)}
|
||||
onKeyDown={handlePublisherKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder={t("editSeries.addPublisher")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPublisher}
|
||||
disabled={isPending || !publisherInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
|
||||
</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isPending}
|
||||
rows={3}
|
||||
placeholder={t("editSeries.descriptionPlaceholder")}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Lock legend */}
|
||||
{Object.values(lockedFields).some(Boolean) && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? t("common.saving") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<span>✏️</span> {t("editSeries.title")}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface TreeNode extends FolderItem {
|
||||
children?: TreeNode[];
|
||||
@@ -15,6 +16,7 @@ interface FolderBrowserProps {
|
||||
}
|
||||
|
||||
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
// Convert initial folders to tree structure
|
||||
const [tree, setTree] = useState<TreeNode[]>(
|
||||
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
|
||||
@@ -173,7 +175,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{tree.length === 0 ? (
|
||||
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
||||
No folders found
|
||||
{t("folder.noFolders")}
|
||||
</div>
|
||||
) : (
|
||||
tree.map(node => renderNode(node))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FolderBrowser } from "./FolderBrowser";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface FolderPickerProps {
|
||||
initialFolders: FolderItem[];
|
||||
@@ -13,6 +15,7 @@ interface FolderPickerProps {
|
||||
|
||||
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSelect = (path: string) => {
|
||||
onSelect(path);
|
||||
@@ -27,7 +30,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={selectedPath || "Select a folder..."}
|
||||
value={selectedPath || t("folder.selectFolder")}
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg border bg-card
|
||||
text-sm font-mono
|
||||
@@ -57,12 +60,12 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Browse
|
||||
{t("common.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Popup Modal */}
|
||||
{isOpen && (
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -79,7 +82,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="font-medium">Select Folder</span>
|
||||
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,7 +107,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Click a folder to select it
|
||||
{t("folder.clickToSelect")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -113,13 +116,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface JobDetailLiveProps {
|
||||
jobId: string;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export function JobDetailLive({ jobId, isTerminal }: JobDetailLiveProps) {
|
||||
const router = useRouter();
|
||||
const isTerminalRef = useRef(isTerminal);
|
||||
isTerminalRef.current = isTerminal;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTerminalRef.current) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
router.refresh();
|
||||
|
||||
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [jobId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||
|
||||
interface ProgressEvent {
|
||||
@@ -24,6 +25,7 @@ interface JobProgressProps {
|
||||
}
|
||||
|
||||
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
@@ -53,25 +55,25 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
onComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to parse SSE data");
|
||||
setError(t("jobProgress.sseError"));
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE error:", err);
|
||||
eventSource.close();
|
||||
setError("Connection lost");
|
||||
setError(t("jobProgress.connectionLost"));
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [jobId, onComplete]);
|
||||
}, [jobId, onComplete, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||
Error: {error}
|
||||
{t("jobProgress.error", { message: error })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +81,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="p-4 text-muted-foreground text-sm">
|
||||
Loading progress...
|
||||
{t("jobProgress.loadingProgress")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,14 +90,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const processed = progress.processed_files ?? 0;
|
||||
const total = progress.total_files ?? 0;
|
||||
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
||||
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "thumbnails" : "files";
|
||||
const unitLabel = progress.status === "extracting_pages" ? t("jobProgress.pages") : progress.status === "generating_thumbnails" ? t("jobProgress.thumbnails") : t("jobProgress.filesUnit");
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<StatusBadge status={progress.status} />
|
||||
{isComplete && (
|
||||
<Badge variant="success">Complete</Badge>
|
||||
<Badge variant="success">{t("jobProgress.done")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -105,20 +107,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
<span>{processed} / {total} {unitLabel}</span>
|
||||
{progress.current_file && (
|
||||
<span className="truncate max-w-md" title={progress.current_file}>
|
||||
Current: {progress.current_file.length > 40
|
||||
{t("jobProgress.currentFile", { file: progress.current_file.length > 40
|
||||
? progress.current_file.substring(0, 40) + "..."
|
||||
: progress.current_file}
|
||||
: progress.current_file })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress.stats_json && !isPhase2 && (
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
||||
<Badge variant="primary">{t("jobProgress.scanned", { count: progress.stats_json.scanned_files })}</Badge>
|
||||
<Badge variant="success">{t("jobProgress.indexed", { count: progress.stats_json.indexed_files })}</Badge>
|
||||
<Badge variant="warning">{t("jobProgress.removed", { count: progress.stats_json.removed_files })}</Badge>
|
||||
{progress.stats_json.errors > 0 && (
|
||||
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
|
||||
<Badge variant="error">{t("jobProgress.errors", { count: progress.stats_json.errors })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -33,6 +34,7 @@ interface JobRowProps {
|
||||
}
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||
|
||||
@@ -57,28 +59,11 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
||||
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
|
||||
|
||||
// Files column: index-phase stats only (Phase 1 discovery)
|
||||
const filesDisplay =
|
||||
job.status === "running" && !isPhase2
|
||||
? job.total_files != null
|
||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||
: scanned > 0
|
||||
? `${scanned} scanned`
|
||||
: "-"
|
||||
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
||||
? null // rendered below as ✓ / − / ⚠
|
||||
: scanned > 0
|
||||
? `${scanned} scanned`
|
||||
: "—";
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
const isMetadataRefresh = job.type === "metadata_refresh";
|
||||
|
||||
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
|
||||
// Thumbnails progress (Phase 2: extracting_pages + generating_thumbnails)
|
||||
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
|
||||
const thumbDisplay =
|
||||
thumbInProgress && job.total_files != null
|
||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||
: job.status === "success" && job.total_files != null && hasThumbnailPhase
|
||||
? `✓ ${job.total_files}`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,32 +98,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
{showProgress ? t("jobRow.hideProgress") : t("jobRow.showProgress")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{filesDisplay !== null ? (
|
||||
<span className="text-sm text-foreground">{filesDisplay}</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-success">✓ {indexed}</span>
|
||||
{removed > 0 && <span className="text-warning">− {removed}</span>}
|
||||
{errors > 0 && <span className="text-error">⚠ {errors}</span>}
|
||||
</div>
|
||||
)}
|
||||
{job.status === "running" && !isPhase2 && job.total_files != null && (
|
||||
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{/* Running progress */}
|
||||
{isActive && job.total_files != null && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-foreground">{thumbDisplay}</span>
|
||||
{thumbInProgress && job.total_files != null && (
|
||||
<span className="text-sm text-foreground">{job.processed_files ?? 0}/{job.total_files}</span>
|
||||
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||
</div>
|
||||
)}
|
||||
{/* Completed stats with icons */}
|
||||
{!isActive && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{/* Files: indexed count */}
|
||||
{indexed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
||||
<Icon name="document" size="sm" />
|
||||
{indexed}
|
||||
</span>
|
||||
)}
|
||||
{/* Removed files */}
|
||||
{removed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
||||
<Icon name="trash" size="sm" />
|
||||
{removed}
|
||||
</span>
|
||||
)}
|
||||
{/* Thumbnails */}
|
||||
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||
<Icon name="image" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
)}
|
||||
{/* Metadata batch: series processed */}
|
||||
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
)}
|
||||
{/* Metadata refresh: links refreshed */}
|
||||
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
)}
|
||||
{/* Errors */}
|
||||
{errors > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
||||
<Icon name="warning" size="sm" />
|
||||
{errors}
|
||||
</span>
|
||||
)}
|
||||
{/* Scanned only (no other stats) */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
||||
)}
|
||||
{/* Nothing to show */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -154,7 +181,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
href={`/jobs/${job.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
View
|
||||
{t("jobRow.view")}
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||
<Button
|
||||
@@ -162,7 +189,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
size="sm"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,7 +197,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
</tr>
|
||||
{showProgress && isActive && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-3 bg-muted/50">
|
||||
<td colSpan={8} className="px-4 py-3 bg-muted/50">
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { ProgressBar } from "./ui/ProgressBar";
|
||||
|
||||
@@ -45,6 +46,7 @@ const ChevronIcon = ({ className }: { className?: string }) => (
|
||||
);
|
||||
|
||||
export function JobsIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -52,21 +54,62 @@ export function JobsIndicator() {
|
||||
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActiveJobs = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/jobs/active");
|
||||
if (response.ok) {
|
||||
const jobs = await response.json();
|
||||
setActiveJobs(jobs);
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch jobs:", error);
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveJobs();
|
||||
const interval = setInterval(fetchActiveJobs, 2000);
|
||||
return () => clearInterval(interval);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
disconnect();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Position the popin relative to the button
|
||||
@@ -152,7 +195,7 @@ export function JobsIndicator() {
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
"
|
||||
title="View all jobs"
|
||||
title={t("jobsIndicator.viewAll")}
|
||||
>
|
||||
<JobsIcon className="w-[18px] h-[18px]" />
|
||||
</Link>
|
||||
@@ -187,11 +230,11 @@ export function JobsIndicator() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📊</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("jobsIndicator.activeTasks")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{runningJobs.length > 0
|
||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
||||
? t("jobsIndicator.runningAndPending", { running: runningJobs.length, pending: pendingJobs.length })
|
||||
: t("jobsIndicator.pendingTasks", { count: pendingJobs.length, plural: pendingJobs.length !== 1 ? "s" : "" })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -201,7 +244,7 @@ export function JobsIndicator() {
|
||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
View All →
|
||||
{t("jobsIndicator.viewAllLink")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +252,7 @@ export function JobsIndicator() {
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall Progress</span>
|
||||
<span className="text-muted-foreground">{t("jobsIndicator.overallProgress")}</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||
@@ -221,7 +264,7 @@ export function JobsIndicator() {
|
||||
{activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<span className="text-4xl mb-2">✅</span>
|
||||
<p>No active jobs</p>
|
||||
<p>{t("jobsIndicator.noActiveTasks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
@@ -242,7 +285,7 @@ export function JobsIndicator() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
|
||||
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
|
||||
{t(`jobType.${job.type}` as any) !== `jobType.${job.type}` ? t(`jobType.${job.type}` as any) : job.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +324,7 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
|
||||
<p className="text-xs text-muted-foreground text-center">{t("jobsIndicator.autoRefresh")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -304,7 +347,7 @@ export function JobsIndicator() {
|
||||
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||
`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||
title={t("jobsIndicator.taskCount", { count: totalCount, plural: totalCount !== 1 ? "s" : "" })}
|
||||
>
|
||||
{/* Animated spinner for running jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
interface Job {
|
||||
@@ -39,26 +40,23 @@ function formatDuration(start: string, end: string | null): string {
|
||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
return `${mins}m ago`;
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||
return date.toLocaleString(loc, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh jobs list via SSE
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/jobs/stream");
|
||||
@@ -102,15 +100,14 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.id")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.library")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.type")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.stats")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.duration")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.created")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface LibraryActionsProps {
|
||||
libraryId: string;
|
||||
monitorEnabled: boolean;
|
||||
scanMode: string;
|
||||
watcherEnabled: boolean;
|
||||
metadataProvider: string | null;
|
||||
fallbackMetadataProvider: string | null;
|
||||
metadataRefreshMode: string;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
@@ -16,22 +22,14 @@ export function LibraryActions({
|
||||
monitorEnabled,
|
||||
scanMode,
|
||||
watcherEnabled,
|
||||
onUpdate
|
||||
metadataProvider,
|
||||
fallbackMetadataProvider,
|
||||
metadataRefreshMode,
|
||||
}: LibraryActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setSaveError(null);
|
||||
@@ -39,17 +37,28 @@ export function LibraryActions({
|
||||
const monitorEnabled = formData.get("monitor_enabled") === "true";
|
||||
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
||||
const scanMode = formData.get("scan_mode") as string;
|
||||
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
||||
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||
const [response] = await Promise.all([
|
||||
fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
monitor_enabled: monitorEnabled,
|
||||
scan_mode: scanMode,
|
||||
watcher_enabled: watcherEnabled,
|
||||
metadata_refresh_mode: newMetadataRefreshMode,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (response.ok) {
|
||||
setIsOpen(false);
|
||||
@@ -69,11 +78,11 @@ export function LibraryActions({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={isOpen ? "bg-accent" : ""}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -82,12 +91,54 @@ export function LibraryActions({
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form action={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
|
||||
|
||||
{/* Section: Indexation */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{t("libraryActions.sectionIndexation")}
|
||||
</h3>
|
||||
|
||||
{/* Auto scan */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="monitor_enabled"
|
||||
@@ -95,12 +146,25 @@ export function LibraryActions({
|
||||
defaultChecked={monitorEnabled}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||
/>
|
||||
Auto Scan
|
||||
{t("libraryActions.autoScan")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.autoScanDesc")}</p>
|
||||
</div>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[130px] shrink-0"
|
||||
>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
<option value="daily">{t("monitoring.daily")}</option>
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{/* File watcher */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="watcher_enabled"
|
||||
@@ -108,42 +172,120 @@ export function LibraryActions({
|
||||
defaultChecked={watcherEnabled}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||
/>
|
||||
File Watcher ⚡
|
||||
{t("libraryActions.fileWatch")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.fileWatchDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
|
||||
<hr className="border-border/40" />
|
||||
|
||||
{/* Section: Metadata */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t("libraryActions.sectionMetadata")}
|
||||
</h3>
|
||||
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
{metadataProvider && metadataProvider !== "none" && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||
{t("libraryActions.provider")}
|
||||
</label>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
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="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<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-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
||||
<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"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
{isPending ? t("libraryActions.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { FolderPicker } from "./FolderPicker";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { Button, FormField, FormInput, FormRow } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface LibraryFormProps {
|
||||
initialFolders: FolderItem[];
|
||||
@@ -11,13 +12,14 @@ interface LibraryFormProps {
|
||||
}
|
||||
|
||||
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder="Library name" required />
|
||||
<FormInput name="name" placeholder={t("libraries.libraryName")} required />
|
||||
</FormField>
|
||||
<FormField className="flex-1 min-w-64">
|
||||
<input type="hidden" name="root_path" value={selectedPath} />
|
||||
@@ -30,7 +32,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||
</FormRow>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="submit" disabled={!selectedPath}>
|
||||
Add Library
|
||||
{t("libraries.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Card, Badge } from "./ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
interface LibrarySubPageHeaderProps {
|
||||
library: {
|
||||
@@ -19,13 +20,14 @@ interface LibrarySubPageHeaderProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function LibrarySubPageHeader({
|
||||
export async function LibrarySubPageHeader({
|
||||
library,
|
||||
title,
|
||||
icon,
|
||||
iconColor = "text-primary",
|
||||
filterInfo
|
||||
}: LibrarySubPageHeaderProps) {
|
||||
const { t } = await getServerTranslations();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header avec breadcrumb intégré */}
|
||||
@@ -38,7 +40,7 @@ export function LibrarySubPageHeader({
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Libraries
|
||||
{t("libraryHeader.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||
@@ -73,8 +75,7 @@ export function LibrarySubPageHeader({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<span className="text-foreground">
|
||||
<span className="font-semibold">{library.book_count}</span>
|
||||
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
|
||||
<span className="text-muted-foreground ml-1">{t("libraryHeader.bookCount", { count: library.book_count, plural: library.book_count !== 1 ? "s" : "" })}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +87,7 @@ export function LibrarySubPageHeader({
|
||||
variant={library.enabled ? "success" : "muted"}
|
||||
className="text-xs"
|
||||
>
|
||||
{library.enabled ? "Enabled" : "Disabled"}
|
||||
{library.enabled ? t("libraryHeader.enabled") : t("libraries.disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
// SVG path data for filter icons, keyed by field name
|
||||
const FILTER_ICONS: Record<string, string> = {
|
||||
// Library - building/collection
|
||||
library: "M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z",
|
||||
// Reading status - open book
|
||||
status: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||
// Series status - signal/activity
|
||||
series_status: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
// Missing books - warning triangle
|
||||
has_missing: "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",
|
||||
// Metadata provider - tag
|
||||
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||
// Sort - arrows up/down
|
||||
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
||||
// Format - document/file
|
||||
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
|
||||
// Metadata - link/chain
|
||||
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||
};
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
@@ -18,11 +39,17 @@ interface LiveSearchFormProps {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "filters:";
|
||||
|
||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
|
||||
|
||||
const buildUrl = useCallback((): string => {
|
||||
if (!formRef.current) return basePath;
|
||||
@@ -36,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
}, [basePath]);
|
||||
|
||||
const saveFilters = useCallback(() => {
|
||||
if (!formRef.current) return;
|
||||
const formData = new FormData(formRef.current);
|
||||
const filters: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const str = value.toString().trim();
|
||||
if (str) filters[key] = str;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(filters));
|
||||
} catch {}
|
||||
}, [storageKey]);
|
||||
|
||||
const navigate = useCallback((immediate: boolean) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (immediate) {
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
} else {
|
||||
timerRef.current = setTimeout(() => {
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
}, debounceMs);
|
||||
}
|
||||
}, [router, buildUrl, debounceMs]);
|
||||
}, [router, buildUrl, debounceMs, saveFilters]);
|
||||
|
||||
// Restore filters from localStorage on mount if URL has no filters
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
|
||||
const hasUrlFilters = fields.some((f) => {
|
||||
const val = searchParams.get(f.name);
|
||||
return val && val.trim() !== "";
|
||||
});
|
||||
if (hasUrlFilters) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (!saved) return;
|
||||
const filters: Record<string, string> = JSON.parse(saved);
|
||||
const fieldNames = new Set(fields.map((f) => f.name));
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (fieldNames.has(key) && value) params.set(key, value);
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs) {
|
||||
router.replace(`${basePath}?${qs}` as any);
|
||||
}
|
||||
} catch {}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -58,41 +127,64 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
return val && val.trim() !== "";
|
||||
});
|
||||
|
||||
const textFields = fields.filter((f) => f.type === "text");
|
||||
const selectFields = fields.filter((f) => f.type === "select");
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
}}
|
||||
className="flex flex-col sm:flex-row gap-3 items-start sm:items-end"
|
||||
className="space-y-4"
|
||||
>
|
||||
{fields.map((field) =>
|
||||
field.type === "text" ? (
|
||||
<div key={field.name} className={field.className || "flex-1 w-full"}>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{field.label}
|
||||
</label>
|
||||
{/* Search input with icon */}
|
||||
{textFields.map((field) => (
|
||||
<div key={field.name} className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
||||
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>
|
||||
<input
|
||||
name={field.name}
|
||||
type="text"
|
||||
placeholder={field.placeholder}
|
||||
defaultValue={searchParams.get(field.name) || ""}
|
||||
onChange={() => navigate(false)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className="flex h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.name} className={field.className || "w-full sm:w-48"}>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
))}
|
||||
|
||||
{/* Filters row */}
|
||||
{selectFields.length > 0 && (
|
||||
<>
|
||||
{textFields.length > 0 && (
|
||||
<div className="border-t border-border/60" />
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{selectFields.map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-1.5">
|
||||
{FILTER_ICONS[field.name] && (
|
||||
<svg className="w-3.5 h-3.5 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={FILTER_ICONS[field.name]} />
|
||||
</svg>
|
||||
)}
|
||||
<label className="text-xs font-medium text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
name={field.name}
|
||||
defaultValue={searchParams.get(field.name) || ""}
|
||||
onChange={() => navigate(true)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -101,28 +193,34 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
{hasFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(basePath as any)}
|
||||
onClick={() => {
|
||||
formRef.current?.reset();
|
||||
try { localStorage.removeItem(storageKey); } catch {}
|
||||
router.replace(basePath as any);
|
||||
}}
|
||||
className="
|
||||
inline-flex items-center justify-center
|
||||
h-10 px-4
|
||||
border border-input
|
||||
text-sm font-medium
|
||||
inline-flex items-center gap-1
|
||||
h-8 px-2.5
|
||||
text-xs font-medium
|
||||
text-muted-foreground
|
||||
bg-background
|
||||
rounded-md
|
||||
hover:bg-accent hover:text-accent-foreground
|
||||
transition-colors duration-200
|
||||
w-full sm:w-auto
|
||||
"
|
||||
>
|
||||
Clear
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{t("common.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MarkBookReadButtonProps {
|
||||
bookId: string;
|
||||
@@ -10,12 +11,13 @@ interface MarkBookReadButtonProps {
|
||||
}
|
||||
|
||||
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const isRead = currentStatus === "read";
|
||||
const targetStatus = isRead ? "unread" : "read";
|
||||
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
|
||||
const label = isRead ? t("markRead.markUnread") : t("markRead.markAsRead");
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MarkSeriesReadButtonProps {
|
||||
seriesName: string;
|
||||
@@ -10,12 +11,13 @@ interface MarkSeriesReadButtonProps {
|
||||
}
|
||||
|
||||
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const allRead = booksReadCount >= bookCount;
|
||||
const targetStatus = allRead ? "unread" : "read";
|
||||
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
|
||||
const label = allRead ? t("markRead.markUnread") : t("markRead.markAllRead");
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
696
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
696
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Icon } from "./ui";
|
||||
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
|
||||
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const FIELD_KEYS: string[] = [
|
||||
"description", "authors", "publishers", "start_year",
|
||||
"total_volumes", "status", "summary", "isbn", "publish_date", "language",
|
||||
];
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value == null) return "—";
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
if (typeof value === "string") {
|
||||
return value.length > 80 ? value.slice(0, 80) + "…" : value;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
interface MetadataSearchModalProps {
|
||||
libraryId: string;
|
||||
seriesName: string;
|
||||
existingLink: ExternalMetadataLinkDto | null;
|
||||
initialMissing: MissingBooksDto | null;
|
||||
}
|
||||
|
||||
type ModalStep = "idle" | "searching" | "results" | "confirm" | "syncing" | "done" | "linked";
|
||||
|
||||
export function MetadataSearchModal({
|
||||
libraryId,
|
||||
seriesName,
|
||||
existingLink,
|
||||
initialMissing,
|
||||
}: MetadataSearchModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const fieldLabel = (field: string): string => {
|
||||
if (FIELD_KEYS.includes(field)) {
|
||||
return t(`field.${field}` as any);
|
||||
}
|
||||
return field;
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [step, setStep] = useState<ModalStep>("idle");
|
||||
const [candidates, setCandidates] = useState<SeriesCandidateDto[]>([]);
|
||||
const [selectedCandidate, setSelectedCandidate] = useState<SeriesCandidateDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [linkId, setLinkId] = useState<string | null>(existingLink?.id ?? null);
|
||||
const [missing, setMissing] = useState<MissingBooksDto | null>(initialMissing);
|
||||
const [showMissingList, setShowMissingList] = useState(false);
|
||||
const [syncReport, setSyncReport] = useState<SyncReport | null>(null);
|
||||
|
||||
// Provider selector: empty string = library default
|
||||
const [searchProvider, setSearchProvider] = useState("");
|
||||
const [activeProvider, setActiveProvider] = useState("");
|
||||
const [hiddenProviders, setHiddenProviders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Fetch metadata provider settings to hide providers without required API keys
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/metadata_providers")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
const hidden = new Set<string>();
|
||||
// ComicVine requires an API key
|
||||
if (!data.comicvine?.api_key) hidden.add("comicvine");
|
||||
setHiddenProviders(hidden);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value));
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
if (existingLink && existingLink.status === "approved") {
|
||||
setStep("linked");
|
||||
} else {
|
||||
doSearch("");
|
||||
}
|
||||
}, [existingLink]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setStep("idle");
|
||||
setError(null);
|
||||
setCandidates([]);
|
||||
setSelectedCandidate(null);
|
||||
setShowMissingList(false);
|
||||
setSyncReport(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
async function doSearch(provider: string) {
|
||||
setStep("searching");
|
||||
setError(null);
|
||||
setActiveProvider(provider);
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
library_id: libraryId,
|
||||
series_name: seriesName,
|
||||
};
|
||||
if (provider) body.provider = provider;
|
||||
|
||||
const resp = await fetch("/api/metadata/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
setError(data.error || t("metadata.searchFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
setCandidates(data);
|
||||
// Update activeProvider from first result (the API returns the actual provider used)
|
||||
if (data.length > 0 && data[0].provider) {
|
||||
setActiveProvider(data[0].provider);
|
||||
if (!provider) setSearchProvider(data[0].provider);
|
||||
}
|
||||
setStep("results");
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
setStep("results");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectCandidate(candidate: SeriesCandidateDto) {
|
||||
setSelectedCandidate(candidate);
|
||||
setStep("confirm");
|
||||
}
|
||||
|
||||
async function handleApprove(syncSeries: boolean, syncBooks: boolean) {
|
||||
if (!selectedCandidate) return;
|
||||
setStep("syncing");
|
||||
setError(null);
|
||||
try {
|
||||
// Create match — use the provider from the candidate
|
||||
const matchResp = await fetch("/api/metadata/match", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
library_id: libraryId,
|
||||
series_name: seriesName,
|
||||
provider: selectedCandidate.provider,
|
||||
external_id: selectedCandidate.external_id,
|
||||
external_url: selectedCandidate.external_url,
|
||||
confidence: selectedCandidate.confidence,
|
||||
title: selectedCandidate.title,
|
||||
metadata_json: {
|
||||
...selectedCandidate.metadata_json,
|
||||
description: selectedCandidate.description,
|
||||
authors: selectedCandidate.authors,
|
||||
publishers: selectedCandidate.publishers,
|
||||
start_year: selectedCandidate.start_year,
|
||||
},
|
||||
total_volumes: selectedCandidate.total_volumes,
|
||||
}),
|
||||
});
|
||||
const matchData = await matchResp.json();
|
||||
if (!matchResp.ok) {
|
||||
setError(matchData.error || t("metadata.linkFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
const newLinkId = matchData.id;
|
||||
setLinkId(newLinkId);
|
||||
|
||||
// Approve
|
||||
const approveResp = await fetch("/api/metadata/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: newLinkId,
|
||||
sync_series: syncSeries,
|
||||
sync_books: syncBooks,
|
||||
}),
|
||||
});
|
||||
const approveData = await approveResp.json();
|
||||
if (!approveResp.ok) {
|
||||
setError(approveData.error || t("metadata.approveFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store sync report
|
||||
if (approveData.report) {
|
||||
setSyncReport(approveData.report);
|
||||
}
|
||||
|
||||
// Fetch missing books info
|
||||
if (syncBooks) {
|
||||
try {
|
||||
const missingResp = await fetch(`/api/metadata/missing?id=${newLinkId}`);
|
||||
if (missingResp.ok) {
|
||||
setMissing(await missingResp.json());
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
setStep("done");
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
setStep("results");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlink() {
|
||||
if (!linkId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/metadata/links?id=${linkId}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
setLinkId(null);
|
||||
setMissing(null);
|
||||
handleClose();
|
||||
router.refresh();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function confidenceBadge(confidence: number) {
|
||||
const color =
|
||||
confidence >= 0.8
|
||||
? "bg-green-500/10 text-green-600 border-green-500/30"
|
||||
: confidence >= 0.5
|
||||
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
|
||||
: "bg-red-500/10 text-red-600 border-red-500/30";
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${color}`}>
|
||||
{Math.round(confidence * 100)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const modal = isOpen
|
||||
? createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<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-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}
|
||||
</h3>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Provider selector — visible during searching & results */}
|
||||
{(step === "searching" || step === "results") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">{t("metadata.provider")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{visibleProviders.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
disabled={step === "searching"}
|
||||
onClick={() => {
|
||||
setSearchProvider(p.value);
|
||||
doSearch(p.value);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
|
||||
(activeProvider || searchProvider) === p.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon provider={p.value} size={14} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEARCHING */}
|
||||
{step === "searching" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">{t("metadata.searching", { name: seriesName })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ERROR */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RESULTS */}
|
||||
{step === "results" && (
|
||||
<>
|
||||
{candidates.length === 0 && !error ? (
|
||||
<p className="text-muted-foreground text-center py-8">{t("metadata.noResults")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t("metadata.resultCount", { count: candidates.length, plural: candidates.length !== 1 ? "s" : "" })}
|
||||
{activeProvider && (
|
||||
<span className="ml-1 text-xs inline-flex items-center gap-1">{t("common.via")} <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
|
||||
)}
|
||||
</p>
|
||||
{candidates.map((c, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleSelectCandidate(c)}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-10 h-14 flex-shrink-0 rounded bg-muted/50 overflow-hidden">
|
||||
{c.cover_url ? (
|
||||
<img src={c.cover_url} alt={c.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground/40">
|
||||
<Icon name="image" size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground truncate">{c.title}</span>
|
||||
{confidenceBadge(c.confidence)}
|
||||
</div>
|
||||
{c.authors.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground truncate">{c.authors.join(", ")}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{c.publishers.length > 0 && <span>{c.publishers[0]}</span>}
|
||||
{c.start_year != null && <span>{c.start_year}</span>}
|
||||
{c.total_volumes != null && (
|
||||
<span>
|
||||
{c.total_volumes} {c.metadata_json?.volume_source === "chapters" ? "ch." : "vol."}
|
||||
</span>
|
||||
)}
|
||||
{c.metadata_json?.status === "RELEASING" && (
|
||||
<span className="italic text-amber-500">{t("metadata.inProgress")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CONFIRM */}
|
||||
{step === "confirm" && selectedCandidate && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div className="flex gap-3">
|
||||
{selectedCandidate.cover_url && (
|
||||
<img
|
||||
src={selectedCandidate.cover_url}
|
||||
alt={selectedCandidate.title}
|
||||
className="w-16 h-22 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">{selectedCandidate.title}</h4>
|
||||
{selectedCandidate.authors.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{selectedCandidate.authors.join(", ")}</p>
|
||||
)}
|
||||
{selectedCandidate.total_volumes != null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? t("metadata.chapters") : t("metadata.volumes")}
|
||||
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">({t("metadata.inProgress")})</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
|
||||
{t("common.via")} <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground font-medium">{t("metadata.howToSync")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApprove(true, false)}
|
||||
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesOnly")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesOnlyDesc")}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApprove(true, true)}
|
||||
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesAndBooks")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesAndBooksDesc")}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("metadata.backToResults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SYNCING */}
|
||||
{step === "syncing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">{t("metadata.syncingMetadata")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DONE */}
|
||||
{step === "done" && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||
<p className="font-medium text-green-600">{t("metadata.syncSuccess")}</p>
|
||||
</div>
|
||||
|
||||
{/* Sync Report */}
|
||||
{syncReport && (
|
||||
<div className="space-y-3">
|
||||
{/* Series report */}
|
||||
{syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (
|
||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">{t("metadata.seriesLabel")}</p>
|
||||
{syncReport.series.fields_updated.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{syncReport.series.fields_updated.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{fieldLabel(f.field)}</span>
|
||||
<span className="text-muted-foreground truncate max-w-[200px]">{formatValue(f.new_value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{syncReport.series.fields_skipped.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
{syncReport.series.fields_skipped.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-amber-500">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Books message (e.g. provider has no volume data) */}
|
||||
{syncReport.books_message && (
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<p className="text-xs text-amber-600">{syncReport.books_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Books report */}
|
||||
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{t("metadata.booksLabel")} — {t("metadata.booksMatched", { matched: syncReport.books_matched, plural: syncReport.books_matched !== 1 ? "s" : "" })}{syncReport.books_unmatched > 0 && `, ${t("metadata.booksUnmatched", { count: syncReport.books_unmatched, plural: syncReport.books_unmatched !== 1 ? "s" : "" })}`}
|
||||
</p>
|
||||
{syncReport.books.length > 0 && (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{syncReport.books.map((b, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<p className="font-medium text-foreground">
|
||||
{b.volume != null && <span className="font-mono text-muted-foreground mr-1.5">#{b.volume}</span>}
|
||||
{b.title}
|
||||
</p>
|
||||
<div className="ml-4 space-y-0.5 mt-0.5">
|
||||
{b.fields_updated.map((f, j) => (
|
||||
<p key={j} className="flex items-center gap-1.5 text-green-600">
|
||||
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||
</p>
|
||||
))}
|
||||
{b.fields_skipped.map((f, j) => (
|
||||
<p key={`s${j}`} className="flex items-center gap-1.5 text-amber-500">
|
||||
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing books */}
|
||||
{missing && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||
<p className="text-2xl font-semibold">{missing.total_external}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||
<p className="text-2xl font-semibold">{missing.total_local}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missing.missing_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMissingList(!showMissingList)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{t("metadata.missingBooks", { count: missing.missing_count, plural: missing.missing_count !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showMissingList && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{missing.missing_books.map((b, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate">
|
||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||
{b.title || t("metadata.unknown")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { handleClose(); router.refresh(); }}
|
||||
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LINKED (already approved) */}
|
||||
{step === "linked" && existingLink && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
|
||||
{t("metadata.linkedTo")} <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
|
||||
</p>
|
||||
{existingLink.external_url && (
|
||||
<a
|
||||
href={existingLink.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("metadata.viewExternal")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{existingLink.confidence != null && confidenceBadge(existingLink.confidence)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initialMissing && (
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||
<p className="text-2xl font-semibold text-warning">{initialMissing.missing_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{initialMissing && initialMissing.missing_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMissingList(!showMissingList)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{t("metadata.missingBooks", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showMissingList && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{initialMissing.missing_books.map((b, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate">
|
||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||
{b.title || t("metadata.unknown")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { doSearch(""); }}
|
||||
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
{t("metadata.searchAgain")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlink}
|
||||
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
{t("metadata.unlink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<Icon name="search" size="sm" />
|
||||
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||
</button>
|
||||
|
||||
{existingLink && existingLink.status === "approved" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
||||
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||
<span>{providerLabel(existingLink.provider)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { NavIcon } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
label: string;
|
||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const HamburgerIcon = () => (
|
||||
@@ -24,6 +25,7 @@ const XIcon = () => (
|
||||
);
|
||||
|
||||
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -53,7 +55,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
`}
|
||||
>
|
||||
<div className="h-16 border-b border-border/40 flex items-center px-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">Navigation</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">{t("nav.navigation")}</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 p-3 flex-1">
|
||||
@@ -76,7 +78,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<NavIcon name="settings" />
|
||||
<span className="font-medium">Settings</span>
|
||||
<span className="font-medium">{t("nav.settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -90,7 +92,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||
aria-label={isOpen ? t("nav.closeMenu") : t("nav.openMenu")}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MonitoringFormProps {
|
||||
libraryId: string;
|
||||
@@ -10,6 +11,7 @@ interface MonitoringFormProps {
|
||||
}
|
||||
|
||||
export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEnabled }: MonitoringFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
@@ -51,7 +53,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Auto</span>
|
||||
<span>{t("monitoring.auto")}</span>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
@@ -67,7 +69,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
||||
/>
|
||||
<span title="Real-time file watcher">⚡</span>
|
||||
<span title={t("monitoring.fileWatch")}>⚡</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
@@ -76,10 +78,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<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>
|
||||
|
||||
<button
|
||||
|
||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Period = "day" | "week" | "month";
|
||||
|
||||
export function PeriodToggle({
|
||||
labels,
|
||||
}: {
|
||||
labels: { day: string; week: string; month: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("period");
|
||||
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||
|
||||
function setPeriod(period: Period) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (period === "month") {
|
||||
params.delete("period");
|
||||
} else {
|
||||
params.set("period", period);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||
}
|
||||
|
||||
const options: Period[] = ["day", "week", "month"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||
{options.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
current === p
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal file
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/** Inline SVG icons for metadata providers */
|
||||
|
||||
interface ProviderIconProps {
|
||||
provider: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, size = 16, className = "" }: ProviderIconProps) {
|
||||
const style = { width: size, height: size, flexShrink: 0 };
|
||||
|
||||
switch (provider) {
|
||||
case "google_books":
|
||||
// Stylized book (Google Books)
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||
<path
|
||||
d="M21 4H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"
|
||||
fill="#4285F4"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M12 4v16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
||||
fill="none"
|
||||
stroke="#4285F4"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M7 8h3M7 11h3M14 8h3M14 11h3" stroke="#4285F4" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "open_library":
|
||||
// Open book (Open Library)
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||
<path
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
fill="none"
|
||||
stroke="#E8590C"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "comicvine":
|
||||
// Explosion / star burst (ComicVine)
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||
<path
|
||||
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
|
||||
fill="#E7272D"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
|
||||
fill="none"
|
||||
stroke="#E7272D"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "anilist":
|
||||
// Stylized play / triangle (AniList)
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" fill="#02A9FF" opacity="0.15" />
|
||||
<path
|
||||
d="M8 6h2.5l4 12H12l-.75-2.5H7.75L7 18H4.5L8 6zm-.25 7.5h3.5L9.5 8.25 7.75 13.5z"
|
||||
fill="#02A9FF"
|
||||
/>
|
||||
<path d="M16 10h2.5v8H16z" fill="#02A9FF" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "bedetheque":
|
||||
// French flag-inspired book (Bédéthèque)
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||
<rect x="3" y="4" width="6" height="16" rx="1" fill="#002395" opacity="0.2" />
|
||||
<rect x="9" y="4" width="6" height="16" fill="#FFFFFF" opacity="0.1" />
|
||||
<rect x="15" y="4" width="6" height="16" rx="1" fill="#ED2939" opacity="0.2" />
|
||||
<path
|
||||
d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
||||
fill="none"
|
||||
stroke="#002395"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M8 9h8M8 12h6M8 15h4" stroke="#002395" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
default:
|
||||
// Generic globe
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" style={style} className={className} fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const PROVIDERS = [
|
||||
{ value: "google_books", label: "Google Books" },
|
||||
{ value: "open_library", label: "Open Library" },
|
||||
{ value: "comicvine", label: "ComicVine" },
|
||||
{ value: "anilist", label: "AniList" },
|
||||
{ value: "bedetheque", label: "Bédéthèque" },
|
||||
] as const;
|
||||
|
||||
export function providerLabel(value: string) {
|
||||
return PROVIDERS.find((p) => p.value === value)?.label ?? value.replace("_", " ");
|
||||
}
|
||||
383
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
383
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "./ui";
|
||||
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MissingBookItem {
|
||||
title: string | null;
|
||||
volume_number: number | null;
|
||||
external_book_id: string | null;
|
||||
}
|
||||
|
||||
interface ProwlarrSearchModalProps {
|
||||
seriesName: string;
|
||||
missingBooks: MissingBookItem[] | null;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB";
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + " KB";
|
||||
return bytes + " B";
|
||||
}
|
||||
|
||||
export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearchModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isConfigured, setIsConfigured] = useState<boolean | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [results, setResults] = useState<ProwlarrRelease[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// qBittorrent state
|
||||
const [isQbConfigured, setIsQbConfigured] = useState(false);
|
||||
const [sendingGuid, setSendingGuid] = useState<string | null>(null);
|
||||
const [sentGuids, setSentGuids] = useState<Set<string>>(new Set());
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
|
||||
// Check if Prowlarr and qBittorrent are configured on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/prowlarr")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
setIsConfigured(!!(data && data.api_key && data.api_key.trim()));
|
||||
})
|
||||
.catch(() => setIsConfigured(false));
|
||||
fetch("/api/settings/qbittorrent")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
setIsQbConfigured(!!(data && data.url && data.url.trim() && data.username && data.username.trim()));
|
||||
})
|
||||
.catch(() => setIsQbConfigured(false));
|
||||
}, []);
|
||||
|
||||
const [searchInput, setSearchInput] = useState(`"${seriesName}"`);
|
||||
|
||||
const doSearch = useCallback(async (queryOverride?: string) => {
|
||||
const searchQuery = queryOverride ?? searchInput;
|
||||
if (!searchQuery.trim()) return;
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
setResults([]);
|
||||
try {
|
||||
const missing_volumes = missingBooks?.map((b) => ({
|
||||
volume_number: b.volume_number,
|
||||
title: b.title,
|
||||
})) ?? undefined;
|
||||
const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes };
|
||||
const resp = await fetch("/api/prowlarr/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
const searchResp = data as ProwlarrSearchResponse;
|
||||
setResults(searchResp.results);
|
||||
setQuery(searchResp.query);
|
||||
}
|
||||
} catch {
|
||||
setError(t("prowlarr.searchError"));
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [t, seriesName, searchInput]);
|
||||
|
||||
const defaultQuery = `"${seriesName}"`;
|
||||
|
||||
function handleOpen() {
|
||||
setIsOpen(true);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
setQuery("");
|
||||
setSearchInput(defaultQuery);
|
||||
// Auto-search the series on open
|
||||
doSearch(defaultQuery);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
async function handleSendToQbittorrent(downloadUrl: string, guid: string) {
|
||||
setSendingGuid(guid);
|
||||
setSendError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/qbittorrent/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: downloadUrl }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
setSendError(data.error);
|
||||
} else if (data.success) {
|
||||
setSentGuids((prev) => new Set(prev).add(guid));
|
||||
} else {
|
||||
setSendError(data.message || t("prowlarr.sentError"));
|
||||
}
|
||||
} catch {
|
||||
setSendError(t("prowlarr.sentError"));
|
||||
} finally {
|
||||
setSendingGuid(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render button if not configured
|
||||
if (isConfigured === false) return null;
|
||||
if (isConfigured === null) return null;
|
||||
|
||||
const modal = isOpen
|
||||
? createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<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-5xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">{t("prowlarr.modalTitle")}</h3>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Search input */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (searchInput.trim()) doSearch(searchInput.trim());
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
placeholder={t("prowlarr.searchPlaceholder")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSearching || !searchInput.trim()}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Icon name="search" size="sm" />
|
||||
{t("prowlarr.searchAction")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Quick search badges */}
|
||||
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
|
||||
disabled={isSearching}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{seriesName}
|
||||
</button>
|
||||
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => {
|
||||
const label = book.title || `Vol. ${book.volume_number}`;
|
||||
const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => { setSearchInput(q); doSearch(q); }}
|
||||
disabled={isSearching}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching indicator */}
|
||||
{isSearching && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||
{t("prowlarr.searching")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })}
|
||||
{query && <span className="ml-1 text-xs opacity-70">({query})</span>}
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 text-left">
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnTitle")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnIndexer")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground text-right">{t("prowlarr.columnSize")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnSeeders")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnLeechers")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnProtocol")}</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{results.map((release, i) => {
|
||||
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
|
||||
return (
|
||||
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
||||
<td className="px-3 py-2 max-w-[400px]">
|
||||
<span className="truncate block" title={release.title}>
|
||||
{release.title}
|
||||
</span>
|
||||
{hasMissing && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{release.matchedMissingVolumes!.map((vol) => (
|
||||
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||
{t("prowlarr.missingVol", { vol })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{release.indexer || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground whitespace-nowrap">
|
||||
{release.size > 0 ? formatSize(release.size) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{release.seeders != null ? (
|
||||
<span className={release.seeders > 0 ? "text-green-500 font-medium" : "text-muted-foreground"}>
|
||||
{release.seeders}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">
|
||||
{release.leechers != null ? release.leechers : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{release.protocol && (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
release.protocol === "torrent"
|
||||
? "bg-blue-500/15 text-blue-600"
|
||||
: "bg-amber-500/15 text-amber-600"
|
||||
}`}>
|
||||
{release.protocol}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{isQbConfigured && release.downloadUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSendToQbittorrent(release.downloadUrl!, release.guid)}
|
||||
disabled={sendingGuid === release.guid || sentGuids.has(release.guid)}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 ${
|
||||
sentGuids.has(release.guid)
|
||||
? "text-green-500"
|
||||
: "text-primary hover:bg-primary/10"
|
||||
}`}
|
||||
title={sentGuids.has(release.guid) ? t("prowlarr.sentSuccess") : t("prowlarr.sendToQbittorrent")}
|
||||
>
|
||||
{sendingGuid === release.guid ? (
|
||||
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||
) : sentGuids.has(release.guid) ? (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 8l4 4 6-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 8V14H2V2H8M10 2H14V6M14 2L7 9" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{release.downloadUrl && (
|
||||
<a
|
||||
href={release.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-primary hover:bg-primary/10 transition-colors"
|
||||
title={t("prowlarr.download")}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4 7l4 4 4-4M2 13h12" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{release.infoUrl && (
|
||||
<a
|
||||
href={release.infoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title={t("prowlarr.info")}
|
||||
>
|
||||
<Icon name="externalLink" size="sm" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* qBittorrent send error */}
|
||||
{sendError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isSearching && !error && query && results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<Icon name="search" size="sm" />
|
||||
{t("prowlarr.searchButton")}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
apps/backoffice/app/components/SafeHtml.tsx
Normal file
29
apps/backoffice/app/components/SafeHtml.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import React from "react";
|
||||
|
||||
interface SafeHtmlProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
as?: "div" | "p" | "span";
|
||||
}
|
||||
|
||||
const sanitizeOptions: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
"b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "span",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target", "rel"],
|
||||
span: ["class"],
|
||||
},
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
|
||||
},
|
||||
};
|
||||
|
||||
export function SafeHtml({ html, className, as: tag = "div" }: SafeHtmlProps) {
|
||||
return React.createElement(tag, {
|
||||
className,
|
||||
dangerouslySetInnerHTML: { __html: sanitizeHtml(html, sanitizeOptions) },
|
||||
});
|
||||
}
|
||||
53
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
53
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface SeriesFiltersProps {
|
||||
basePath: string;
|
||||
currentSeriesStatus?: string;
|
||||
currentHasMissing: boolean;
|
||||
seriesStatusOptions: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateFilter = useCallback((key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
params.delete("page");
|
||||
const qs = params.toString();
|
||||
router.push(`${basePath}${qs ? `?${qs}` : ""}` as any);
|
||||
}, [router, searchParams, basePath]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={currentSeriesStatus || ""}
|
||||
onChange={(e) => updateFilter("series_status", e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||
>
|
||||
{seriesStatusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={currentHasMissing ? "true" : ""}
|
||||
onChange={(e) => updateFilter("has_missing", e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||
>
|
||||
<option value="">{t("seriesFilters.all")}</option>
|
||||
<option value="true">{t("seriesFilters.missingBooks")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user