Compare commits

...

25 Commits
0.1.1 ... 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
40 changed files with 885 additions and 183 deletions

259
Cargo.lock generated
View File

@ -150,12 +150,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.5.0" version = "2.5.0"
@ -187,6 +181,18 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 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]] [[package]]
name = "bytes" name = "bytes"
version = "1.6.0" version = "1.6.0"
@ -201,6 +207,7 @@ dependencies = [
"r2d2_sqlite", "r2d2_sqlite",
"rusqlite", "rusqlite",
"serde", "serde",
"tempfile",
"thiserror", "thiserror",
"time", "time",
] ]
@ -238,9 +245,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono-tz" name = "chrono-tz"
version = "0.8.6" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz-build", "chrono-tz-build",
@ -249,9 +256,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono-tz-build" name = "chrono-tz-build"
version = "0.2.1" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [ dependencies = [
"parse-zoneinfo", "parse-zoneinfo",
"phf", "phf",
@ -260,9 +267,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.4" version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -270,9 +277,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.2" version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -282,9 +289,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.4" version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -321,9 +328,9 @@ dependencies = [
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.4.0" version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]
@ -349,9 +356,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.19" version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
@ -446,12 +453,28 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.1" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -464,6 +487,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.0.30" version = "1.0.30"
@ -584,11 +613,11 @@ dependencies = [
[[package]] [[package]]
name = "globwalk" name = "globwalk"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags",
"ignore", "ignore",
"walkdir", "walkdir",
] ]
@ -815,6 +844,20 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@ -866,9 +909,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.154" version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]] [[package]]
name = "libm" name = "libm"
@ -887,13 +930,20 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]] [[package]]
name = "little-hesinde" name = "little-hesinde"
version = "0.1.1" version = "0.3.1"
dependencies = [ dependencies = [
"calibre-db", "calibre-db",
"clap", "clap",
"ignore", "ignore",
"image",
"once_cell", "once_cell",
"poem", "poem",
"quick-xml", "quick-xml",
@ -901,6 +951,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"sha2",
"tera", "tera",
"thiserror", "thiserror",
"time", "time",
@ -922,6 +973,12 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "lockfree-object-pool"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.21" version = "0.4.21"
@ -976,7 +1033,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@ -1179,9 +1236,9 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]] [[package]]
name = "poem" name = "poem"
version = "3.0.0" version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b735eaaaa6bc7ed2dcbcab1d5373afe1f6d03a37d8695ba3c42101f733a8455" checksum = "e88b6912ed1e8833d7c22c9c986c517f4518d7d37e3c04566d917c789aaea591"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-util", "futures-util",
@ -1258,9 +1315,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.31.0" version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@ -1327,13 +1384,33 @@ dependencies = [
"getrandom", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.1" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags",
] ]
[[package]] [[package]]
@ -1380,7 +1457,7 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@ -1391,9 +1468,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.3.0" version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb78f46d0066053d16d4ca7b898e9343bc3530f71c61d5ad84cd404ada068745" checksum = "19549741604902eb99a7ed0ee177a0663ee1eda51a29f71401f166e47e77806a"
dependencies = [ dependencies = [
"rust-embed-impl", "rust-embed-impl",
"rust-embed-utils", "rust-embed-utils",
@ -1402,9 +1479,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed-impl" name = "rust-embed-impl"
version = "8.3.0" version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91ac2a3c6c0520a3fb3dd89321177c3c692937c4eb21893378219da10c44fc8" checksum = "cb9f96e283ec64401f30d3df8ee2aaeb2561f34c824381efa24a35f79bf40ee4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1415,9 +1492,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed-utils" name = "rust-embed-utils"
version = "8.3.0" version = "8.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f69089032567ffff4eada41c573fc43ff466c7db7c5688b2e7969584345581" checksum = "38c74a686185620830701348de757fd36bef4aa9680fd23c49fc539ddcc1af32"
dependencies = [ dependencies = [
"sha2", "sha2",
"walkdir", "walkdir",
@ -1429,6 +1506,19 @@ version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.17" version = "1.0.17"
@ -1461,18 +1551,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.200" version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.200" version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1481,9 +1571,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.116" version = "1.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@ -1572,6 +1662,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@ -1646,10 +1742,22 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tera" name = "tempfile"
version = "1.19.1" version = "3.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
@ -1669,18 +1777,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.59" version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.59" version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1730,9 +1838,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.37.0" version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
@ -1748,9 +1856,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.2.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1938,9 +2046,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.8.0" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439"
dependencies = [ dependencies = [
"getrandom", "getrandom",
"rand", "rand",
@ -2250,9 +2358,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "1.2.2" version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed14a57c18714abaa03196c0714ed36bab969d7457f72d12fb5c2e1ced4c24ae" checksum = "775a2b471036342aa69bc5a602bc889cb0a06cda00477d0c69566757d5553d39"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"crc32fast", "crc32fast",
@ -2260,5 +2368,36 @@ dependencies = [
"displaydoc", "displaydoc",
"flate2", "flate2",
"indexmap 2.2.6", "indexmap 2.2.6",
"memchr",
"thiserror", "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,8 +5,8 @@ members = [
] ]
[workspace.dependencies] [workspace.dependencies]
serde = "1.0.200" serde = "1.0.203"
thiserror = "1.0.59" thiserror = "1.0.61"
time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] } time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] }
[workspace.package] [workspace.package]

View File

@ -1,4 +1,7 @@
FROM docker.io/rust:1-alpine3.19 AS builder FROM docker.io/rust:1-alpine3.20 AS builder
RUN mkdir /tmp/tmp
RUN echo "hesinde:x:2222:2222:Linux User,,,:/:/app" > /passwd
RUN apk --no-cache add \ RUN apk --no-cache add \
musl-dev musl-dev
@ -13,11 +16,16 @@ COPY . .
RUN cargo build --release --target=$(arch)-unknown-linux-musl RUN cargo build --release --target=$(arch)-unknown-linux-musl
RUN cp "./target/$(arch)-unknown-linux-musl/release/little-hesinde" /app RUN cp "./target/$(arch)-unknown-linux-musl/release/little-hesinde" /app
FROM scratch FROM scratch
COPY --from=builder /passwd /etc/passwd
COPY --from=builder /app /app 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 EXPOSE 3000

View File

@ -12,6 +12,8 @@ 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 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. do but luckily enough it is the part I need for myself.
![Screenshot](screenshot.jpg)
# Building # Building
## Nix ## Nix
@ -31,10 +33,24 @@ From there on `cargo run` and `cargo build` and so on can be used.
# Configuration # 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 Arguments:
interfaces. <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 # Usage
@ -48,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. 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? ## Why are the OPDS entries not paginated?
My hardware (a Kobo Aura One from ~2016) with KOReader works perfectly fine with 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 parsing the 1MB book feed from my own library. Once that changes I might get
my laziness and implement it. over my laziness and implement it.
## Aren't these database access patterns inefficient? ## Aren't these database access patterns inefficient?

View File

@ -12,5 +12,6 @@ r2d2 = "0.8.10"
r2d2_sqlite = "0.24.0" r2d2_sqlite = "0.24.0"
rusqlite = { version = "0.31.0", features = ["bundled", "time"] } rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
serde = { workspace = true } serde = { workspace = true }
tempfile = "3.10.1"
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }

View File

@ -1,12 +1,16 @@
//! Bundle all functions together. //! Bundle all functions together.
use std::path::Path; use std::path::{Path, PathBuf};
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use tempfile::NamedTempFile;
use crate::data::{ use crate::{
author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series, 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 /// 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)] #[derive(Debug, Clone)]
pub struct Calibre { pub struct Calibre {
pool: Pool<SqliteConnectionManager>, pool: Pool<SqliteConnectionManager>,
search_db_path: PathBuf,
} }
impl Calibre { impl Calibre {
@ -24,7 +29,20 @@ impl Calibre {
let manager = SqliteConnectionManager::file(path); let manager = SqliteConnectionManager::file(path);
let pool = r2d2::Pool::new(manager)?; 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 /// 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. //! Error handling for calibre database access.
use std::io;
use tempfile::PersistError;
use thiserror::Error; use thiserror::Error;
use time::error::Parse; use time::error::Parse;
@ -16,9 +19,15 @@ pub enum DataStoreError {
/// Error connecting to the database. /// Error connecting to the database.
#[error("connection error")] #[error("connection error")]
ConnectionError(#[from] r2d2::Error), ConnectionError(#[from] r2d2::Error),
/// Error wparsing a datetime from the database. /// Error parsing a datetime from the database.
#[error("failed to parse datetime")] #[error("failed to parse datetime")]
DateTimeError(#[from] Parse), 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 /// 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. //! Read data from a calibre library, leveraging its SQLite metadata database.
pub mod calibre; pub mod calibre;
pub mod search;
/// Data structs for the calibre database. /// Data structs for the calibre database.
pub mod data { pub mod data {
pub mod author; 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", "AGPL-3.0",
"Apache-2.0", "Apache-2.0",
"BSD-3-Clause", "BSD-3-Clause",
"BSL-1.0",
"MIT", "MIT",
"Unicode-DFS-2016" "Unicode-DFS-2016"
] ]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "little-hesinde" name = "little-hesinde"
version = "0.1.1" version = "0.3.1"
edition = "2021" edition = "2021"
license = { workspace = true } license = { workspace = true }
authors = { workspace = true } authors = { workspace = true }
@ -9,23 +9,25 @@ description = "A very simple ebook server for a calibre library, providing a htm
[dependencies] [dependencies]
calibre-db = { path = "../calibre-db/", version = "0.1.0" } calibre-db = { path = "../calibre-db/", version = "0.1.0" }
clap = { version = "4.5.4", features = ["derive"] } clap = { version = "4.5.7", features = ["derive", "env"] }
image = { version = "0.25.1", default-features = false, features = ["jpeg", "rayon"] }
once_cell = "1.19.0" once_cell = "1.19.0"
poem = { version = "3.0.0", features = ["embed", "static-files"] } poem = { version = "3.0.1", features = ["embed", "static-files"] }
rust-embed = "8.3.0" rust-embed = "8.4.0"
sha2 = "0.10.8"
serde = { workspace = true } serde = { workspace = true }
serde_json = "1.0.116" serde_json = "1.0.118"
serde_with = "3.8.1" serde_with = "3.8.1"
tera = "1.19.1" tera = "1.20.0"
thiserror = { workspace = true } thiserror = { workspace = true }
time = { 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" tokio-util = "0.7.11"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } uuid = { version = "1.9.1", features = ["v4", "fast-rng"] }
quick-xml = { version = "0.31.0", features = ["serialize"] } quick-xml = { version = "0.34.0", features = ["serialize"] }
[build-dependencies] [build-dependencies]
ignore = "0.4.22" ignore = "0.4.22"
zip = { version = "1.2.2", default-features = false, features = ["deflate"] } zip = { version = "2.1.3", default-features = false, features = ["deflate"] }

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)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Cli { 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 /// Calibre library path
#[arg(last = true)] #[arg(env, last = true)]
pub library_path: String, pub library_path: String,
} }

View File

@ -1,8 +1,14 @@
//! Configuration data. //! Configuration data.
use std::path::{Path, PathBuf}; use std::{
env, fs, io,
net::SocketAddr,
net::ToSocketAddrs,
path::{Path, PathBuf},
};
use thiserror::Error; use thiserror::Error;
use tracing::info;
use crate::cli::Cli; use crate::cli::Cli;
@ -15,14 +21,25 @@ pub enum ConfigError {
/// Calibre database does not exist. /// Calibre database does not exist.
#[error("no metadata.db in {0}")] #[error("no metadata.db in {0}")]
MetadataNotFound(String), 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. /// Application configuration.
#[derive(Debug, Clone)]
pub struct Config { pub struct Config {
/// Calibre library folder. /// Calibre library folder.
pub library_path: PathBuf, pub library_path: PathBuf,
/// Calibre metadata file path. /// Calibre metadata file path.
pub metadata_path: PathBuf, pub metadata_path: PathBuf,
/// Address to listen on.
pub listen_address: SocketAddr,
/// Path to data like thumbnails.
pub cache_path: PathBuf,
} }
impl Config { impl Config {
@ -48,10 +65,32 @@ impl Config {
.to_string(); .to_string();
return Err(ConfigError::MetadataNotFound(metadata_path)); 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 { Ok(Self {
library_path, library_path,
metadata_path, metadata_path,
listen_address,
cache_path,
}) })
} }
} }

View File

@ -4,12 +4,20 @@ use std::sync::Arc;
use calibre_db::data::pagination::SortOrder; use calibre_db::data::pagination::SortOrder;
use poem::{ use poem::{
error::NotFoundError,
handler, handler,
web::{Data, Path}, web::{Data, Path},
Response, 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. /// Handle a request for multiple books, starting at the first.
#[handler] #[handler]
@ -31,6 +39,31 @@ pub async fn handler(
books(&accept, &state, Some(&cursor), &sort_order).await 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( async fn books(
accept: &Accept, accept: &Accept,
state: &Arc<AppState>, state: &Arc<AppState>,

View File

@ -1,31 +1,74 @@
//! Handle requests for cover images. //! 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::{ use poem::{
error::NotFoundError, error::NotFoundError,
handler, handler,
web::{headers::ContentType, Data, Path, WithContentType}, web::{headers::ContentType, Data, Path},
IntoResponse, 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`. /// Handle a request for the cover image of book with id `id`.
#[handler] #[handler]
pub async fn handler( pub async fn handler_full(
id: Path<u64>, id: Path<u64>,
state: Data<&Arc<AppState>>, state: Data<&Arc<AppState>>,
) -> Result<WithContentType<Vec<u8>>, poem::Error> { ) -> Result<Response, poem::Error> {
let book = state cover(&state.calibre, &state.config, *id, |cover_path, _| {
.calibre Ok(File::open(cover_path)?)
.scalar_book(*id) })
.map_err(HandlerError::DataError)?; .await
let cover_path = state.config.library_path.join(book.path).join("cover.jpg"); }
let mut cover = File::open(cover_path).map_err(|_| NotFoundError)?;
async fn cover<F>(
let mut data = Vec::new(); calibre: &Calibre,
cover.read_to_end(&mut data).map_err(|_| NotFoundError)?; config: &Config,
id: u64,
Ok(data.with_content_type(ContentType::jpeg().to_string())) 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. //! Handle requests for specific formats of a book.
use std::sync::Arc; use tokio::io::AsyncRead;
use tokio::fs::File; use poem::{Body, IntoResponse, Response};
use poem::{
error::NotFoundError,
handler,
web::{Data, Path},
Body, IntoResponse, Response,
};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::{ /// Handle a request for file.
app_state::AppState, ///
data::book::{Book, Format}, /// Must not be used directly from a route as that makes it vulnerable to path traversal attacks.
handlers::error::HandlerError, pub async fn handler<A: AsyncRead + Send + 'static>(
opds::media_type::MediaType, file_name: &str,
}; reader: A,
content_type: &str,
/// 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>>,
) -> Result<Response, poem::Error> { ) -> Result<Response, poem::Error> {
let book = state let stream = ReaderStream::new(reader);
.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 body = Body::from_bytes_stream(stream); let body = Body::from_bytes_stream(stream);
let content_type: MediaType = format.into();
Ok(body Ok(body
.with_content_type(format!("{content_type}")) .with_content_type(content_type)
.with_header("Content-Disposition", format!("filename=\"{file_name}\"")) .with_header("Content-Disposition", format!("filename=\"{file_name}\""))
.into_response()) .into_response())
} }

View File

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

@ -23,7 +23,11 @@ pub async fn handler(
.books(u32::MAX.into(), None, &SortOrder::ASC) .books(u32::MAX.into(), None, &SortOrder::ASC)
.map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect())
.map_err(HandlerError::DataError)?; .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 entries: Vec<Entry> = books.into_iter().map(Entry::from).collect();
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();

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

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

@ -1,20 +1,11 @@
use crate::{APP_NAME, VERSION}; use crate::{APP_NAME, VERSION};
use poem::{handler, Body, IntoResponse, Response}; use poem::{handler, Response};
use tokio_util::io::ReaderStream;
const SOURCE_ARCHIVE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/archive.zip")); const SOURCE_ARCHIVE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/archive.zip"));
/// Handle a request for source code of the server.. /// Handle a request for source code of the server..
#[handler] #[handler]
pub async fn handler() -> Result<Response, poem::Error> { pub async fn handler() -> Result<Response, poem::Error> {
let stream = ReaderStream::new(SOURCE_ARCHIVE); let file_name = format!("{APP_NAME}-{VERSION}.zip");
let body = Body::from_bytes_stream(stream); crate::handlers::download::handler(&file_name, SOURCE_ARCHIVE, "application/zip").await
Ok(body
.with_content_type("application/zip")
.with_header(
"Content-Disposition",
format!("filename=\"{APP_NAME}-{VERSION}.zip\""),
)
.into_response())
} }

View File

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

View File

@ -61,6 +61,13 @@ impl Feed {
title: Some("Home".to_string()), title: Some("Home".to_string()),
count: None, 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, self_link,
]; ];
links.append(&mut additional_links); links.append(&mut additional_links);
@ -80,7 +87,7 @@ impl Feed {
pub fn as_xml(&self) -> Result<String, OpdsError> { pub fn as_xml(&self) -> Result<String, OpdsError> {
let xml = to_string(&self)?; let xml = to_string(&self)?;
let mut reader = Reader::from_str(&xml); 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 declaration = BytesDecl::new("1.0", Some("UTF-8"), None);
let mut writer = Writer::new(Cursor::new(Vec::new())); let mut writer = Writer::new(Cursor::new(Vec::new()));

View File

@ -16,6 +16,7 @@ pub enum MediaType {
Navigation, Navigation,
Pdf, Pdf,
Text, Text,
Search,
} }
/// Convert `epub` and `pdf` formats to their respective media type. Everything else is `Text`. /// 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::Pdf => write!(f, "application/pdf"),
MediaType::Text => write!(f, "text"), MediaType::Text => write!(f, "text"),
MediaType::Search => write!(f, "application/opensearchdescription+xml"),
} }
} }
} }

View File

@ -14,6 +14,7 @@ pub enum Relation {
Subsection, Subsection,
Thumbnail, Thumbnail,
Acquisition, Acquisition,
Search,
} }
/// Convert a media type int a relation. /// Convert a media type int a relation.
@ -29,6 +30,7 @@ impl From<MediaType> for Relation {
MediaType::Navigation => Relation::Myself, MediaType::Navigation => Relation::Myself,
MediaType::Pdf => Relation::Acquisition, MediaType::Pdf => Relation::Acquisition,
MediaType::Text => 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::Subsection => write!(f, "subsection"),
Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"), Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"),
Relation::Acquisition => write!(f, "http://opds-spec.org/acquisition"), 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")), ("book_list", include_str!("../templates/book_list.html")),
("books", include_str!("../templates/books.html")), ("books", include_str!("../templates/books.html")),
("series", include_str!("../templates/series.html")), ("series", include_str!("../templates/series.html")),
("empty", include_str!("../templates/empty.html")),
]) ])
.expect("failed to parse tera templates"); .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; padding-bottom: 0.25rem;
} }
.nav-input {
margin-bottom: 0;
}
.nav-active { .nav-active {
border-bottom: solid var(--pico-primary-underline); border-bottom: solid var(--pico-primary-underline);
} }

View File

@ -1,12 +1,12 @@
{% extends "base" %} {% extends "base" %}
{% block title %} {% block title %}
{% if has_previous %} {% 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 %} {% endif %}
{% if has_previous and has_more %}|{% endif%} {% if has_previous and has_more %}|{% endif%}
{% if has_more %} {% 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 %} {% endif %}
{% endblock title %} {% endblock title %}

View File

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark" /> <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/pico.min.css" />
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<title>Little Hesinde</title> <title>Little Hesinde</title>
@ -11,6 +12,21 @@
<body> <body>
<header class="container fixed"> <header class="container fixed">
<nav> <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> <ul>
<li>{% block title %}<strong>{{ title }}</strong>{% endblock title %}</li> <li>{% block title %}<strong>{{ title }}</strong>{% endblock title %}</li>
</ul> </ul>

View File

@ -14,7 +14,7 @@
{% endif %} {% endif %}
</hgroup> </hgroup>
</header> </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> <footer>
<form> <form>
<fieldset role="group"> <fieldset role="group">

View File

@ -1,12 +1,12 @@
{% extends "base" %} {% extends "base" %}
{% block title %} {% block title %}
{% if has_previous %} {% 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 %} {% endif %}
{% if has_previous and has_more %}|{% endif%} {% if has_previous and has_more %}|{% endif%}
{% if has_more %} {% 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 %} {% endif %}
{% endblock title %} {% 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" %} {% extends "base" %}
{% block title %} {% block title %}
{% if has_previous %} {% 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 %} {% endif %}
{% if has_previous and has_more %}|{% endif%} {% if has_previous and has_more %}|{% endif%}
{% if has_more %} {% 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 %} {% endif %}
{% endblock title %} {% endblock title %}

BIN
screenshot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB