Compare commits

...

29 Commits
0.1.0 ... main

Author SHA1 Message Date
19af0b8d3e
make favicon brighter
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 25m20s
2024-06-27 19:52:02 +02:00
30c9e09039
prepare 0.3.1
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Has been cancelled
2024-06-27 10:58:39 +02:00
4273017c62
add a simple favicon 2024-06-27 10:58:06 +02:00
10f0584e90
reword readme
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 21m2s
2024-06-26 23:47:11 +02:00
cafb81b9e2
doc string for thumbnail generation
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 29m13s
2024-06-26 22:37:05 +02:00
7deb8e5bfc
bring back full covers if needed
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 30m2s
2024-06-26 22:35:03 +02:00
b77b1bc139
implement thumbnail caching 2024-06-26 21:22:07 +02:00
a0c5122735
ensure the search database is attached to all connections 2024-06-26 17:41:04 +02:00
aa47ec7c43
update yanked version of libc
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 26m45s
2024-06-26 16:17:08 +02:00
b8ed5b1cdf
search database has to be a file, as in-memory dbs are per connection
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Has been cancelled
2024-06-26 16:14:58 +02:00
97cf9db9ff
revise search data for missing fields
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 28m27s
2024-06-26 14:46:33 +02:00
40db2c37bb
screenshot of new version
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 19m31s
2024-06-26 14:21:50 +02:00
55d3364b0e
implement html & opds search
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 27m24s
2024-06-26 13:53:00 +02:00
ed8b69de13
do not crash if there are no items to paginate
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 19m41s
2024-06-26 08:45:22 +02:00
c9e7566aee
update all dependencies 2024-06-26 08:10:39 +02:00
672c50e5c5
make listening address configurable
All checks were successful
Build Multiarch Container Image / call-reusable-workflow (push) Successful in 20m20s
2024-06-26 08:06:48 +02:00
191e5b66c3
use urlencode_strict
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 0s
2024-05-12 21:08:39 +02:00
70acfb1230
urlencode cursors, otherwise it fails on # and similar
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 0s
2024-05-12 21:03:46 +02:00
248387b355
prepare 0.1.2 release
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 1s
2024-05-11 14:08:02 +02:00
f4ee1c19ec
make screenshot in readme smaller
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 1s
2024-05-11 09:30:30 +02:00
ee764ca4ca
common download handler 2024-05-11 09:27:33 +02:00
13aae44163
stream covers (chunked encoding) 2024-05-11 09:06:08 +02:00
8f698c0e7d
make screenshot slightly bigger
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 0s
2024-05-11 08:20:00 +02:00
68ab97763b
add screenshot
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 1s
2024-05-11 08:18:49 +02:00
92f298df2e
typo in readme
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 9s
2024-05-11 07:50:08 +02:00
43054212ce
remove musl building with nix for now, clashes with the build script
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 0s
2024-05-10 22:42:14 +02:00
5b7365f458
use deflate for zip, as otherwise we need more native build inputs 2024-05-10 22:32:23 +02:00
fa7babfafb
add own source code as a zip archive
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 0s
2024-05-10 21:57:09 +02:00
64639953f0
add cargo metadata
Some checks failed
Build Multiarch Container Image / call-reusable-workflow (push) Failing after 1s
2024-05-10 21:03:14 +02:00
50 changed files with 1050 additions and 333 deletions

318
Cargo.lock generated
View File

@ -108,6 +108,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "arbitrary"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "autocfg"
version = "1.3.0"
@ -141,12 +150,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.5.0"
@ -178,6 +181,18 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytemuck"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.6.0"
@ -192,6 +207,7 @@ dependencies = [
"r2d2_sqlite",
"rusqlite",
"serde",
"tempfile",
"thiserror",
"time",
]
@ -229,9 +245,9 @@ dependencies = [
[[package]]
name = "chrono-tz"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
@ -240,9 +256,9 @@ dependencies = [
[[package]]
name = "chrono-tz-build"
version = "0.2.1"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
@ -251,9 +267,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.4"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [
"clap_builder",
"clap_derive",
@ -261,9 +277,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.2"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [
"anstream",
"anstyle",
@ -273,9 +289,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.4"
version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
dependencies = [
"heck",
"proc-macro2",
@ -310,6 +326,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
@ -331,9 +356,9 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.19"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "crypto-common"
@ -390,6 +415,17 @@ dependencies = [
"serde",
]
[[package]]
name = "derive_arbitrary"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "deunicode"
version = "1.4.4"
@ -406,12 +442,39 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@ -424,6 +487,22 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
[[package]]
name = "flate2"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -534,11 +613,11 @@ dependencies = [
[[package]]
name = "globwalk"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"ignore",
"walkdir",
]
@ -765,6 +844,20 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "image"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11"
dependencies = [
"bytemuck",
"byteorder",
"num-traits",
"rayon",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -816,9 +909,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.154"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libm"
@ -837,12 +930,20 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "little-hesinde"
version = "0.1.0"
version = "0.3.1"
dependencies = [
"calibre-db",
"clap",
"ignore",
"image",
"once_cell",
"poem",
"quick-xml",
@ -850,6 +951,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"sha2",
"tera",
"thiserror",
"time",
@ -858,6 +960,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zip",
]
[[package]]
@ -870,6 +973,12 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lockfree-object-pool"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"
[[package]]
name = "log"
version = "0.4.21"
@ -924,7 +1033,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.5.0",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@ -1127,9 +1236,9 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "poem"
version = "3.0.0"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b735eaaaa6bc7ed2dcbcab1d5373afe1f6d03a37d8695ba3c42101f733a8455"
checksum = "e88b6912ed1e8833d7c22c9c986c517f4518d7d37e3c04566d917c789aaea591"
dependencies = [
"bytes",
"futures-util",
@ -1206,9 +1315,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.31.0"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
dependencies = [
"memchr",
"serde",
@ -1275,13 +1384,33 @@ dependencies = [
"getrandom",
]
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
dependencies = [
"bitflags 2.5.0",
"bitflags",
]
[[package]]
@ -1328,7 +1457,7 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.5.0",
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@ -1339,9 +1468,9 @@ dependencies = [
[[package]]
name = "rust-embed"
version = "8.3.0"
version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb78f46d0066053d16d4ca7b898e9343bc3530f71c61d5ad84cd404ada068745"
checksum = "19549741604902eb99a7ed0ee177a0663ee1eda51a29f71401f166e47e77806a"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
@ -1350,9 +1479,9 @@ dependencies = [
[[package]]
name = "rust-embed-impl"
version = "8.3.0"
version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91ac2a3c6c0520a3fb3dd89321177c3c692937c4eb21893378219da10c44fc8"
checksum = "cb9f96e283ec64401f30d3df8ee2aaeb2561f34c824381efa24a35f79bf40ee4"
dependencies = [
"proc-macro2",
"quote",
@ -1363,9 +1492,9 @@ dependencies = [
[[package]]
name = "rust-embed-utils"
version = "8.3.0"
version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f69089032567ffff4eada41c573fc43ff466c7db7c5688b2e7969584345581"
checksum = "38c74a686185620830701348de757fd36bef4aa9680fd23c49fc539ddcc1af32"
dependencies = [
"sha2",
"walkdir",
@ -1377,6 +1506,19 @@ version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "rustix"
version = "0.38.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
]
[[package]]
name = "ryu"
version = "1.0.17"
@ -1409,18 +1551,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.200"
version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.200"
version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
dependencies = [
"proc-macro2",
"quote",
@ -1429,9 +1571,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.116"
version = "1.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4"
dependencies = [
"itoa",
"ryu",
@ -1520,6 +1662,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "siphasher"
version = "0.3.11"
@ -1594,10 +1742,22 @@ dependencies = [
]
[[package]]
name = "tera"
version = "1.19.1"
name = "tempfile"
version = "3.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8"
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
dependencies = [
"cfg-if",
"fastrand",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "tera"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee"
dependencies = [
"chrono",
"chrono-tz",
@ -1617,18 +1777,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.59"
version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.59"
version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
dependencies = [
"proc-macro2",
"quote",
@ -1678,9 +1838,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.37.0"
version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [
"backtrace",
"bytes",
@ -1696,9 +1856,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
@ -1886,9 +2046,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "1.8.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439"
dependencies = [
"getrandom",
"rand",
@ -2195,3 +2355,49 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zip"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775a2b471036342aa69bc5a602bc889cb0a06cda00477d0c69566757d5553d39"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.2.6",
"memchr",
"thiserror",
"zopfli",
]
[[package]]
name = "zopfli"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946"
dependencies = [
"bumpalo",
"crc32fast",
"lockfree-object-pool",
"log",
"once_cell",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448"
dependencies = [
"zune-core",
]

View File

@ -5,9 +5,11 @@ members = [
]
[workspace.dependencies]
serde = "1.0.200"
thiserror = "1.0.59"
serde = "1.0.203"
thiserror = "1.0.61"
time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] }
[workspace.package]
license = "AGPL-3.0"
authors = ["Sebastian Hugentobler <shu@vanwa.ch>"]
repository = "https://code.vanwa.ch/shu/little-hesinde"

View File

@ -1,6 +1,10 @@
FROM docker.io/rust:1-alpine3.19 AS builder
FROM docker.io/rust:1-alpine3.20 AS builder
RUN apk --no-cache add musl-dev
RUN mkdir /tmp/tmp
RUN echo "hesinde:x:2222:2222:Linux User,,,:/:/app" > /passwd
RUN apk --no-cache add \
musl-dev
ENV CARGO_CARGO_NEW_VCS="none"
ENV CARGO_BUILD_RUSTFLAGS="-C target-feature=+crt-static"
@ -12,11 +16,16 @@ COPY . .
RUN cargo build --release --target=$(arch)-unknown-linux-musl
RUN cp "./target/$(arch)-unknown-linux-musl/release/little-hesinde" /app
FROM scratch
COPY --from=builder /passwd /etc/passwd
COPY --from=builder /app /app
CMD ["/app", "--", "/library"]
COPY --from=builder --chown=2222: /tmp/tmp /tmp
VOLUME ["/library"]
USER hesinde
CMD ["/app", "--listen-address", "[::]:3000", "--cache-path", "/tmp/cache", "--", "/library"]
ENV TMPDIR=/tmp
VOLUME ["/library", "/tmp"]
EXPOSE 3000

View File

@ -12,20 +12,17 @@ insist on writing my own containers). _How hard can it be_ I thought and went on
hacking something together. The result does at most one tenth of what cops can
do but luckily enough it is the part I need for myself.
![Screenshot](screenshot.jpg)
# Building
## Nix
A [nix](https://nixos.org/download/) environment with enabled
[nix-commands](https://nixos.wiki/wiki/Flakes) in order to use `nix develop` and
`nix build`.
[nix-commands](https://nixos.wiki/wiki/Flakes) in order to use `nix develop`.
A statically linked binary for linux systems (using
[musl](https://musl.libc.org/)) can be compiled by running `nix build` (run
`nix flake show` to get a list of available targets).
Otherwise run `nix develop` to be dropped into a shell with everything installed
and configured. From there all the usual `cargo` commands are accessible.
Run `nix develop` to be dropped into a shell with everything installed and
configured. From there all the usual `cargo` commands are accessible.
## Classic
@ -36,10 +33,24 @@ From there on `cargo run` and `cargo build` and so on can be used.
# Configuration
The binary takes exactly one argument, the path to the calibre library folder.
```
Usage: little-hesinde [OPTIONS] -- <LIBRARY_PATH>
The listening port is hardcoded to `3000` for now, as is the listening on all
interfaces.
Arguments:
<LIBRARY_PATH> Calibre library path [env: LIBRARY_PATH=]
Options:
-l, --listen-address <LISTEN_ADDRESS>
Address to listen on [env: LISTEN_ADDRESS=] [default: [::1]:3000]
-c, --cache-path <CACHE_PATH>
Cache path ($TMP cascades through $XDG_CACHE_HOME, $TMPDIR and /tmp) [env: CACHE_PATH=] [default: $TMP/little-hesinde]
-h, --help
Print help
-V, --version
Print version
```
Example: `little-hesinde -l [::]4000 -- ~/Documents/library/`
# Usage
@ -53,15 +64,19 @@ http://localhost:3000/opds is the entry point for the OPDS feed.
Not planned, put a reverse proxy in front of it that handles access.
## No search?
## How do I search?
On my todo list once I feel like I need it.
Enter your search text and you are done. Searching is done on title, tags,
author, series title, identifiers and comments.
For more sophisticated queries take a look at the
[fts5 documentation](https://www.sqlite.org/fts5.html#full_text_query_syntax).
## Why are the OPDS entries not paginated?
My hardware (a Kobo Aura One from ~2016) with KOReader works perfectly fine with
parsing the 1MB book feed from own library. Once that changes I might get over
my laziness and implement it.
parsing the 1MB book feed from my own library. Once that changes I might get
over my laziness and implement it.
## Aren't these database access patterns inefficient?

View File

@ -3,11 +3,15 @@ name = "calibre-db"
version = "0.1.0"
edition = "2021"
license = { workspace = true }
authors = { workspace = true }
repository = { workspace = true }
description = "Read data from a calibre library, leveraging its SQLite metadata database."
[dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = "0.24.0"
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
serde = { workspace = true }
tempfile = "3.10.1"
thiserror = { workspace = true }
time = { workspace = true }

View File

@ -1,12 +1,16 @@
//! Bundle all functions together.
use std::path::Path;
use std::path::{Path, PathBuf};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use tempfile::NamedTempFile;
use crate::data::{
author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series,
use crate::{
data::{
author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series,
},
search::search,
};
/// Top level calibre functions, bundling all sub functions in one place and providing secure access to
@ -14,6 +18,7 @@ use crate::data::{
#[derive(Debug, Clone)]
pub struct Calibre {
pool: Pool<SqliteConnectionManager>,
search_db_path: PathBuf,
}
impl Calibre {
@ -24,7 +29,20 @@ impl Calibre {
let manager = SqliteConnectionManager::file(path);
let pool = r2d2::Pool::new(manager)?;
Ok(Self { pool })
let tmpfile = NamedTempFile::new()?;
let (_, search_db_path) = tmpfile.keep()?;
Ok(Self {
pool,
search_db_path,
})
}
/// Full text search with a query.
///
/// See https://www.sqlite.org/fts5.html#full_text_query_syntax for syntax.
pub fn search(&self, query: &str) -> Result<Vec<Book>, DataStoreError> {
search(query, &self.pool, &self.search_db_path)
}
/// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and

View File

@ -1,5 +1,8 @@
//! Error handling for calibre database access.
use std::io;
use tempfile::PersistError;
use thiserror::Error;
use time::error::Parse;
@ -16,9 +19,15 @@ pub enum DataStoreError {
/// Error connecting to the database.
#[error("connection error")]
ConnectionError(#[from] r2d2::Error),
/// Error wparsing a datetime from the database.
/// Error parsing a datetime from the database.
#[error("failed to parse datetime")]
DateTimeError(#[from] Parse),
/// Error creating the search database.
#[error("failed to create search database")]
SearchDbError(#[from] io::Error),
/// Error marking the search database as persistent.
#[error("failed to persist search database")]
PersistSearchDbError(#[from] PersistError),
}
/// Convert an SQLite error into a proper NoResults one if the query

View File

@ -1,6 +1,8 @@
//! Read data from a calibre library, leveraging its SQLite metadata database.
pub mod calibre;
pub mod search;
/// Data structs for the calibre database.
pub mod data {
pub mod author;

106
calibre-db/src/search.rs Normal file
View File

@ -0,0 +1,106 @@
//! Provide search funcitonality for calibre.
//!
//! Because the calibre database can not be disturbed (it is treated as read-only)
//! it attaches a temporary database and inserts the relevant data into a
//! virtual table leveraging fts5 (https://www.sqlite.org/fts5.html). Full-text search is run on
//! that virtual table.
use std::path::Path;
use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::named_params;
use crate::data::{book::Book, error::DataStoreError};
/// A lot of joins but only run once at startup.
const SEARCH_INIT_QUERY: &str = "INSERT INTO search.fts(book_id, data)
SELECT b.id as book_id,
COALESCE(b.title, '') || ' ' ||
COALESCE(a.name, '') || ' ' ||
COALESCE(c.text, '') || ' ' ||
COALESCE(GROUP_CONCAT(DISTINCT t.name), '') || ' ' ||
COALESCE(GROUP_CONCAT(DISTINCT i.val), '') || ' ' ||
COALESCE(GROUP_CONCAT(DISTINCT s.name), '') as data
FROM main.books as b
LEFT JOIN main.books_authors_link AS b2a ON b.id = b2a.book
LEFT JOIN main.authors AS a ON b2a.author = a.id
LEFT JOIN main.comments AS c ON c.book = b.id
LEFT JOIN main.books_tags_link AS b2t ON b.id = b2t.book
LEFT JOIN main.tags AS t ON b2t.tag = t.id
LEFT JOIN main.identifiers AS i ON i.book = b.id
LEFT JOIN main.books_series_link AS b2s ON b.id = b2s.book
LEFT JOIN main.series AS s ON b2s.series = s.id
GROUP BY b.id";
/// Ensure the search database is attached to the connection and
/// initializes the data if needed.
fn ensure_search_db(
conn: &PooledConnection<SqliteConnectionManager>,
db_path: &Path,
) -> Result<(), DataStoreError> {
let mut stmt =
conn.prepare("SELECT COUNT() FROM pragma_database_list WHERE name = 'search'")?;
let count: u64 = stmt.query_row([], |x| x.get(0))?;
let need_attachment = count == 0;
if need_attachment {
attach(conn, db_path)?;
init(conn)?;
}
Ok(())
}
/// Attach the fts temporary database to the read-only calibre database.
fn attach(
conn: &PooledConnection<SqliteConnectionManager>,
db_path: &Path,
) -> Result<(), DataStoreError> {
conn.execute(
&format!("ATTACH DATABASE '{}' AS search", db_path.to_string_lossy()),
[],
)?;
init(conn)?;
Ok(())
}
/// Initialise the fts virtual table.
fn init(conn: &PooledConnection<SqliteConnectionManager>) -> Result<(), DataStoreError> {
let mut stmt = conn
.prepare("SELECT COUNT() FROM search.sqlite_master WHERE type='table' AND name = 'fts'")?;
let count: u64 = stmt.query_row([], |x| x.get(0))?;
let need_init = count == 0;
if need_init {
conn.execute(
"CREATE VIRTUAL TABLE search.fts USING fts5(book_id, data)",
[],
)?;
conn.execute(SEARCH_INIT_QUERY, [])?;
}
Ok(())
}
/// Run a full-text search with the parameter `query`.
pub(crate) fn search(
query: &str,
pool: &Pool<SqliteConnectionManager>,
search_db_path: &Path,
) -> Result<Vec<Book>, DataStoreError> {
let conn = pool.get()?;
ensure_search_db(&conn, search_db_path)?;
let mut stmt =
conn.prepare("SELECT book_id FROM search.fts WHERE data MATCH (:query) ORDER BY rank")?;
let params = named_params! { ":query": query };
let books = stmt
.query_map(params, |r| -> Result<u64, rusqlite::Error> { r.get(0) })?
.filter_map(Result::ok)
.filter_map(|id| Book::scalar_book(&conn, id).ok())
.collect();
Ok(books)
}

View File

@ -92,6 +92,7 @@ allow = [
"AGPL-3.0",
"Apache-2.0",
"BSD-3-Clause",
"BSL-1.0",
"MIT",
"Unicode-DFS-2016"
]

View File

@ -37,24 +37,6 @@
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1713520724,
"narHash": "sha256-CO8MmVDmqZX2FovL75pu5BvwhW+Vugc7Q6ze7Hj8heI=",
"owner": "nix-community",
"repo": "naersk",
"rev": "c5037590290c6c7dae2e42e7da1e247e54ed2d49",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1714253743,
@ -72,18 +54,6 @@
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 0,
"narHash": "sha256-mdTQw2XlariysyScCv2tTE45QSU9v/ezLcHJ22f0Nxc=",
"path": "/nix/store/801l7gvdz7yaibhjsxqx82sc7zkakjbq-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1714253743,
"narHash": "sha256-mdTQw2XlariysyScCv2tTE45QSU9v/ezLcHJ22f0Nxc=",
@ -103,8 +73,7 @@
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_2"
}
},
"rust-analyzer-src": {

106
flake.nix
View File

@ -3,68 +3,16 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
fenix.url = "github:nix-community/fenix";
};
outputs =
{
nixpkgs,
naersk,
fenix,
flake-utils,
...
}:
let
buildTargets = {
"x86_64-linux" = {
crossSystemConfig = "x86_64-unknown-linux-musl";
rustTarget = "x86_64-unknown-linux-musl";
};
"i686-linux" = {
crossSystemConfig = "i686-unknown-linux-musl";
rustTarget = "i686-unknown-linux-musl";
};
"aarch64-linux" = {
crossSystemConfig = "aarch64-unknown-linux-musl";
rustTarget = "aarch64-unknown-linux-musl";
};
"armv6l-linux" = {
crossSystemConfig = "armv6l-unknown-linux-musleabihf";
rustTarget = "arm-unknown-linux-musleabihf";
};
};
eachSystem =
supportedSystems: callback:
builtins.foldl' (overall: system: overall // { ${system} = callback system; }) { } supportedSystems;
eachCrossSystem =
supportedSystems: callback:
eachSystem supportedSystems (
buildSystem:
builtins.foldl' (
inner: targetSystem: inner // { "cross-${targetSystem}" = callback buildSystem targetSystem; }
) { default = callback buildSystem buildSystem; } supportedSystems
);
mkPkgs =
buildSystem: targetSystem:
import nixpkgs (
{
system = buildSystem;
}
// (
if targetSystem == null then
{ }
else
{ crossSystem.config = buildTargets.${targetSystem}.crossSystemConfig; }
)
);
in
flake-utils.lib.eachDefaultSystem (
system:
let
@ -80,57 +28,5 @@
];
};
}
)
// {
packages = eachCrossSystem (builtins.attrNames buildTargets) (
buildSystem: targetSystem:
let
pkgs = mkPkgs buildSystem null;
pkgsCross = mkPkgs buildSystem targetSystem;
rustTarget = buildTargets.${targetSystem}.rustTarget;
fenixPkgs = fenix.packages.${buildSystem};
mkToolchain = fenixPkgs: fenixPkgs.stable;
toolchain = fenixPkgs.combine [
(mkToolchain fenixPkgs).rustc
(mkToolchain fenixPkgs).cargo
(mkToolchain fenixPkgs.targets.${rustTarget}).rust-std
];
buildPackageAttrs =
if builtins.hasAttr "makeBuildPackageAttrs" buildTargets.${targetSystem} then
buildTargets.${targetSystem}.makeBuildPackageAttrs pkgsCross
else
{ };
naersk-lib = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
in
naersk-lib.buildPackage (
buildPackageAttrs
// rec {
src = ./.;
strictDeps = true;
doCheck = true;
nativeBuildInputs = [ pkgs.cargo-deny ];
cargoTestCommands = (default: default ++ [ "cargo deny check ban bans license licenses sources" ]);
TARGET_CC = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
CARGO_BUILD_TARGET = rustTarget;
CARGO_BUILD_RUSTFLAGS = [
"-C"
"target-feature=+crt-static"
"-C"
"linker=${TARGET_CC}"
];
}
)
);
};
);
}

View File

@ -1,24 +1,33 @@
[package]
name = "little-hesinde"
version = "0.1.0"
version = "0.3.1"
edition = "2021"
license = { workspace = true }
authors = { workspace = true }
repository = { workspace = true }
description = "A very simple ebook server for a calibre library, providing a html interface as well as an OPDS feed."
[dependencies]
calibre-db = { path = "../calibre-db/" }
clap = { version = "4.5.4", features = ["derive"] }
calibre-db = { path = "../calibre-db/", version = "0.1.0" }
clap = { version = "4.5.7", features = ["derive", "env"] }
image = { version = "0.25.1", default-features = false, features = ["jpeg", "rayon"] }
once_cell = "1.19.0"
poem = { version = "3.0.0", features = ["embed", "static-files"] }
rust-embed = "8.3.0"
poem = { version = "3.0.1", features = ["embed", "static-files"] }
rust-embed = "8.4.0"
sha2 = "0.10.8"
serde = { workspace = true }
serde_json = "1.0.116"
serde_json = "1.0.118"
serde_with = "3.8.1"
tera = "1.19.1"
tera = "1.20.0"
thiserror = { workspace = true }
time = { workspace = true }
tokio = { version = "1.37.0", features = ["signal", "rt-multi-thread", "macros"] }
tokio = { version = "1.38.0", features = ["signal", "rt-multi-thread", "macros"] }
tokio-util = "0.7.11"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
uuid = { version = "1.8.0", features = ["v4", "fast-rng"] }
quick-xml = { version = "0.31.0", features = ["serialize"] }
uuid = { version = "1.9.1", features = ["v4", "fast-rng"] }
quick-xml = { version = "0.34.0", features = ["serialize"] }
[build-dependencies]
ignore = "0.4.22"
zip = { version = "2.1.3", default-features = false, features = ["deflate"] }

46
little-hesinde/build.rs Normal file
View File

@ -0,0 +1,46 @@
use std::{
env,
fs::File,
io::{Read, Write},
path::Path,
};
use ignore::Walk;
use zip::{write::SimpleFileOptions, CompressionMethod};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out_dir = env::var("OUT_DIR")?;
let src_dir = "..";
let zip_path = Path::new(&out_dir).join("archive.zip");
let zip_file = File::create(zip_path)?;
let walkdir = Walk::new(src_dir);
let it = walkdir.into_iter();
let mut zip = zip::ZipWriter::new(zip_file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o755);
for entry in it {
let entry = entry?;
let path = entry.path();
let name = path.strip_prefix(Path::new(src_dir))?;
if path.is_file() {
zip.start_file(name.to_str().unwrap(), options)?;
let mut f = File::open(path)?;
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
} else if !name.as_os_str().is_empty() {
zip.add_directory(name.to_str().unwrap(), options)?;
}
}
zip.finish()?;
println!("cargo:rerun-if-changed={}", src_dir);
Ok(())
}

View File

@ -0,0 +1,87 @@
//! Handle caching of files, specifically book covers.
use std::{
fs::{self, File},
path::{Path, PathBuf},
};
use sha2::{
digest::{generic_array::GenericArray, typenum::U32},
Digest, Sha256,
};
use std::fmt::Write;
use thiserror::Error;
use tracing::debug;
/// Errors from dealing with file caching.
#[derive(Error, Debug)]
pub enum CacheError {
/// Error converting a hash to its string representation.
#[error("failed to access thumbnail")]
HashError(#[from] std::fmt::Error),
/// Error creating a thumbnail for an image..
#[error("failed to create thumbnail")]
ImageError(#[from] image::ImageError),
/// Error accessing a thumbnail.
#[error("failed to access thumbnail")]
ThumbnailAccessError(#[from] std::io::Error),
/// Error accessing thumbnail directories.
#[error("failed to access thumbnail directory")]
ThumbnailPathError(PathBuf),
}
/// Convert a hash into its path representation inside the cache directory.
///
/// First hash character is the top folder, second character the second level folder and the rest
/// is the filename.
fn hash_to_path(hash: GenericArray<u8, U32>, cache_path: &Path) -> Result<PathBuf, CacheError> {
let mut hash_string = String::new();
for byte in hash {
write!(&mut hash_string, "{:02x}", byte)?;
}
let hash = hash_string;
let first_segment = &hash[0..1];
let second_segment = &hash[1..2];
let remaining_segment = &hash[2..];
Ok(PathBuf::from(cache_path)
.join(first_segment)
.join(second_segment)
.join(remaining_segment))
}
/// Create a thumbnail for `cover_path` at `thumbnail_path`.
fn create_thumbnail(cover_path: &Path, thumbnail_path: &Path) -> Result<(), CacheError> {
debug!("creating thumbnail for {}", cover_path.to_string_lossy());
let folders = thumbnail_path
.parent()
.ok_or_else(|| CacheError::ThumbnailPathError(thumbnail_path.to_path_buf()))?;
fs::create_dir_all(folders)?;
const THUMBNAIL_SIZE: u32 = 512;
let img = image::open(cover_path)?;
let thumbnail = img.thumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE);
thumbnail.save_with_format(thumbnail_path, image::ImageFormat::Jpeg)?;
debug!("saved thumbnail to {}", thumbnail_path.to_string_lossy());
Ok(())
}
/// Get the thumbnail for a book cover.
///
/// If a thumbnail does not yet exist, create it.
pub fn get_thumbnail(cover_path: &Path, cache_path: &Path) -> Result<File, CacheError> {
let path_str = cover_path.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let hash = hasher.finalize();
let thumbnail_path = hash_to_path(hash, cache_path)?;
if !thumbnail_path.exists() {
create_thumbnail(cover_path, &thumbnail_path)?;
}
Ok(File::open(thumbnail_path)?)
}

View File

@ -6,7 +6,13 @@ use clap::Parser;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Address to listen on
#[arg(short, long, env, default_value = "[::1]:3000")]
pub listen_address: String,
/// Cache path ($TMP cascades through $XDG_CACHE_HOME, $TMPDIR and /tmp)
#[arg(short, long, env, default_value = "$TMP/little-hesinde")]
pub cache_path: String,
/// Calibre library path
#[arg(last = true)]
#[arg(env, last = true)]
pub library_path: String,
}

View File

@ -1,8 +1,14 @@
//! Configuration data.
use std::path::{Path, PathBuf};
use std::{
env, fs, io,
net::SocketAddr,
net::ToSocketAddrs,
path::{Path, PathBuf},
};
use thiserror::Error;
use tracing::info;
use crate::cli::Cli;
@ -15,14 +21,25 @@ pub enum ConfigError {
/// Calibre database does not exist.
#[error("no metadata.db in {0}")]
MetadataNotFound(String),
/// Error converting a string to a listening address.
#[error("failed to convert into listening address")]
ListeningAddressError(String),
/// Error accessing the configured cache path.
#[error("failed to access cache path")]
CachePathError(#[from] io::Error),
}
/// Application configuration.
#[derive(Debug, Clone)]
pub struct Config {
/// Calibre library folder.
pub library_path: PathBuf,
/// Calibre metadata file path.
pub metadata_path: PathBuf,
/// Address to listen on.
pub listen_address: SocketAddr,
/// Path to data like thumbnails.
pub cache_path: PathBuf,
}
impl Config {
@ -48,10 +65,32 @@ impl Config {
.to_string();
return Err(ConfigError::MetadataNotFound(metadata_path));
}
let listen_address = args
.listen_address
.to_socket_addrs()
.map_err(|e| {
ConfigError::ListeningAddressError(format!("{}: {e:?}", args.listen_address))
})?
.next()
.ok_or(ConfigError::ListeningAddressError(
args.listen_address.clone(),
))?;
let cache_path = if args.cache_path.starts_with("$TMP") {
let cache_base = env::var("XDG_CACHE_HOME")
.unwrap_or_else(|_| env::var("TMPDIR").unwrap_or("/tmp/".to_string()));
PathBuf::from(&cache_base).join("little-hesinde")
} else {
PathBuf::from(&args.cache_path)
};
fs::create_dir_all(&cache_path)?;
info!("Using {} for cache", cache_path.to_string_lossy());
Ok(Self {
library_path,
metadata_path,
listen_address,
cache_path,
})
}
}

View File

@ -4,12 +4,20 @@ use std::sync::Arc;
use calibre_db::data::pagination::SortOrder;
use poem::{
error::NotFoundError,
handler,
web::{Data, Path},
Response,
};
use tokio::fs::File;
use crate::{app_state::AppState, Accept};
use crate::{
app_state::AppState,
data::book::{Book, Format},
handlers::error::HandlerError,
opds::media_type::MediaType,
Accept,
};
/// Handle a request for multiple books, starting at the first.
#[handler]
@ -31,6 +39,31 @@ pub async fn handler(
books(&accept, &state, Some(&cursor), &sort_order).await
}
/// Handle a request for a book with id `id` in format `format`.
#[handler]
pub async fn handler_download(
Path((id, format)): Path<(u64, String)>,
state: Data<&Arc<AppState>>,
) -> Result<Response, poem::Error> {
let book = state
.calibre
.scalar_book(id)
.map_err(HandlerError::DataError)?;
let book = Book::full_book(&book, &state).ok_or(NotFoundError)?;
let format = Format(format);
let file_name = book.formats.get(&format).ok_or(NotFoundError)?;
let file_path = state
.config
.library_path
.join(book.data.path)
.join(file_name);
let mut file = File::open(file_path).await.map_err(|_| NotFoundError)?;
let content_type: MediaType = format.into();
let content_type = format!("{content_type}");
crate::handlers::download::handler(file_name, file, &content_type).await
}
async fn books(
accept: &Accept,
state: &Arc<AppState>,

View File

@ -1,31 +1,74 @@
//! Handle requests for cover images.
use std::{fs::File, io::Read, sync::Arc};
use std::{fs::File, path::Path as FilePath, sync::Arc};
use crate::{
app_state::AppState,
cache::{self, CacheError},
config::Config,
handlers::error::HandlerError,
};
use calibre_db::calibre::Calibre;
use poem::{
error::NotFoundError,
handler,
web::{headers::ContentType, Data, Path, WithContentType},
IntoResponse,
web::{headers::ContentType, Data, Path},
Response,
};
use thiserror::Error;
use tokio::fs::File as AsyncFile;
use crate::{app_state::AppState, handlers::error::HandlerError};
/// Errors from fetching cover images.
#[derive(Error, Debug)]
pub enum CoverError {
/// Error fetching a cover thumbnail.
#[error("failed to access thumbnail")]
ThumbnailError(#[from] CacheError),
/// Error fetching a full cover.
#[error("failed access cover")]
FullCoverError(#[from] std::io::Error),
}
/// Handle a request for the cover thumbnail of book with id `id`.
#[handler]
pub async fn handler_thumbnail(
id: Path<u64>,
state: Data<&Arc<AppState>>,
) -> Result<Response, poem::Error> {
cover(
&state.calibre,
&state.config,
*id,
|cover_path, cache_path| Ok(cache::get_thumbnail(cover_path, cache_path)?),
)
.await
}
/// Handle a request for the cover image of book with id `id`.
#[handler]
pub async fn handler(
pub async fn handler_full(
id: Path<u64>,
state: Data<&Arc<AppState>>,
) -> Result<WithContentType<Vec<u8>>, poem::Error> {
let book = state
.calibre
.scalar_book(*id)
.map_err(HandlerError::DataError)?;
let cover_path = state.config.library_path.join(book.path).join("cover.jpg");
let mut cover = File::open(cover_path).map_err(|_| NotFoundError)?;
let mut data = Vec::new();
cover.read_to_end(&mut data).map_err(|_| NotFoundError)?;
Ok(data.with_content_type(ContentType::jpeg().to_string()))
) -> Result<Response, poem::Error> {
cover(&state.calibre, &state.config, *id, |cover_path, _| {
Ok(File::open(cover_path)?)
})
.await
}
async fn cover<F>(
calibre: &Calibre,
config: &Config,
id: u64,
f: F,
) -> Result<Response, poem::Error>
where
F: Fn(&FilePath, &FilePath) -> Result<File, CoverError>,
{
let book = calibre.scalar_book(id).map_err(HandlerError::DataError)?;
let cover_path = config.library_path.join(book.path).join("cover.jpg");
let cover = f(&cover_path, &config.cache_path).map_err(|_| NotFoundError)?;
let cover = AsyncFile::from_std(cover);
crate::handlers::download::handler("cover.jpg", cover, &ContentType::jpeg().to_string()).await
}

View File

@ -1,50 +1,23 @@
//! Handle requests for specific formats of a book.
use std::sync::Arc;
use tokio::io::AsyncRead;
use tokio::fs::File;
use poem::{
error::NotFoundError,
handler,
web::{Data, Path},
Body, IntoResponse, Response,
};
use poem::{Body, IntoResponse, Response};
use tokio_util::io::ReaderStream;
use crate::{
app_state::AppState,
data::book::{Book, Format},
handlers::error::HandlerError,
opds::media_type::MediaType,
};
/// Handle a request for a book with id `id` in format `format`.
#[handler]
pub async fn handler(
Path((id, format)): Path<(u64, String)>,
state: Data<&Arc<AppState>>,
/// Handle a request for file.
///
/// Must not be used directly from a route as that makes it vulnerable to path traversal attacks.
pub async fn handler<A: AsyncRead + Send + 'static>(
file_name: &str,
reader: A,
content_type: &str,
) -> Result<Response, poem::Error> {
let book = state
.calibre
.scalar_book(id)
.map_err(HandlerError::DataError)?;
let book = Book::full_book(&book, &state).ok_or(NotFoundError)?;
let format = Format(format);
let file_name = book.formats.get(&format).ok_or(NotFoundError)?;
let file_path = state
.config
.library_path
.join(book.data.path)
.join(file_name);
let mut file = File::open(file_path).await.map_err(|_| NotFoundError)?;
let stream = ReaderStream::new(file);
let stream = ReaderStream::new(reader);
let body = Body::from_bytes_stream(stream);
let content_type: MediaType = format.into();
Ok(body
.with_content_type(format!("{content_type}"))
.with_content_type(content_type)
.with_header("Content-Disposition", format!("filename=\"{file_name}\""))
.into_response())
}

View File

@ -8,7 +8,7 @@ use crate::{data::book::Book, templates::TEMPLATES};
/// Render recent books as html.
pub async fn handler(recent_books: Vec<Book>) -> Result<Response, poem::Error> {
let mut context = Context::new();
context.insert("title", "Recent Books");
context.insert("title", "");
context.insert("nav", "recent");
context.insert("books", &recent_books);

View File

@ -0,0 +1,20 @@
//! Handle search results in html.
use poem::{error::InternalServerError, web::Html, IntoResponse, Response};
use tera::Context;
use crate::{data::book::Book, templates::TEMPLATES};
/// Render all search results as html.
pub async fn handler(books: Vec<Book>) -> Result<Response, poem::Error> {
let mut context = Context::new();
context.insert("title", "Search Results");
context.insert("nav", "search");
context.insert("books", &books);
Ok(TEMPLATES
.render("book_list", &context)
.map_err(InternalServerError)
.map(Html)?
.into_response())
}

View File

@ -8,6 +8,7 @@ use crate::{
data::book::Book,
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render a single author as an OPDS entry embedded in a feed.
@ -24,7 +25,7 @@ pub async fn handler(author: Author, books: Vec<Book>) -> Result<Response, poem:
};
let feed = Feed::create(
now,
&format!("little-hesinde:author:{}", author.id),
&format!("{APP_NAME}author:{}", author.id),
&author.name,
self_link,
vec![],

View File

@ -10,6 +10,7 @@ use time::OffsetDateTime;
use crate::{
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render all authors as OPDS entries embedded in a feed.
@ -34,7 +35,7 @@ pub async fn handler(
};
let feed = Feed::create(
now,
"little-hesinde:authors",
&format!("{APP_NAME}:authors"),
"All Authors",
self_link,
vec![],

View File

@ -9,6 +9,7 @@ use crate::{
data::book::Book,
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render all books as OPDS entries embedded in a feed.
@ -22,7 +23,11 @@ pub async fn handler(
.books(u32::MAX.into(), None, &SortOrder::ASC)
.map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect())
.map_err(HandlerError::DataError)?;
render_books(books).await
}
/// Render a list of books as OPDS entries in a feed.
pub(crate) async fn render_books(books: Vec<Book>) -> Result<Response, poem::Error> {
let entries: Vec<Entry> = books.into_iter().map(Entry::from).collect();
let now = OffsetDateTime::now_utc();
@ -35,7 +40,7 @@ pub async fn handler(
};
let feed = Feed::create(
now,
"little-hesinde:books",
&format!("{APP_NAME}:books"),
"All Books",
self_link,
vec![],

View File

@ -9,6 +9,7 @@ use crate::{
content::Content, entry::Entry, feed::Feed, link::Link, media_type::MediaType,
relation::Relation,
},
APP_NAME,
};
/// Render a root OPDS feed with links to the subsections (authors, books, series and recent).
@ -25,7 +26,7 @@ pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
};
let books_entry = Entry {
title: "Books".to_string(),
id: "little-hesinde:books".to_string(),
id: format!("{APP_NAME}:books"),
updated: now,
content: Some(Content {
media_type: MediaType::Text,
@ -43,7 +44,7 @@ pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
let authors_entry = Entry {
title: "Authors".to_string(),
id: "little-hesinde:authors".to_string(),
id: format!("{APP_NAME}:authors"),
updated: now,
content: Some(Content {
media_type: MediaType::Text,
@ -61,7 +62,7 @@ pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
let series_entry = Entry {
title: "Series".to_string(),
id: "little-hesinde:series".to_string(),
id: format!("{APP_NAME}:series"),
updated: now,
content: Some(Content {
media_type: MediaType::Text,
@ -79,7 +80,7 @@ pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
let recents_entry = Entry {
title: "Recent Additions".to_string(),
id: "little-hesinde:recentbooks".to_string(),
id: format!("{APP_NAME}:recentbooks"),
updated: now,
content: Some(Content {
media_type: MediaType::Text,
@ -97,7 +98,7 @@ pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
let feed = Feed::create(
now,
"little-hesinde:catalog",
&format!("{APP_NAME}:catalog"),
"Little Hesinde",
self_link,
vec![],

View File

@ -7,6 +7,7 @@ use crate::{
data::book::Book,
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render recent books as OPDS entries embedded in a feed.
@ -23,7 +24,7 @@ pub async fn handler(recent_books: Vec<Book>) -> Result<Response, poem::Error> {
};
let feed = Feed::create(
now,
"little-hesinde:recentbooks",
&format!("{APP_NAME}:recentbooks"),
"Recent Books",
self_link,
vec![],

View File

@ -0,0 +1,12 @@
//! Handle search results in opds.
use poem::Response;
use crate::data::book::Book;
use super::books::render_books;
/// Render search results as OPDS entries in a feed.
pub async fn handler(books: Vec<Book>) -> Result<Response, poem::Error> {
render_books(books).await
}

View File

@ -0,0 +1,27 @@
//! Handle open search description..
use crate::{
handlers::error::HandlerError,
opds::search::{OpenSearchDescription, Url},
APP_NAME,
};
use poem::{handler, IntoResponse, Response};
/// Render search information as open search description.
#[handler]
pub async fn handler() -> Result<Response, poem::Error> {
let search = OpenSearchDescription {
short_name: APP_NAME.to_string(),
description: "Search for ebooks".to_string(),
input_encoding: "UTF-8".to_string(),
output_encoding: "UTF-8".to_string(),
url: Url {
type_name: "application/atom+xml".to_string(),
template: "/opds/search?query={searchTerms}".to_string(),
},
};
let xml = search.as_xml().map_err(HandlerError::OpdsError)?;
Ok(xml
.with_content_type("application/atom+xml")
.into_response())
}

View File

@ -7,6 +7,7 @@ use time::OffsetDateTime;
use crate::{
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render all series as OPDS entries embedded in a feed.
@ -31,7 +32,7 @@ pub async fn handler(
};
let feed = Feed::create(
now,
"little-hesinde:series",
&format!("{APP_NAME}:series"),
"All Series",
self_link,
vec![],

View File

@ -8,6 +8,7 @@ use crate::{
data::book::Book,
handlers::error::HandlerError,
opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation},
APP_NAME,
};
/// Render a single series as an OPDS entry embedded in a feed.
@ -24,7 +25,7 @@ pub async fn handler(series: Series, books: Vec<Book>) -> Result<Response, poem:
};
let feed = Feed::create(
now,
&format!("little-hesinde:series:{}", series.id),
&format!("{APP_NAME}:series:{}", series.id),
&series.name,
self_link,
vec![],

View File

@ -1,16 +1,13 @@
//! Deal with cursor pagination.
use std::fmt::Debug;
use super::error::HandlerError;
use crate::templates::TEMPLATES;
use calibre_db::data::error::DataStoreError;
use poem::{error::InternalServerError, web::Html, IntoResponse, Response};
use serde::Serialize;
use std::fmt::Debug;
use tera::Context;
use crate::templates::TEMPLATES;
use super::error::HandlerError;
/// Render a tera template with paginated items and generate back and forth links.
pub fn render<T: Serialize + Debug, F, S, P, M>(
template: &str,
@ -25,11 +22,18 @@ where
P: Fn(&str) -> Result<bool, DataStoreError>,
M: Fn(&str) -> Result<bool, DataStoreError>,
{
let items = fetcher().map_err(HandlerError::DataError)?;
let mut context = Context::new();
context.insert("nav", template);
let items = fetcher().map_err(HandlerError::DataError)?;
if items.is_empty() {
return Ok(TEMPLATES
.render("empty", &context)
.map_err(InternalServerError)
.map(Html)?
.into_response());
}
// fails already in the sql query if there is nothing returned
let first_item = items.first().unwrap();
let last_item = items.last().unwrap();
@ -42,7 +46,6 @@ where
context.insert("has_more", &has_more);
context.insert("backward_cursor", &backward_cursor);
context.insert("forward_cursor", &forward_cursor);
context.insert("nav", template);
context.insert(template, &items);
Ok(TEMPLATES

View File

@ -0,0 +1,38 @@
//! Handle search requests.
use std::sync::Arc;
use poem::{
handler,
web::{Data, Query},
Response,
};
use serde::Deserialize;
use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept};
#[derive(Deserialize)]
struct Params {
/// Query for a search request.
query: String,
}
/// Handle a search request with query parameter `query`.
#[handler]
pub async fn handler(
accept: Data<&Accept>,
state: Data<&Arc<AppState>>,
Query(params): Query<Params>,
) -> Result<Response, poem::Error> {
let books = state
.calibre
.search(&params.query)
.map_err(HandlerError::DataError)?
.iter()
.filter_map(|book| Book::full_book(book, *state))
.collect();
match *accept {
Accept::Html => crate::handlers::html::search::handler(books).await,
Accept::Opds => crate::handlers::opds::search::handler(books).await,
}
}

View File

@ -0,0 +1,11 @@
use crate::{APP_NAME, VERSION};
use poem::{handler, Response};
const SOURCE_ARCHIVE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/archive.zip"));
/// Handle a request for source code of the server..
#[handler]
pub async fn handler() -> Result<Response, poem::Error> {
let file_name = format!("{APP_NAME}-{VERSION}.zip");
crate::handlers::download::handler(&file_name, SOURCE_ARCHIVE, "application/zip").await
}

View File

@ -16,6 +16,7 @@ use tokio::signal;
use tracing::info;
pub mod app_state;
pub mod cache;
pub mod cli;
pub mod config;
/// Data structs and their functions.
@ -32,6 +33,7 @@ pub mod handlers {
pub mod authors;
pub mod books;
pub mod recent;
pub mod search;
pub mod series;
pub mod series_single;
}
@ -42,6 +44,8 @@ pub mod handlers {
pub mod books;
pub mod feed;
pub mod recent;
pub mod search;
pub mod search_info;
pub mod series;
pub mod series_single;
}
@ -53,8 +57,10 @@ pub mod handlers {
pub mod error;
pub mod paginated;
pub mod recent;
pub mod search;
pub mod series;
pub mod series_single;
pub mod source_archive;
}
/// OPDS data structs.
pub mod opds {
@ -66,9 +72,13 @@ pub mod opds {
pub mod link;
pub mod media_type;
pub mod relation;
pub mod search;
}
pub mod templates;
pub const APP_NAME: &str = "little-hesinde";
pub const VERSION: &str = "0.3.1";
/// Internal marker data in lieu of a proper `Accept` header.
#[derive(Debug, Clone, Copy)]
pub enum Accept {
@ -86,7 +96,10 @@ pub struct Files;
/// Main entry point to run the ebook server with a calibre library specified in `config`.
pub async fn run(config: Config) -> Result<(), std::io::Error> {
let calibre = Calibre::load(&config.metadata_path).expect("failed to load calibre database");
let app_state = Arc::new(AppState { calibre, config });
let app_state = Arc::new(AppState {
calibre,
config: config.clone(),
});
let html_routes = Route::new()
.at("/", get(handlers::recent::handler))
@ -104,8 +117,14 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> {
"/authors/:cursor/:sort_order",
get(handlers::authors::handler),
)
.at("/cover/:id", get(handlers::cover::handler))
.at("/book/:id/:format", get(handlers::download::handler))
.at("/cover/:id", get(handlers::cover::handler_full))
.at(
"/cover/:id/thumbnail",
get(handlers::cover::handler_thumbnail),
)
.at("/book/:id/:format", get(handlers::books::handler_download))
.at("/archive", get(handlers::source_archive::handler))
.at("/search", get(handlers::search::handler))
.nest("/static", EmbeddedFilesEndpoint::<Files>::new())
.data(Accept::Html);
@ -117,6 +136,8 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> {
.at("/authors/:id", get(handlers::author::handler))
.at("/series", get(handlers::series::handler_init))
.at("/series/:id", get(handlers::series_single::handler))
.at("/search/info", get(handlers::opds::search_info::handler))
.at("/search", get(handlers::search::handler))
.data(Accept::Opds);
let app = Route::new()
@ -125,8 +146,8 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> {
.data(app_state)
.with(Tracing);
let server = Server::new(TcpListener::bind("[::]:3000"))
.name("cops-web")
let server = Server::new(TcpListener::bind(config.listen_address))
.name("little-hesinde")
.run(app);
tokio::select! {

View File

@ -4,7 +4,7 @@ use calibre_db::data::{author::Author as DbAuthor, series::Series};
use serde::Serialize;
use time::OffsetDateTime;
use crate::data::book::Book;
use crate::{data::book::Book, APP_NAME};
use super::{
author::Author, content::Content, link::Link, media_type::MediaType, relation::Relation,
@ -88,7 +88,7 @@ impl From<DbAuthor> for Entry {
Self {
title: value.name.clone(),
id: format!("little-hesinde:authors:{}", value.id),
id: format!("{APP_NAME}:authors:{}", value.id),
updated: OffsetDateTime::now_utc(),
content: None,
author: None,
@ -112,7 +112,7 @@ impl From<Series> for Entry {
Self {
title: value.name.clone(),
id: format!("little-hesinde:series:{}", value.id),
id: format!("{APP_NAME}:series:{}", value.id),
updated: OffsetDateTime::now_utc(),
content: None,
author: None,

View File

@ -61,6 +61,13 @@ impl Feed {
title: Some("Home".to_string()),
count: None,
},
Link {
href: "/opds/search/info".to_string(),
media_type: MediaType::Search,
rel: Relation::Search,
title: Some("Search".to_string()),
count: None,
},
self_link,
];
links.append(&mut additional_links);
@ -80,7 +87,7 @@ impl Feed {
pub fn as_xml(&self) -> Result<String, OpdsError> {
let xml = to_string(&self)?;
let mut reader = Reader::from_str(&xml);
reader.trim_text(true);
reader.config_mut().trim_text(true);
let declaration = BytesDecl::new("1.0", Some("UTF-8"), None);
let mut writer = Writer::new(Cursor::new(Vec::new()));

View File

@ -16,6 +16,7 @@ pub enum MediaType {
Navigation,
Pdf,
Text,
Search,
}
/// Convert `epub` and `pdf` formats to their respective media type. Everything else is `Text`.
@ -46,6 +47,7 @@ impl std::fmt::Display for MediaType {
),
MediaType::Pdf => write!(f, "application/pdf"),
MediaType::Text => write!(f, "text"),
MediaType::Search => write!(f, "application/opensearchdescription+xml"),
}
}
}

View File

@ -14,6 +14,7 @@ pub enum Relation {
Subsection,
Thumbnail,
Acquisition,
Search,
}
/// Convert a media type int a relation.
@ -29,6 +30,7 @@ impl From<MediaType> for Relation {
MediaType::Navigation => Relation::Myself,
MediaType::Pdf => Relation::Acquisition,
MediaType::Text => Relation::Acquisition,
MediaType::Search => Relation::Search,
}
}
}
@ -43,6 +45,7 @@ impl std::fmt::Display for Relation {
Relation::Subsection => write!(f, "subsection"),
Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"),
Relation::Acquisition => write!(f, "http://opds-spec.org/acquisition"),
Relation::Search => write!(f, "application/opensearchdescription+xml"),
}
}
}

View File

@ -0,0 +1,65 @@
//! Search data.
use std::io::Cursor;
use quick_xml::{
events::{BytesDecl, BytesStart, Event},
se::to_string,
Reader, Writer,
};
use serde::Serialize;
use super::error::OpdsError;
/// Url pointing to a location.
#[derive(Debug, Serialize)]
pub struct Url {
#[serde(rename = "@type")]
pub type_name: String,
#[serde(rename = "@template")]
pub template: String,
}
/// Search information.
#[derive(Debug, Serialize)]
pub struct OpenSearchDescription {
#[serde(rename = "ShortName")]
pub short_name: String,
#[serde(rename = "Description")]
pub description: String,
#[serde(rename = "InputEncoding")]
pub input_encoding: String,
#[serde(rename = "OutputEncoding")]
pub output_encoding: String,
#[serde(rename = "Url")]
pub url: Url,
}
impl OpenSearchDescription {
/// Serialize search information to an open search description xml.
pub fn as_xml(&self) -> Result<String, OpdsError> {
let xml = to_string(&self)?;
let mut reader = Reader::from_str(&xml);
reader.config_mut().trim_text(true);
let declaration = BytesDecl::new("1.0", Some("UTF-8"), None);
let mut writer = Writer::new(Cursor::new(Vec::new()));
writer.write_event(Event::Decl(declaration))?;
let mut search_start = BytesStart::new("OpenSearchDescription");
search_start.push_attribute(("xmlns", "http://a9.com/-/spec/opensearch/1.1/"));
loop {
match reader.read_event() {
Ok(Event::Start(e)) if e.name().as_ref() == b"feed" => {
writer.write_event(Event::Start(search_start.clone()))?
}
Ok(Event::Eof) => break,
Ok(e) => writer.write_event(e)?,
Err(e) => return Err(e)?,
}
}
let result = writer.into_inner().into_inner();
Ok(String::from_utf8(result)?)
}
}

View File

@ -13,6 +13,7 @@ pub static TEMPLATES: Lazy<Tera> = Lazy::new(|| {
("book_list", include_str!("../templates/book_list.html")),
("books", include_str!("../templates/books.html")),
("series", include_str!("../templates/series.html")),
("empty", include_str!("../templates/empty.html")),
])
.expect("failed to parse tera templates");

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

@ -15,6 +15,10 @@ nav ul li {
padding-bottom: 0.25rem;
}
.nav-input {
margin-bottom: 0;
}
.nav-active {
border-bottom: solid var(--pico-primary-underline);
}
@ -40,7 +44,3 @@ nav ul li {
height: 6rem;
}
footer small {
display: flex;
justify-content: space-between;
}

View File

@ -1,12 +1,12 @@
{% extends "base" %}
{% block title %}
{% if has_previous %}
<a class="secondary" href="/authors/{{ backward_cursor }}/DESC">← back</a>
<a class="secondary" href="/authors/{{ backward_cursor | urlencode_strict }}/DESC">← back</a>
{% endif %}
{% if has_previous and has_more %}|{% endif%}
{% if has_more %}
<a class="secondary" href="/authors/{{ forward_cursor }}/ASC">more →</a>
<a class="secondary" href="/authors/{{ forward_cursor | urlencode_strict }}/ASC">more →</a>
{% endif %}
{% endblock title %}

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="/static/favicon.ico" />
<link rel="stylesheet" href="/static/pico.min.css" />
<link rel="stylesheet" href="/static/style.css" />
<title>Little Hesinde</title>
@ -11,6 +12,21 @@
<body>
<header class="container fixed">
<nav>
<ul>
<li>
<form action="/search">
<fieldset class="nav-input" role="search">
<input
type="search"
name="query"
placeholder="Search..."
aria-label="Search"
/>
<input type="submit" value="🔍" />
</fieldset>
</form>
</li>
</ul>
<ul>
<li>{% block title %}<strong>{{ title }}</strong>{% endblock title %}</li>
</ul>
@ -28,9 +44,10 @@
<footer class="container">
<hr />
<small>
<div>From <a href="https://code.vanwa.ch/shu/little-hesinde">https://code.vanwa.ch</a>
under <a href="https://www.gnu.org/licenses/agpl-3.0.txt">AGPL-3</a></div>
<div><a href="/opds">opds feed</a></div>
From <a class="secondary" href="https://code.vanwa.ch/shu/little-hesinde">https://code.vanwa.ch</a>
under <a class="secondary" href="https://www.gnu.org/licenses/agpl-3.0.txt">AGPL-3</a> //
<a class="secondary" href="/archive">source code</a> //
<a class="secondary" href="/opds">opds feed</a>
</small>
</footer>
</body>

View File

@ -14,7 +14,7 @@
{% endif %}
</hgroup>
</header>
<img class="cover" src="/cover/{{ book.data.id }}" alt="book cover">
<img class="cover" src="/cover/{{ book.data.id }}/thumbnail" alt="book cover">
<footer>
<form>
<fieldset role="group">

View File

@ -1,12 +1,12 @@
{% extends "base" %}
{% block title %}
{% if has_previous %}
<a class="secondary" href="/books/{{ backward_cursor }}/DESC">← back</a>
<a class="secondary" href="/books/{{ backward_cursor | urlencode_strict }}/DESC">← back</a>
{% endif %}
{% if has_previous and has_more %}|{% endif%}
{% if has_more %}
<a class="secondary" href="/books/{{ forward_cursor }}/ASC">more →</a>
<a class="secondary" href="/books/{{ forward_cursor | urlencode_strict }}/ASC">more →</a>
{% endif %}
{% endblock title %}

View File

@ -0,0 +1,6 @@
{% extends "base" %}
{% block title %}
{% endblock title %}
{% block content %}
<h2>No items</h2>
{% endblock content %}

View File

@ -1,12 +1,12 @@
{% extends "base" %}
{% block title %}
{% if has_previous %}
<a class="secondary" href="/series/{{ backward_cursor }}/DESC">← back</a>
<a class="secondary" href="/series/{{ backward_cursor | urlencode_strict }}/DESC">← back</a>
{% endif %}
{% if has_previous and has_more %}|{% endif%}
{% if has_more %}
<a class="secondary" href="/series/{{ forward_cursor }}/ASC">more →</a>
<a class="secondary" href="/series/{{ forward_cursor | urlencode_strict }}/ASC">more →</a>
{% endif %}
{% endblock title %}

BIN
screenshot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB