Compare commits
3 Commits
a99bfb5a91
...
f75d795215
| Author | SHA1 | Date | |
|---|---|---|---|
| f75d795215 | |||
| ac13f53124 | |||
| c9ccf5cd90 |
311
Cargo.lock
generated
311
Cargo.lock
generated
@@ -19,6 +19,19 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -51,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -65,7 +78,9 @@ dependencies = [
|
|||||||
"lru",
|
"lru",
|
||||||
"parsers",
|
"parsers",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -463,6 +478,29 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -483,6 +521,17 @@ dependencies = [
|
|||||||
"powerfmt",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -512,6 +561,21 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
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]]
|
[[package]]
|
||||||
name = "ecb"
|
name = "ecb"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -521,6 +585,12 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -629,6 +699,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -728,6 +808,15 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -738,6 +827,15 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -855,6 +953,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -1122,7 +1232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1406,6 +1516,37 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1496,6 +1637,12 @@ version = "1.0.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -1624,7 +1771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1690,6 +1837,58 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1793,6 +1992,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -2230,6 +2435,41 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -2302,6 +2542,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2365,6 +2614,12 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2613,6 +2868,31 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2626,7 +2906,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2679,6 +2959,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -2991,6 +3282,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -3038,6 +3335,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf16string"
|
name = "utf16string"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -41,3 +41,4 @@ walkdir = "2.5"
|
|||||||
webp = "0.3"
|
webp = "0.3"
|
||||||
utoipa = "4.0"
|
utoipa = "4.0"
|
||||||
utoipa-swagger-ui = "6.0"
|
utoipa-swagger-ui = "6.0"
|
||||||
|
scraper = "0.21"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ stripstream-core = { path = "../../crates/core" }
|
|||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
regex = "1"
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
@@ -33,3 +34,4 @@ uuid.workspace = true
|
|||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
||||||
webp.workspace = true
|
webp.workspace = true
|
||||||
|
scraper.workspace = true
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ COPY crates/parsers/src crates/parsers/src
|
|||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
|
touch apps/api/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||||
cargo build --release -p api && \
|
cargo build --release -p api && \
|
||||||
cp /app/target/release/api /usr/local/bin/api
|
cp /app/target/release/api /usr/local/bin/api
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ pub struct BookDetails {
|
|||||||
pub reading_current_page: Option<i32>,
|
pub reading_current_page: Option<i32>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub reading_last_read_at: Option<DateTime<Utc>>,
|
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
|
/// List books with optional filtering and pagination
|
||||||
@@ -249,7 +255,7 @@ pub async fn get_book(
|
|||||||
) -> Result<Json<BookDetails>, ApiError> {
|
) -> Result<Json<BookDetails>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
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,
|
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,
|
bf.abs_path, bf.format, bf.parse_status,
|
||||||
COALESCE(brp.status, 'unread') AS reading_status,
|
COALESCE(brp.status, 'unread') AS reading_status,
|
||||||
brp.current_page AS reading_current_page,
|
brp.current_page AS reading_current_page,
|
||||||
@@ -290,6 +296,10 @@ pub async fn get_book(
|
|||||||
reading_status: row.get("reading_status"),
|
reading_status: row.get("reading_status"),
|
||||||
reading_current_page: row.get("reading_current_page"),
|
reading_current_page: row.get("reading_current_page"),
|
||||||
reading_last_read_at: row.get("reading_last_read_at"),
|
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")),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,6 +971,12 @@ pub struct UpdateBookRequest {
|
|||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
pub volume: Option<i32>,
|
pub volume: Option<i32>,
|
||||||
pub language: Option<String>,
|
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
|
/// Update metadata for a specific book
|
||||||
@@ -996,12 +1012,18 @@ pub async fn update_book(
|
|||||||
let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
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 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(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE books
|
UPDATE books
|
||||||
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7, updated_at = NOW()
|
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
|
WHERE id = $1
|
||||||
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
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,
|
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 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
|
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
||||||
@@ -1014,6 +1036,10 @@ pub async fn update_book(
|
|||||||
.bind(&series)
|
.bind(&series)
|
||||||
.bind(body.volume)
|
.bind(body.volume)
|
||||||
.bind(&language)
|
.bind(&language)
|
||||||
|
.bind(&summary)
|
||||||
|
.bind(&isbn)
|
||||||
|
.bind(&publish_date)
|
||||||
|
.bind(&locked_fields)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1038,6 +1064,10 @@ pub async fn update_book(
|
|||||||
reading_status: row.get("reading_status"),
|
reading_status: row.get("reading_status"),
|
||||||
reading_current_page: row.get("reading_current_page"),
|
reading_current_page: row.get("reading_current_page"),
|
||||||
reading_last_read_at: row.get("reading_last_read_at"),
|
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),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,9 +1078,12 @@ pub struct SeriesMetadata {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub publishers: Vec<String>,
|
pub publishers: Vec<String>,
|
||||||
pub start_year: Option<i32>,
|
pub start_year: Option<i32>,
|
||||||
|
pub total_volumes: Option<i32>,
|
||||||
/// Convenience: author from first book (for pre-filling the per-book apply section)
|
/// Convenience: author from first book (for pre-filling the per-book apply section)
|
||||||
pub book_author: Option<String>,
|
pub book_author: Option<String>,
|
||||||
pub book_language: Option<String>,
|
pub book_language: Option<String>,
|
||||||
|
/// Fields locked from external metadata sync, e.g. {"authors": true, "description": true}
|
||||||
|
pub locked_fields: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get metadata for a specific series
|
/// Get metadata for a specific series
|
||||||
@@ -1087,7 +1120,7 @@ pub async fn get_series_metadata(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let meta_row = sqlx::query(
|
let meta_row = sqlx::query(
|
||||||
"SELECT authors, description, publishers, start_year FROM series_metadata WHERE library_id = $1 AND name = $2"
|
"SELECT authors, description, publishers, start_year, total_volumes, locked_fields FROM series_metadata WHERE library_id = $1 AND name = $2"
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
@@ -1099,8 +1132,10 @@ pub async fn get_series_metadata(
|
|||||||
description: meta_row.as_ref().and_then(|r| r.get("description")),
|
description: meta_row.as_ref().and_then(|r| r.get("description")),
|
||||||
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
|
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
|
||||||
start_year: meta_row.as_ref().and_then(|r| r.get("start_year")),
|
start_year: meta_row.as_ref().and_then(|r| r.get("start_year")),
|
||||||
|
total_volumes: meta_row.as_ref().and_then(|r| r.get("total_volumes")),
|
||||||
book_author: books_row.as_ref().and_then(|r| r.get("author")),
|
book_author: books_row.as_ref().and_then(|r| r.get("author")),
|
||||||
book_language: books_row.as_ref().and_then(|r| r.get("language")),
|
book_language: books_row.as_ref().and_then(|r| r.get("language")),
|
||||||
|
locked_fields: meta_row.as_ref().map(|r| r.get::<serde_json::Value, _>("locked_fields")).unwrap_or(serde_json::json!({})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,6 +1157,10 @@ pub struct UpdateSeriesRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub publishers: Vec<String>,
|
pub publishers: Vec<String>,
|
||||||
pub start_year: Option<i32>,
|
pub start_year: Option<i32>,
|
||||||
|
pub total_volumes: Option<i32>,
|
||||||
|
/// Fields locked from external metadata sync
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked_fields: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -1214,15 +1253,18 @@ pub async fn update_series(
|
|||||||
.map(|a| a.trim().to_string())
|
.map(|a| a.trim().to_string())
|
||||||
.filter(|a| !a.is_empty())
|
.filter(|a| !a.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, updated_at)
|
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, locked_fields, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
ON CONFLICT (library_id, name) DO UPDATE
|
ON CONFLICT (library_id, name) DO UPDATE
|
||||||
SET authors = EXCLUDED.authors,
|
SET authors = EXCLUDED.authors,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
publishers = EXCLUDED.publishers,
|
publishers = EXCLUDED.publishers,
|
||||||
start_year = EXCLUDED.start_year,
|
start_year = EXCLUDED.start_year,
|
||||||
|
total_volumes = EXCLUDED.total_volumes,
|
||||||
|
locked_fields = EXCLUDED.locked_fields,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
@@ -1232,6 +1274,8 @@ pub async fn update_series(
|
|||||||
.bind(&description)
|
.bind(&description)
|
||||||
.bind(&publishers)
|
.bind(&publishers)
|
||||||
.bind(body.start_year)
|
.bind(body.start_year)
|
||||||
|
.bind(body.total_volumes)
|
||||||
|
.bind(&locked_fields)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct LibraryResponse {
|
|||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
pub watcher_enabled: bool,
|
pub watcher_enabled: bool,
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -45,8 +46,8 @@ pub struct CreateLibraryRequest {
|
|||||||
)]
|
)]
|
||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled,
|
"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,
|
||||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
||||||
FROM libraries l ORDER BY l.created_at DESC"
|
FROM libraries l ORDER BY l.created_at DESC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -64,6 +65,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
|||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ pub async fn create_library(
|
|||||||
scan_mode: "manual".to_string(),
|
scan_mode: "manual".to_string(),
|
||||||
next_scan_at: None,
|
next_scan_at: None,
|
||||||
watcher_enabled: false,
|
watcher_enabled: false,
|
||||||
|
metadata_provider: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +284,7 @@ pub async fn update_monitoring(
|
|||||||
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||||
|
|
||||||
let result = sqlx::query(
|
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 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(input.monitor_enabled)
|
.bind(input.monitor_enabled)
|
||||||
@@ -310,5 +313,66 @@ pub async fn update_monitoring(
|
|||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateMetadataProviderRequest {
|
||||||
|
pub 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 result = sqlx::query(
|
||||||
|
"UPDATE libraries SET metadata_provider = $2 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(provider)
|
||||||
|
.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?;
|
||||||
|
|
||||||
|
Ok(Json(LibraryResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
root_path: row.get("root_path"),
|
||||||
|
enabled: row.get("enabled"),
|
||||||
|
book_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"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ mod handlers;
|
|||||||
mod index_jobs;
|
mod index_jobs;
|
||||||
mod komga;
|
mod komga;
|
||||||
mod libraries;
|
mod libraries;
|
||||||
|
mod metadata;
|
||||||
|
mod metadata_providers;
|
||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
@@ -83,6 +85,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/libraries/:id", delete(libraries::delete_library))
|
.route("/libraries/:id", delete(libraries::delete_library))
|
||||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||||
|
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
|
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
|
||||||
@@ -102,6 +105,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
|
.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))
|
||||||
.merge(settings::settings_routes())
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|||||||
1010
apps/api/src/metadata.rs
Normal file
1010
apps/api/src/metadata.rs
Normal file
File diff suppressed because it is too large
Load Diff
322
apps/api/src/metadata_providers/anilist.rs
Normal file
322
apps/api/src/metadata_providers/anilist.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
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 }
|
||||||
|
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 }
|
||||||
|
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())? 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 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);
|
||||||
|
|
||||||
|
Some(SeriesCandidate {
|
||||||
|
external_id: id.to_string(),
|
||||||
|
title,
|
||||||
|
authors,
|
||||||
|
description,
|
||||||
|
publishers: vec![],
|
||||||
|
start_year,
|
||||||
|
total_volumes: volumes,
|
||||||
|
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 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 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 volume entries if volumes count is known
|
||||||
|
let mut books = Vec::new();
|
||||||
|
if let Some(total) = volumes {
|
||||||
|
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!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single entry for the whole manga
|
||||||
|
books.push(BookCandidate {
|
||||||
|
external_book_id: external_id.to_string(),
|
||||||
|
title,
|
||||||
|
volume_number: Some(1),
|
||||||
|
authors,
|
||||||
|
isbn: None,
|
||||||
|
summary: description,
|
||||||
|
cover_url,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
576
apps/api/src/metadata_providers/bedetheque.rs
Normal file
576
apps/api/src/metadata_providers/bedetheque.rs
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Description: look for #full-commentaire or .serie-info
|
||||||
|
if let Ok(sel) = Selector::parse("#full-commentaire") {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
let text = el.text().collect::<String>().trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
details.description = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback description from span.infoedition
|
||||||
|
if details.description.is_none() {
|
||||||
|
if let Ok(sel) = Selector::parse("span.infoedition") {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
let text = el.text().collect::<String>().trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
details.description = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract authors and publishers from album info blocks
|
||||||
|
if let Ok(sel) = Selector::parse(".infos li") {
|
||||||
|
let mut authors_set = std::collections::HashSet::new();
|
||||||
|
let mut publishers_set = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for li in doc.select(&sel) {
|
||||||
|
let text = li.text().collect::<String>();
|
||||||
|
let text = text.trim();
|
||||||
|
|
||||||
|
if let Some(val) = extract_info_value(text, "Scénario") {
|
||||||
|
for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
authors_set.insert(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(val) = extract_info_value(text, "Dessin") {
|
||||||
|
for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
authors_set.insert(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(val) = extract_info_value(text, "Editeur") {
|
||||||
|
for p in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
publishers_set.insert(p.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
details.authors = authors_set.into_iter().collect();
|
||||||
|
details.authors.sort();
|
||||||
|
details.publishers = publishers_set.into_iter().collect();
|
||||||
|
details.publishers.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album count from serie-info text (e.g. "Tomes : 8")
|
||||||
|
let page_text = doc.root_element().text().collect::<String>();
|
||||||
|
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 album date (Dépot légal)
|
||||||
|
if let Ok(re) = regex::Regex::new(r"[Dd][ée]p[ôo]t l[ée]gal\s*:\s*\d{2}/(\d{4})") {
|
||||||
|
if let Some(caps) = re.captures(&page_text) {
|
||||||
|
if let Ok(year) = caps[1].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();
|
||||||
|
|
||||||
|
// Albums are in .album-main blocks
|
||||||
|
let album_sel = Selector::parse(".album-main").map_err(|e| format!("selector: {e}"))?;
|
||||||
|
|
||||||
|
for album_el in doc.select(&album_sel) {
|
||||||
|
let album_html = album_el.html();
|
||||||
|
let album_doc = Html::parse_fragment(&album_html);
|
||||||
|
|
||||||
|
// Title from .titre
|
||||||
|
let title = select_text(&album_doc, ".titre")
|
||||||
|
.or_else(|| {
|
||||||
|
Selector::parse(".titre a")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_doc.select(&s).next())
|
||||||
|
.map(|el| el.text().collect::<String>().trim().to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if title.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume number from title or .num span
|
||||||
|
let volume_number = select_text(&album_doc, ".num")
|
||||||
|
.and_then(|s| {
|
||||||
|
s.trim_end_matches('.')
|
||||||
|
.trim()
|
||||||
|
.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.or_else(|| extract_volume_from_title(&title));
|
||||||
|
|
||||||
|
// Album URL
|
||||||
|
let album_url = Selector::parse("a[href*='/BD-']")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_doc.select(&s).next())
|
||||||
|
.and_then(|el| el.value().attr("href"))
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
// External book id from URL
|
||||||
|
let external_book_id = album_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|u| {
|
||||||
|
regex::Regex::new(r"-(\d+)\.html")
|
||||||
|
.ok()
|
||||||
|
.and_then(|re| re.captures(u))
|
||||||
|
.map(|c| c[1].to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Cover
|
||||||
|
let cover_url = Selector::parse("img[src*='cache/thb_couv']")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_doc.select(&s).next())
|
||||||
|
.and_then(|el| el.value().attr("src"))
|
||||||
|
.map(|s| {
|
||||||
|
if s.starts_with("http") {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://www.bedetheque.com{}", s)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract info fields
|
||||||
|
let album_text = album_el.text().collect::<String>();
|
||||||
|
let authors = extract_all_authors(&album_text);
|
||||||
|
let isbn = extract_info_value(&album_text, "EAN/ISBN")
|
||||||
|
.or_else(|| extract_info_value(&album_text, "ISBN"))
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
let page_count = extract_info_value(&album_text, "Planches")
|
||||||
|
.and_then(|s| s.trim().parse::<i32>().ok());
|
||||||
|
let publish_date = extract_info_value(&album_text, "Dépot légal")
|
||||||
|
.or_else(|| extract_info_value(&album_text, "Depot legal"))
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_text(doc: &Html, selector: &str) -> Option<String> {
|
||||||
|
Selector::parse(selector)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| doc.select(&s).next())
|
||||||
|
.map(|el| el.text().collect::<String>().trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_all_authors(text: &str) -> Vec<String> {
|
||||||
|
let mut authors = Vec::new();
|
||||||
|
for label in ["Scénario", "Scenario", "Dessin"] {
|
||||||
|
if let Some(val) = extract_info_value(text, label) {
|
||||||
|
for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
if !authors.contains(&a.to_string()) {
|
||||||
|
authors.push(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authors
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.85
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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())? as i64;
|
||||||
|
let description = vol
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|d| strip_html(d));
|
||||||
|
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())? 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(|d| strip_html(d));
|
||||||
|
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(|item| volume_to_book_candidate(item))
|
||||||
|
.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,
|
||||||
|
}
|
||||||
81
apps/api/src/metadata_providers/mod.rs
Normal file
81
apps/api/src/metadata_providers/mod.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
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.map_or(false, |y| entry.start_year.unwrap() > y) {
|
||||||
|
if 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>,
|
||||||
|
}
|
||||||
@@ -46,6 +46,13 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::clear_cache,
|
crate::settings::clear_cache,
|
||||||
crate::settings::get_cache_stats,
|
crate::settings::get_cache_stats,
|
||||||
crate::settings::get_thumbnail_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,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -94,6 +101,18 @@ use utoipa::OpenApi;
|
|||||||
crate::stats::LibraryStats,
|
crate::stats::LibraryStats,
|
||||||
crate::stats::TopSeries,
|
crate::stats::TopSeries,
|
||||||
crate::stats::MonthlyAdditions,
|
crate::stats::MonthlyAdditions,
|
||||||
|
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,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { BookPreview } from "../../components/BookPreview";
|
|||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "../../components/ConvertButton";
|
||||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||||
import { EditBookForm } from "../../components/EditBookForm";
|
import { EditBookForm } from "../../components/EditBookForm";
|
||||||
|
import { SafeHtml } from "../../components/SafeHtml";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -15,31 +16,6 @@ const readingStatusConfig: Record<ReadingStatus, { label: string; className: str
|
|||||||
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
|
read: { label: "Lu", className: "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> {
|
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<BookDto>(`/books/${bookId}`);
|
return await apiFetch<BookDto>(`/books/${bookId}`);
|
||||||
@@ -64,163 +40,195 @@ export default async function BookDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const library = libraries.find(l => l.id === book.library_id);
|
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 { label: statusLabel, className: statusClassName } = readingStatusConfig[book.reading_status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
{/* Breadcrumb */}
|
||||||
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
← Back to books
|
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Libraries
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
<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="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
|
<Image
|
||||||
src={getBookCoverUrl(book.id)}
|
src={getBookCoverUrl(book.id)}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={`Cover of ${book.title}`}
|
||||||
width={300}
|
fill
|
||||||
height={440}
|
className="object-cover"
|
||||||
className="w-auto h-auto max-w-[300px] rounded-lg"
|
|
||||||
unoptimized
|
unoptimized
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* Info */}
|
||||||
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
||||||
<EditBookForm book={book} />
|
{book.author && (
|
||||||
</div>
|
<p className="text-base text-muted-foreground mt-1">{book.author}</p>
|
||||||
|
|
||||||
{book.author && (
|
|
||||||
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.series && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
|
||||||
{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.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' :
|
|
||||||
'bg-muted/50 text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{(book.format ?? book.kind).toUpperCase()}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
|
|
||||||
</div>
|
|
||||||
</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'
|
|
||||||
}`}>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<EditBookForm book={book} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Series + Volume link */}
|
||||||
|
{book.series && (
|
||||||
|
<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}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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()}
|
||||||
|
</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} pages
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.page_count && book.page_count > 0 && (
|
{/* Technical info (collapsible) */}
|
||||||
<div className="mt-8">
|
<details className="group">
|
||||||
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
<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>
|
||||||
|
Informations techniques
|
||||||
|
</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">Fichier</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">Format fichier</span>
|
||||||
|
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{book.file_parse_status && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">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>
|
||||||
|
)}
|
||||||
|
<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 items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Library ID</span>
|
||||||
|
<code className="font-mono text-foreground">{book.library_id}</code>
|
||||||
|
</div>
|
||||||
|
{book.updated_at && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Mis à jour</span>
|
||||||
|
<span className="text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Book Preview */}
|
||||||
|
{book.page_count && book.page_count > 0 && (
|
||||||
|
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export default async function BooksPage({
|
|||||||
reading_status: "unread" as const,
|
reading_status: "unread" as const,
|
||||||
reading_current_page: null,
|
reading_current_page: null,
|
||||||
reading_last_read_at: null,
|
reading_last_read_at: null,
|
||||||
|
summary: null,
|
||||||
|
isbn: null,
|
||||||
|
publish_date: null,
|
||||||
}));
|
}));
|
||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,40 @@ import { useRouter } from "next/navigation";
|
|||||||
import { BookDto } from "@/lib/api";
|
import { BookDto } from "@/lib/api";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
|
||||||
|
function LockButton({
|
||||||
|
locked,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
locked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
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 ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
|
||||||
|
>
|
||||||
|
{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 {
|
interface EditBookFormProps {
|
||||||
book: BookDto;
|
book: BookDto;
|
||||||
}
|
}
|
||||||
@@ -23,6 +57,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
const [series, setSeries] = useState(book.series ?? "");
|
const [series, setSeries] = useState(book.series ?? "");
|
||||||
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
|
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
|
||||||
const [language, setLanguage] = useState(book.language ?? "");
|
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 addAuthor = () => {
|
||||||
const v = authorInput.trim();
|
const v = authorInput.trim();
|
||||||
@@ -51,6 +93,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
setSeries(book.series ?? "");
|
setSeries(book.series ?? "");
|
||||||
setVolume(book.volume?.toString() ?? "");
|
setVolume(book.volume?.toString() ?? "");
|
||||||
setLanguage(book.language ?? "");
|
setLanguage(book.language ?? "");
|
||||||
|
setSummary(book.summary ?? "");
|
||||||
|
setIsbn(book.isbn ?? "");
|
||||||
|
setPublishDate(book.publish_date ?? "");
|
||||||
|
setLockedFields(book.locked_fields ?? {});
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [book]);
|
}, [book]);
|
||||||
@@ -85,6 +131,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
series: series.trim() || null,
|
series: series.trim() || null,
|
||||||
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
|
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
|
||||||
language: language.trim() || null,
|
language: language.trim() || null,
|
||||||
|
summary: summary.trim() || null,
|
||||||
|
isbn: isbn.trim() || null,
|
||||||
|
publish_date: publishDate.trim() || null,
|
||||||
|
locked_fields: lockedFields,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -130,7 +180,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel required>Titre</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel required>Titre</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
@@ -141,7 +194,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
|
|
||||||
{/* Auteurs — multi-valeur */}
|
{/* Auteurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Auteur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Auteur(s)</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{authors.length > 0 && (
|
{authors.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -187,7 +243,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Langue</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Langue</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
@@ -197,7 +256,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Série</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Série</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={series}
|
value={series}
|
||||||
onChange={(e) => setSeries(e.target.value)}
|
onChange={(e) => setSeries(e.target.value)}
|
||||||
@@ -207,7 +269,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Volume</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Volume</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -217,8 +282,59 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
placeholder="Numéro de volume"
|
placeholder="Numéro de volume"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>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>Date de publication</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="ex : 2023-01-15"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField className="sm:col-span-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Résumé / description du livre"
|
||||||
|
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>
|
</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>
|
||||||
|
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ import { createPortal } from "react-dom";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
|
||||||
|
function LockButton({
|
||||||
|
locked,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
locked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
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 ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
|
||||||
|
>
|
||||||
|
{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 EditSeriesFormProps {
|
interface EditSeriesFormProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
seriesName: string;
|
seriesName: string;
|
||||||
@@ -14,6 +48,8 @@ interface EditSeriesFormProps {
|
|||||||
currentBookLanguage: string | null;
|
currentBookLanguage: string | null;
|
||||||
currentDescription: string | null;
|
currentDescription: string | null;
|
||||||
currentStartYear: number | null;
|
currentStartYear: number | null;
|
||||||
|
currentTotalVolumes: number | null;
|
||||||
|
currentLockedFields: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditSeriesForm({
|
export function EditSeriesForm({
|
||||||
@@ -25,6 +61,8 @@ export function EditSeriesForm({
|
|||||||
currentBookLanguage,
|
currentBookLanguage,
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentStartYear,
|
currentStartYear,
|
||||||
|
currentTotalVolumes,
|
||||||
|
currentLockedFields,
|
||||||
}: EditSeriesFormProps) {
|
}: EditSeriesFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -41,12 +79,20 @@ export function EditSeriesForm({
|
|||||||
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
||||||
const [description, setDescription] = useState(currentDescription ?? "");
|
const [description, setDescription] = useState(currentDescription ?? "");
|
||||||
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
||||||
|
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
|
||||||
|
|
||||||
|
// Lock states
|
||||||
|
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
|
||||||
|
|
||||||
// Propagation aux livres — opt-in via bouton
|
// Propagation aux livres — opt-in via bouton
|
||||||
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
||||||
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
||||||
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
||||||
|
|
||||||
|
const toggleLock = (field: string) => {
|
||||||
|
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||||
|
};
|
||||||
|
|
||||||
const addAuthor = () => {
|
const addAuthor = () => {
|
||||||
const v = authorInput.trim();
|
const v = authorInput.trim();
|
||||||
if (v && !authors.includes(v)) {
|
if (v && !authors.includes(v)) {
|
||||||
@@ -95,12 +141,14 @@ export function EditSeriesForm({
|
|||||||
setPublisherInput("");
|
setPublisherInput("");
|
||||||
setDescription(currentDescription ?? "");
|
setDescription(currentDescription ?? "");
|
||||||
setStartYear(currentStartYear?.toString() ?? "");
|
setStartYear(currentStartYear?.toString() ?? "");
|
||||||
|
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
|
||||||
|
setLockedFields(currentLockedFields);
|
||||||
setShowApplyToBooks(false);
|
setShowApplyToBooks(false);
|
||||||
setBookAuthor(currentBookAuthor ?? "");
|
setBookAuthor(currentBookAuthor ?? "");
|
||||||
setBookLanguage(currentBookLanguage ?? "");
|
setBookLanguage(currentBookLanguage ?? "");
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentBookAuthor, currentBookLanguage]);
|
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -133,6 +181,8 @@ export function EditSeriesForm({
|
|||||||
publishers: finalPublishers,
|
publishers: finalPublishers,
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
||||||
|
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
|
||||||
|
locked_fields: lockedFields,
|
||||||
};
|
};
|
||||||
if (showApplyToBooks) {
|
if (showApplyToBooks) {
|
||||||
body.author = bookAuthor.trim() || null;
|
body.author = bookAuthor.trim() || null;
|
||||||
@@ -205,7 +255,10 @@ export function EditSeriesForm({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Année de début</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Année de début</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1900"
|
min="1900"
|
||||||
@@ -217,9 +270,27 @@ export function EditSeriesForm({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Nombre de volumes</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="ex : 12"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
{/* Auteurs — multi-valeur */}
|
{/* Auteurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Auteur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Auteur(s)</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{authors.length > 0 && (
|
{authors.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -302,7 +373,10 @@ export function EditSeriesForm({
|
|||||||
|
|
||||||
{/* Éditeurs — multi-valeur */}
|
{/* Éditeurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Éditeur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Éditeur(s)</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{publishers.length > 0 && (
|
{publishers.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -348,7 +422,10 @@ export function EditSeriesForm({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Description</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -360,6 +437,16 @@ export function EditSeriesForm({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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>
|
||||||
|
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -2,21 +2,24 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
import { Button } from "../components/ui";
|
import { Button } from "../components/ui";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
|
|
||||||
interface LibraryActionsProps {
|
interface LibraryActionsProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
monitorEnabled: boolean;
|
monitorEnabled: boolean;
|
||||||
scanMode: string;
|
scanMode: string;
|
||||||
watcherEnabled: boolean;
|
watcherEnabled: boolean;
|
||||||
|
metadataProvider: string | null;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryActions({
|
export function LibraryActions({
|
||||||
libraryId,
|
libraryId,
|
||||||
monitorEnabled,
|
monitorEnabled,
|
||||||
scanMode,
|
scanMode,
|
||||||
watcherEnabled,
|
watcherEnabled,
|
||||||
onUpdate
|
metadataProvider,
|
||||||
|
onUpdate
|
||||||
}: LibraryActionsProps) {
|
}: LibraryActionsProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -39,17 +42,25 @@ export function LibraryActions({
|
|||||||
const monitorEnabled = formData.get("monitor_enabled") === "true";
|
const monitorEnabled = formData.get("monitor_enabled") === "true";
|
||||||
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
||||||
const scanMode = formData.get("scan_mode") as string;
|
const scanMode = formData.get("scan_mode") as string;
|
||||||
|
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/libraries/${libraryId}/monitoring`, {
|
const [response] = await Promise.all([
|
||||||
method: "PATCH",
|
fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
headers: { "Content-Type": "application/json" },
|
||||||
monitor_enabled: monitorEnabled,
|
body: JSON.stringify({
|
||||||
scan_mode: scanMode,
|
monitor_enabled: monitorEnabled,
|
||||||
watcher_enabled: watcherEnabled,
|
scan_mode: scanMode,
|
||||||
|
watcher_enabled: watcherEnabled,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ metadata_provider: newMetadataProvider }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -126,6 +137,25 @@ export function LibraryActions({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
|
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||||
|
Metadata Provider
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="metadata_provider"
|
||||||
|
defaultValue={metadataProvider || ""}
|
||||||
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
|
>
|
||||||
|
<option value="">Default</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>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
||||||
{saveError}
|
{saveError}
|
||||||
|
|||||||
671
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
671
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
description: "Description",
|
||||||
|
authors: "Auteurs",
|
||||||
|
publishers: "Éditeurs",
|
||||||
|
start_year: "Année",
|
||||||
|
total_volumes: "Nb volumes",
|
||||||
|
summary: "Résumé",
|
||||||
|
isbn: "ISBN",
|
||||||
|
publish_date: "Date de publication",
|
||||||
|
language: "Langue",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldLabel(field: string): string {
|
||||||
|
return FIELD_LABELS[field] ?? field;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 router = useRouter();
|
||||||
|
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 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 || "Search failed");
|
||||||
|
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("Network error");
|
||||||
|
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 || "Failed to create match");
|
||||||
|
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 || "Failed to approve");
|
||||||
|
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("Network error");
|
||||||
|
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" ? "Metadata Link" : "Search External Metadata"}
|
||||||
|
</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">Provider :</label>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{PROVIDERS.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">Searching for "{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">No results found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{candidates.length} result{candidates.length !== 1 ? "s" : ""} found
|
||||||
|
{activeProvider && (
|
||||||
|
<span className="ml-1 text-xs inline-flex items-center gap-1">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} vol.</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 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedCandidate.total_volumes} volumes</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
|
||||||
|
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">How would you like to sync?</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">Sync series metadata only</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</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">Sync series + books</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Back to results
|
||||||
|
</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">Syncing metadata...</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">Metadata synced successfully!</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">Série</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">locked</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books report */}
|
||||||
|
{(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">
|
||||||
|
Livres — {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
|
||||||
|
</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">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">External</p>
|
||||||
|
<p className="text-2xl font-semibold">{missing.total_external}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Local</p>
|
||||||
|
<p className="text-2xl font-semibold">{missing.total_local}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Missing</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" />
|
||||||
|
{missing.missing_count} missing book{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 || "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"
|
||||||
|
>
|
||||||
|
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">
|
||||||
|
Linked to <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"
|
||||||
|
>
|
||||||
|
View on external source
|
||||||
|
</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">External</p>
|
||||||
|
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Local</p>
|
||||||
|
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Missing</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" />
|
||||||
|
{initialMissing.missing_count} missing book{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 || "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"
|
||||||
|
>
|
||||||
|
Search again
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
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" ? "Metadata" : "Search metadata"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Inline badge when linked */}
|
||||||
|
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
|
||||||
|
{initialMissing.missing_count} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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("_", " ");
|
||||||
|
}
|
||||||
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) },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, BookDto, SeriesMetadataDto } from "../../../../../lib/api";
|
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
||||||
|
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
||||||
import { OffsetPagination } from "../../../../components/ui";
|
import { OffsetPagination } from "../../../../components/ui";
|
||||||
|
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -24,7 +26,7 @@ export default async function SeriesDetailPage({
|
|||||||
|
|
||||||
const seriesName = decodeURIComponent(name);
|
const seriesName = decodeURIComponent(name);
|
||||||
|
|
||||||
const [library, booksPage, seriesMeta] = await Promise.all([
|
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
|
||||||
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
@@ -33,8 +35,15 @@ export default async function SeriesDetailPage({
|
|||||||
limit,
|
limit,
|
||||||
})),
|
})),
|
||||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||||
|
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
|
||||||
|
let missingData: MissingBooksDto | null = null;
|
||||||
|
if (existingLink && existingLink.status === "approved") {
|
||||||
|
missingData = await getMissingBooks(existingLink.id).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!library) {
|
if (!library) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -96,7 +105,7 @@ export default async function SeriesDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{seriesMeta?.description && (
|
{seriesMeta?.description && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{seriesMeta.description}</p>
|
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
@@ -143,6 +152,14 @@ export default async function SeriesDetailPage({
|
|||||||
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
|
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
|
||||||
currentDescription={seriesMeta?.description ?? null}
|
currentDescription={seriesMeta?.description ?? null}
|
||||||
currentStartYear={seriesMeta?.start_year ?? null}
|
currentStartYear={seriesMeta?.start_year ?? null}
|
||||||
|
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
|
||||||
|
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
||||||
|
/>
|
||||||
|
<MetadataSearchModal
|
||||||
|
libraryId={id}
|
||||||
|
seriesName={seriesName}
|
||||||
|
existingLink={existingLink}
|
||||||
|
initialMissing={missingData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default async function LibrariesPage() {
|
|||||||
monitorEnabled={lib.monitor_enabled}
|
monitorEnabled={lib.monitor_enabled}
|
||||||
scanMode={lib.scan_mode}
|
scanMode={lib.scan_mode}
|
||||||
watcherEnabled={lib.watcher_enabled}
|
watcherEnabled={lib.watcher_enabled}
|
||||||
|
metadataProvider={lib.metadata_provider}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
|
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
@@ -550,6 +551,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{activeTab === "integrations" && (<>
|
{activeTab === "integrations" && (<>
|
||||||
|
{/* Metadata Providers */}
|
||||||
|
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
|
||||||
{/* Komga Sync */}
|
{/* Komga Sync */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -793,3 +797,170 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metadata Providers sub-component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const METADATA_LANGUAGES = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "es", label: "Español" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||||
|
const [defaultProvider, setDefaultProvider] = useState("google_books");
|
||||||
|
const [metadataLanguage, setMetadataLanguage] = useState("en");
|
||||||
|
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/metadata_providers")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
if (data.default_provider) setDefaultProvider(data.default_provider);
|
||||||
|
if (data.metadata_language) setMetadataLanguage(data.metadata_language);
|
||||||
|
if (data.comicvine?.api_key) setApiKeys((prev) => ({ ...prev, comicvine: data.comicvine.api_key }));
|
||||||
|
if (data.google_books?.api_key) setApiKeys((prev) => ({ ...prev, google_books: data.google_books.api_key }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function save(provider: string, lang: string, keys: Record<string, string>) {
|
||||||
|
const value: Record<string, unknown> = {
|
||||||
|
default_provider: provider,
|
||||||
|
metadata_language: lang,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(keys)) {
|
||||||
|
if (v) value[k] = { api_key: v };
|
||||||
|
}
|
||||||
|
handleUpdateSetting("metadata_providers", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="search" size="md" />
|
||||||
|
Metadata Providers
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure external metadata providers for series/book enrichment. Each library can override the default provider. All providers are available for quick-search in the metadata modal.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Default provider */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-2 block">Default Provider</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{([
|
||||||
|
{ 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).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDefaultProvider(p.value);
|
||||||
|
save(p.value, metadataLanguage, apiKeys);
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
defaultProvider === 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={18} />
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Used by default for metadata search. Libraries can override this individually.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata language */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-2 block">Metadata Language</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{METADATA_LANGUAGES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMetadataLanguage(l.value);
|
||||||
|
save(defaultProvider, l.value, apiKeys);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
metadataLanguage === l.value
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Preferred language for search results and descriptions. Fallback: English.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider API keys — always visible */}
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground mb-3">API Keys</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
|
||||||
|
<ProviderIcon provider="google_books" size={16} />
|
||||||
|
Google Books API Key
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
type="password"
|
||||||
|
placeholder="Optional — for higher rate limits"
|
||||||
|
value={apiKeys.google_books || ""}
|
||||||
|
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
||||||
|
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Works without a key but with lower rate limits.</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
|
||||||
|
<ProviderIcon provider="comicvine" size={16} />
|
||||||
|
ComicVine API Key
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
type="password"
|
||||||
|
placeholder="Required to use ComicVine"
|
||||||
|
value={apiKeys.comicvine || ""}
|
||||||
|
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
||||||
|
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Get your key at <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-muted/30 flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ProviderIcon provider="open_library" size={16} />
|
||||||
|
<span className="text-xs font-medium text-foreground">Open Library</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">,</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ProviderIcon provider="anilist" size={16} />
|
||||||
|
<span className="text-xs font-medium text-foreground">AniList</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">and</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ProviderIcon provider="bedetheque" size={16} />
|
||||||
|
<span className="text-xs font-medium text-foreground">Bédéthèque</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">are free and require no API key.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type LibraryDto = {
|
|||||||
scan_mode: string;
|
scan_mode: string;
|
||||||
next_scan_at: string | null;
|
next_scan_at: string | null;
|
||||||
watcher_enabled: boolean;
|
watcher_enabled: boolean;
|
||||||
|
metadata_provider: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IndexJobDto = {
|
export type IndexJobDto = {
|
||||||
@@ -74,6 +75,10 @@ export type BookDto = {
|
|||||||
reading_status: ReadingStatus;
|
reading_status: ReadingStatus;
|
||||||
reading_current_page: number | null;
|
reading_current_page: number | null;
|
||||||
reading_last_read_at: string | null;
|
reading_last_read_at: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
isbn: string | null;
|
||||||
|
publish_date: string | null;
|
||||||
|
locked_fields?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BooksPageDto = {
|
export type BooksPageDto = {
|
||||||
@@ -492,6 +497,10 @@ export type UpdateBookRequest = {
|
|||||||
series: string | null;
|
series: string | null;
|
||||||
volume: number | null;
|
volume: number | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
isbn: string | null;
|
||||||
|
publish_date: string | null;
|
||||||
|
locked_fields?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function updateBook(bookId: string, data: UpdateBookRequest) {
|
export async function updateBook(bookId: string, data: UpdateBookRequest) {
|
||||||
@@ -506,8 +515,10 @@ export type SeriesMetadataDto = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
publishers: string[];
|
publishers: string[];
|
||||||
start_year: number | null;
|
start_year: number | null;
|
||||||
|
total_volumes: number | null;
|
||||||
book_author: string | null;
|
book_author: string | null;
|
||||||
book_language: string | null;
|
book_language: string | null;
|
||||||
|
locked_fields: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
|
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
|
||||||
@@ -524,6 +535,8 @@ export type UpdateSeriesRequest = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
publishers: string[];
|
publishers: string[];
|
||||||
start_year: number | null;
|
start_year: number | null;
|
||||||
|
total_volumes: number | null;
|
||||||
|
locked_fields?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) {
|
export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) {
|
||||||
@@ -584,3 +597,136 @@ export async function listKomgaReports() {
|
|||||||
export async function getKomgaReport(id: string) {
|
export async function getKomgaReport(id: string) {
|
||||||
return apiFetch<KomgaSyncResponse>(`/komga/reports/${id}`);
|
return apiFetch<KomgaSyncResponse>(`/komga/reports/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// External Metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type SeriesCandidateDto = {
|
||||||
|
provider: string;
|
||||||
|
external_id: string;
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
description: string | null;
|
||||||
|
publishers: string[];
|
||||||
|
start_year: number | null;
|
||||||
|
total_volumes: number | null;
|
||||||
|
cover_url: string | null;
|
||||||
|
external_url: string | null;
|
||||||
|
confidence: number;
|
||||||
|
metadata_json: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExternalMetadataLinkDto = {
|
||||||
|
id: string;
|
||||||
|
library_id: string;
|
||||||
|
series_name: string;
|
||||||
|
provider: string;
|
||||||
|
external_id: string;
|
||||||
|
external_url: string | null;
|
||||||
|
status: string;
|
||||||
|
confidence: number | null;
|
||||||
|
metadata_json: Record<string, unknown>;
|
||||||
|
total_volumes_external: number | null;
|
||||||
|
matched_at: string;
|
||||||
|
approved_at: string | null;
|
||||||
|
synced_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldChange = {
|
||||||
|
field: string;
|
||||||
|
old_value?: unknown;
|
||||||
|
new_value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeriesSyncReport = {
|
||||||
|
fields_updated: FieldChange[];
|
||||||
|
fields_skipped: FieldChange[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookSyncReport = {
|
||||||
|
book_id: string;
|
||||||
|
title: string;
|
||||||
|
volume: number | null;
|
||||||
|
fields_updated: FieldChange[];
|
||||||
|
fields_skipped: FieldChange[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncReport = {
|
||||||
|
series: SeriesSyncReport | null;
|
||||||
|
books: BookSyncReport[];
|
||||||
|
books_matched: number;
|
||||||
|
books_unmatched: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MissingBooksDto = {
|
||||||
|
total_external: number;
|
||||||
|
total_local: number;
|
||||||
|
missing_count: number;
|
||||||
|
missing_books: {
|
||||||
|
title: string | null;
|
||||||
|
volume_number: number | null;
|
||||||
|
external_book_id: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchMetadata(libraryId: string, seriesName: string, provider?: string) {
|
||||||
|
return apiFetch<SeriesCandidateDto[]>("/metadata/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ library_id: libraryId, series_name: seriesName, provider: provider || undefined }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMetadataMatch(data: {
|
||||||
|
library_id: string;
|
||||||
|
series_name: string;
|
||||||
|
provider: string;
|
||||||
|
external_id: string;
|
||||||
|
external_url?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
title: string;
|
||||||
|
metadata_json: Record<string, unknown>;
|
||||||
|
total_volumes?: number | null;
|
||||||
|
}) {
|
||||||
|
return apiFetch<ExternalMetadataLinkDto>("/metadata/match", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveMetadataMatch(id: string, syncSeries: boolean, syncBooks: boolean) {
|
||||||
|
return apiFetch<{ status: string; report: SyncReport }>(`/metadata/approve/${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sync_series: syncSeries, sync_books: syncBooks }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectMetadataMatch(id: string) {
|
||||||
|
return apiFetch<{ status: string }>(`/metadata/reject/${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetadataLink(libraryId: string, seriesName: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("library_id", libraryId);
|
||||||
|
params.set("series_name", seriesName);
|
||||||
|
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMissingBooks(linkId: string) {
|
||||||
|
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMetadataLink(id: string) {
|
||||||
|
return apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLibraryMetadataProvider(libraryId: string, provider: string | null) {
|
||||||
|
return apiFetch<LibraryDto>(`/libraries/${libraryId}/metadata-provider`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ metadata_provider: provider }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
188
apps/backoffice/package-lock.json
generated
188
apps/backoffice/package-lock.json
generated
@@ -1,23 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "0.1.0",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "0.1.0",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"sanitize-html": "^2.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
@@ -1079,6 +1081,49 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sanitize-html": {
|
||||||
|
"version": "2.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||||
|
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"htmlparser2": "^10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/sanitize-html/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
@@ -1195,6 +1240,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1205,6 +1259,61 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.307",
|
"version": "1.5.307",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||||
@@ -1226,6 +1335,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1236,6 +1357,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -1257,6 +1390,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -1666,6 +1827,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-srcset": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1676,7 +1843,6 @@
|
|||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1729,6 +1895,20 @@
|
|||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sanitize-html": {
|
||||||
|
"version": "2.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
|
||||||
|
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"htmlparser2": "^8.0.0",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
|
"postcss": "^8.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
@@ -11,13 +11,15 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"sanitize-html": "^2.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ COPY crates/parsers/src crates/parsers/src
|
|||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
|
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||||
cargo build --release -p indexer && \
|
cargo build --release -p indexer && \
|
||||||
cp /app/target/release/indexer /usr/local/bin/indexer
|
cp /app/target/release/indexer /usr/local/bin/indexer
|
||||||
|
|
||||||
|
|||||||
41
infra/migrations/0028_add_external_metadata.sql
Normal file
41
infra/migrations/0028_add_external_metadata.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
CREATE TABLE external_metadata_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
series_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
external_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
confidence REAL,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}',
|
||||||
|
total_volumes_external INTEGER,
|
||||||
|
matched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (library_id, series_name, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE external_book_metadata (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
link_id UUID NOT NULL REFERENCES external_metadata_links(id) ON DELETE CASCADE,
|
||||||
|
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
|
||||||
|
external_book_id TEXT,
|
||||||
|
volume_number INTEGER,
|
||||||
|
title TEXT,
|
||||||
|
authors TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
isbn TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
page_count INTEGER,
|
||||||
|
language TEXT,
|
||||||
|
publish_date TEXT,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_eml_library_series ON external_metadata_links(library_id, series_name);
|
||||||
|
CREATE INDEX idx_eml_status ON external_metadata_links(status);
|
||||||
|
CREATE INDEX idx_ebm_link_id ON external_book_metadata(link_id);
|
||||||
|
CREATE INDEX idx_ebm_book_id ON external_book_metadata(book_id);
|
||||||
1
infra/migrations/0029_add_library_metadata_provider.sql
Normal file
1
infra/migrations/0029_add_library_metadata_provider.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE libraries ADD COLUMN metadata_provider TEXT;
|
||||||
6
infra/migrations/0030_add_locked_fields.sql
Normal file
6
infra/migrations/0030_add_locked_fields.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add locked_fields to series_metadata and books so that manually edited
|
||||||
|
-- fields can be protected from external-metadata sync overwrites.
|
||||||
|
-- The JSON object maps field names to true, e.g. {"authors": true, "description": true}.
|
||||||
|
|
||||||
|
ALTER TABLE series_metadata ADD COLUMN locked_fields JSONB NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE books ADD COLUMN locked_fields JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE series_metadata ADD COLUMN total_volumes INTEGER;
|
||||||
4
infra/migrations/0032_add_book_metadata_fields.sql
Normal file
4
infra/migrations/0032_add_book_metadata_fields.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add metadata fields to books table for external sync
|
||||||
|
ALTER TABLE books ADD COLUMN summary TEXT;
|
||||||
|
ALTER TABLE books ADD COLUMN isbn TEXT;
|
||||||
|
ALTER TABLE books ADD COLUMN publish_date TEXT;
|
||||||
Reference in New Issue
Block a user