diff --git a/.DS_Store b/.DS_Store index 1f4aa303..fb6c2e8d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 5520f3bb..7887250f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,7 @@ xxx /dcai/ **/.pmomusic.yml **/.pmomusic_covers/** -**/.DS_Strore/** -**/.DS_Strore +.DS_Store /target/ .pmomusic_covers C/src/soxr-0.1.3/Release/tests diff --git a/.pmomusic.yml b/.pmomusic.yml index e07192c9..985ac37b 100644 --- a/.pmomusic.yml +++ b/.pmomusic.yml @@ -1,5 +1,11 @@ +host: + http_port: '8080' + cover_cache: + directory: ./.pmomusic_covers + size: 2000 devices: mediarenderer: + mpd_renderer: null fakerenderer: udn: d7eaad15-7d21-4411-926a-bc1eea0713db mediarenderer: @@ -7,5 +13,3 @@ devices: mediaserver: qobuz: udn: 28963b75-4c5f-4da7-b10e-ffafd -host: - http_port: '8080' diff --git a/Cargo.lock b/Cargo.lock index 6e50a34f..a7109dae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "axum", "pmoapp", "pmoconfig", + "pmocovers", "pmoserver", "pmoupnp", "serde_json", @@ -32,6 +33,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -41,6 +54,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -65,6 +87,23 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_type_match" version = "0.1.1" @@ -121,6 +160,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av1-grain" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.6" @@ -254,7 +316,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "toml_edit", + "toml_edit 0.23.6", ] [[package]] @@ -266,7 +328,7 @@ dependencies = [ "critical-section", "foldhash", "futures-channel", - "hashbrown", + "hashbrown 0.16.0", "js-sys", "portable-atomic", "portable-atomic-util", @@ -303,7 +365,7 @@ dependencies = [ "serde", "smallvec", "smol_str", - "thiserror", + "thiserror 2.0.17", "uuid", "variadics_please", "wgpu-types", @@ -333,6 +395,12 @@ dependencies = [ "disqualified", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.9.4" @@ -342,6 +410,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "block-buffer" version = "0.10.4" @@ -351,6 +425,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "bumpalo" version = "3.19.0" @@ -377,6 +457,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" @@ -396,9 +482,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -419,6 +517,12 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -459,6 +563,37 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -563,6 +698,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -572,6 +713,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -599,12 +760,68 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.2" @@ -818,6 +1035,16 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -833,6 +1060,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.12" @@ -852,6 +1085,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -862,6 +1114,21 @@ dependencies = [ "serde", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -1126,6 +1393,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "2.11.4" @@ -1133,11 +1440,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", "serde", "serde_core", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "inventory" version = "0.3.21" @@ -1174,12 +1492,31 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -1196,12 +1533,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libredox" version = "0.1.10" @@ -1212,6 +1565,27 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -1249,6 +1623,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1264,6 +1647,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1286,6 +1679,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1293,6 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1306,6 +1706,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc7d85f3d741164e8972ad355e26ac6e51b20fcae5f911c7da8f2d8bbbb3f33" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1323,6 +1733,28 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1332,12 +1764,53 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1447,6 +1920,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1507,6 +1986,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "pmocovers" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "hex", + "image", + "pmoconfig", + "pmoserver", + "reqwest", + "rusqlite", + "serde", + "sha1", + "tokio", + "tracing", + "utoipa", + "webp", +] + [[package]] name = "pmodidl" version = "0.1.0" @@ -1560,7 +2060,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -1576,6 +2076,19 @@ dependencies = [ "os_info", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1606,6 +2119,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1615,6 +2137,49 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.3" @@ -1640,6 +2205,106 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -1657,7 +2322,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -1698,7 +2363,9 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1729,6 +2396,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.17.14" @@ -1743,6 +2416,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.7.2" @@ -1942,6 +2629,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1967,6 +2663,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2008,6 +2715,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "slab" version = "0.4.11" @@ -2112,6 +2828,25 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.23.0" @@ -2125,13 +2860,33 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2154,6 +2909,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.44" @@ -2206,6 +2975,7 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", @@ -2269,6 +3039,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.2" @@ -2278,6 +3069,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.6" @@ -2285,7 +3089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.2", "toml_parser", "winnow", ] @@ -2521,6 +3325,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2544,6 +3359,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "version_check" version = "0.9.5" @@ -2675,6 +3496,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + [[package]] name = "wgpu-types" version = "26.0.0" @@ -2686,7 +3523,7 @@ dependencies = [ "js-sys", "log", "serde", - "thiserror", + "thiserror 2.0.17", "web-sys", ] @@ -2950,6 +3787,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -3041,3 +3898,27 @@ dependencies = [ "log", "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-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 7b568a3b..c44ba0a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "3" -members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"] +members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"] diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 7cdc02ef..cb1e1bb3 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmoupnp = { path = "../pmoupnp"} pmoserver = { path = "../pmoserver" } +pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoapp = { path = "../pmoapp", features = ["pmoserver"] } diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 06dcb631..6c8c6248 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -9,6 +9,7 @@ use pmoserver::{ ServerBuilder }; use pmoapp::{Webapp, WebAppExt}; +use pmocovers::CoverCacheExt; use tracing::info; #[tokio::main] @@ -19,6 +20,18 @@ async fn main() { // Initialiser le logging et enregistrer les routes de logs server.init_logging(LoggingOptions::default()).await; + + info!("📡 Registering the cover cache..."); + let cache = server.init_cover_cache_configured() + .await + .expect("Cannot initialise the image cache"); + + info!("✅ Cover cache ready at {}", + cache.cache_dir(), + ); + + + // Routes de base server .add_route("/info", || async { @@ -26,6 +39,7 @@ async fn main() { }) .await; + // Ajouter la webapp via le trait WebAppExt info!("📡 Registering Web application..."); server.add_webapp_with_redirect::("/app").await; diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index f8a79926..db750d1a 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -2,7 +2,8 @@
diff --git a/pmoapp/webapp/src/components/CoverCacheManager.vue b/pmoapp/webapp/src/components/CoverCacheManager.vue new file mode 100644 index 00000000..5f43a341 --- /dev/null +++ b/pmoapp/webapp/src/components/CoverCacheManager.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 8cb9c97e..7aaaebc6 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -1,10 +1,12 @@ import { createRouter, createWebHistory } from "vue-router"; import HelloWorld from "../components/HelloWorld.vue"; import LogView from "../components/LogView.vue"; +import CoverCacheManager from "../components/CoverCacheManager.vue"; const routes = [ { path: "/", name: "home", component: HelloWorld }, { path: "/logs", name: "logs", component: LogView }, + { path: "/covers-cache", name: "covers-cache", component: CoverCacheManager }, ]; const router = createRouter({ diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts new file mode 100644 index 00000000..96c36d0d --- /dev/null +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -0,0 +1,120 @@ +/** + * Service API pour interagir avec le cache d'images de couvertures + */ + +export interface CacheEntry { + pk: string; + source_url: string; + hits: number; + last_used: string | null; +} + +export interface AddImageRequest { + url: string; +} + +export interface AddImageResponse { + pk: string; + url: string; + message: string; +} + +export interface ApiError { + error: string; + message: string; +} + +/** + * Liste toutes les images en cache + */ +export async function listImages(): Promise { + const response = await fetch("/api/covers/images"); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch images"); + } + return response.json(); +} + +/** + * RĂ©cupĂšre les informations d'une image spĂ©cifique + */ +export async function getImageInfo(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch image info"); + } + return response.json(); +} + +/** + * Ajoute une nouvelle image au cache depuis une URL + */ +export async function addImage(url: string): Promise { + const response = await fetch("/api/covers/images", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ url }), + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to add image"); + } + return response.json(); +} + +/** + * Supprime une image du cache + */ +export async function deleteImage(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to delete image"); + } +} + +/** + * Purge complĂštement le cache + */ +export async function purgeCache(): Promise { + const response = await fetch("/api/covers/images", { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to purge cache"); + } +} + +/** + * Consolide le cache (re-tĂ©lĂ©charge les images manquantes) + */ +export async function consolidateCache(): Promise { + const response = await fetch("/api/covers/images/consolidate", { + method: "POST", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to consolidate cache"); + } +} + +/** + * GĂ©nĂšre l'URL pour afficher une image + */ +export function getImageUrl(pk: string, size?: number): string { + if (size) { + return `/covers/images/${pk}/${size}`; + } + return `/covers/images/${pk}`; +} diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index 580ed071..1633c7d8 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -40,10 +40,13 @@ impl Clone for Config { } impl Config { + pub fn load_config(filename: &str) -> Result { let mut path = filename.to_string(); let mut data: Option> = None; + let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?; + // Essayer de charger depuis diffĂ©rents emplacements if !filename.is_empty() { info!(config_file=%path, "Trying to load config"); @@ -97,8 +100,11 @@ impl Config { DEFAULT_CONFIG.as_bytes().to_vec() }; - let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?; - config_value = Self::lower_keys_value(config_value); + + let external_value: Value = serde_yaml::from_slice(&yaml_data)?; + merge_yaml(&mut default_value, &external_value); + let mut config_value = Self::lower_keys_value(default_value); + Self::apply_env_overrides(&mut config_value); if path.is_empty() || !Self::is_writable(&path) { @@ -175,8 +181,10 @@ impl Config { fn get_value_internal(data: &Value, path: &[&str]) -> Result { let mut current = data; for (i, key) in path.iter().enumerate() { + if let Value::Mapping(map) = current { let key = key.to_lowercase(); + if let Some(next) = map.get(&Value::String(key)) { current = next; } else { @@ -317,3 +325,17 @@ impl Config { pub fn get_config() -> Arc { CONFIG.clone() } + +fn merge_yaml(default: &mut Value, external: &Value) { + match (default, external) { + (Value::Mapping(dmap), Value::Mapping(emap)) => { + for (k, v) in emap { + match dmap.get_mut(k) { + Some(dv) => merge_yaml(dv, v), + None => { dmap.insert(k.clone(), v.clone()); } + } + } + } + (d, e) => *d = e.clone(), // pour les scalaires ou sĂ©quences, on remplace + } +} diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index c2acfad7..047af744 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -1,6 +1,6 @@ host: http_port: "8080" - cover_cache: + cover_cache: directory: "./.pmomusic_covers" size: 2000 devices: diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml new file mode 100644 index 00000000..feeca835 --- /dev/null +++ b/pmocovers/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "pmocovers" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Gestion d'images +image = "0.25" +webp = "0.3" + +# Base de donnĂ©es +rusqlite = { version = "0.32", features = ["bundled"] } + +# HTTP client +reqwest = { version = "0.12", features = ["blocking"] } + +# Cryptographie +sha1 = "0.10" +hex = "0.4" + +# Utilitaires +anyhow = "1.0" +chrono = "0.4" +serde = { version = "1.0", features = ["derive"] } + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Serveur HTTP (optionnel pour l'extension) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +tracing = "0.1.41" + +[features] +default = ["pmoserver"] +pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"] diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs new file mode 100644 index 00000000..84a31330 --- /dev/null +++ b/pmocovers/src/api.rs @@ -0,0 +1,311 @@ +//! API REST pour la gestion du cache de couvertures +//! +//! Ce module expose une API REST documentĂ©e avec OpenAPI/Swagger pour : +//! - Lister les images en cache +//! - Ajouter des images depuis une URL +//! - Supprimer des images +//! - Consulter les statistiques + +use crate::{Cache, CacheEntry}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +/// RequĂȘte pour ajouter une image au cache +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageRequest { + /// URL de l'image source + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, +} + +/// RĂ©ponse aprĂšs ajout d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageResponse { + /// ClĂ© primaire (pk) de l'image ajoutĂ©e + #[schema(example = "1a2b3c4d5e6f7a8b")] + pub pk: String, + /// URL source de l'image + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, + /// Message de succĂšs + #[schema(example = "Image added successfully")] + pub message: String, +} + +/// RĂ©ponse de suppression d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DeleteImageResponse { + /// Message de succĂšs + #[schema(example = "Image deleted successfully")] + pub message: String, +} + +/// RĂ©ponse d'erreur gĂ©nĂ©rique +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ErrorResponse { + /// Code d'erreur + #[schema(example = "NOT_FOUND")] + pub error: String, + /// Message descriptif + #[schema(example = "Image not found in cache")] + pub message: String, +} + +/// Liste toutes les images en cache avec leurs statistiques +/// +/// Retourne la liste complĂšte des entrĂ©es du cache triĂ©es par nombre d'accĂšs dĂ©croissant. +#[utoipa::path( + get, + path = "/api/covers", + responses( + (status = 200, description = "Liste des images en cache", body = Vec), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn list_images(State(cache): State>) -> impl IntoResponse { + match cache.db.get_all() { + Ok(entries) => (StatusCode::OK, Json(entries)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot retrieve cache entries: {}", e), + }), + ) + .into_response(), + } +} + +/// RĂ©cupĂšre les informations d'une image spĂ©cifique +/// +/// Retourne les mĂ©tadonnĂ©es d'une image identifiĂ©e par sa clĂ© (pk). +#[utoipa::path( + get, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "ClĂ© primaire de l'image", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Informations de l'image", body = CacheEntry), + (status = 404, description = "Image non trouvĂ©e", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn get_image_info( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + match cache.db.get(&pk) { + Ok(entry) => (StatusCode::OK, Json(entry)).into_response(), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(), + } +} + +/// Ajoute une image au cache depuis une URL +/// +/// TĂ©lĂ©charge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache. +/// Si l'image existe dĂ©jĂ , elle est mise Ă  jour. +#[utoipa::path( + post, + path = "/api/covers", + request_body = AddImageRequest, + responses( + (status = 201, description = "Image ajoutĂ©e avec succĂšs", body = AddImageResponse), + (status = 400, description = "RequĂȘte invalide", body = ErrorResponse), + (status = 500, description = "Erreur lors du tĂ©lĂ©chargement ou de la conversion", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn add_image( + State(cache): State>, + Json(req): Json, +) -> impl IntoResponse { + if req.url.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "INVALID_REQUEST".to_string(), + message: "URL cannot be empty".to_string(), + }), + ) + .into_response(); + } + + match cache.add_from_url(&req.url).await { + Ok(pk) => ( + StatusCode::CREATED, + Json(AddImageResponse { + pk, + url: req.url, + message: "Image added successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PROCESSING_ERROR".to_string(), + message: format!("Cannot add image: {}", e), + }), + ) + .into_response(), + } +} + +/// Supprime une image du cache +/// +/// Supprime l'image et toutes ses variantes du disque et de la base de donnĂ©es. +#[utoipa::path( + delete, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "ClĂ© primaire de l'image Ă  supprimer", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Image supprimĂ©e avec succĂšs", body = DeleteImageResponse), + (status = 404, description = "Image non trouvĂ©e", body = ErrorResponse), + (status = 500, description = "Erreur lors de la suppression", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn delete_image( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + // VĂ©rifier que l'image existe + if cache.db.get(&pk).is_err() { + return ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(); + } + + // Supprimer les fichiers (original + variantes) + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + if let Err(e) = tokio::fs::remove_file(&orig_path).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "FILE_DELETE_ERROR".to_string(), + message: format!("Cannot delete original file: {}", e), + }), + ) + .into_response(); + } + } + + // Supprimer toutes les variantes (*.{pk}.*.webp) + if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Some(filename) = entry.file_name().to_str() { + if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + } + + // Supprimer de la base de donnĂ©es + match cache.db.delete(&pk) { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: format!("Image '{}' deleted successfully", pk), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot delete from database: {}", e), + }), + ) + .into_response(), + } +} + +/// Purge complĂštement le cache +/// +/// Supprime toutes les images et vide la base de donnĂ©es. OpĂ©ration irrĂ©versible. +#[utoipa::path( + delete, + path = "/api/covers", + responses( + (status = 200, description = "Cache purgĂ© avec succĂšs", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la purge", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn purge_cache(State(cache): State>) -> impl IntoResponse { + match cache.purge().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache purged successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PURGE_ERROR".to_string(), + message: format!("Cannot purge cache: {}", e), + }), + ) + .into_response(), + } +} + +/// Consolide le cache +/// +/// Re-tĂ©lĂ©charge les images manquantes et supprime les fichiers orphelins. +/// Utile pour rĂ©parer un cache corrompu. +#[utoipa::path( + post, + path = "/api/covers/consolidate", + responses( + (status = 200, description = "Cache consolidĂ© avec succĂšs", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn consolidate_cache(State(cache): State>) -> impl IntoResponse { + match cache.consolidate().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache consolidated successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "CONSOLIDATE_ERROR".to_string(), + message: format!("Cannot consolidate cache: {}", e), + }), + ) + .into_response(), + } +} diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs new file mode 100644 index 00000000..424ce87b --- /dev/null +++ b/pmocovers/src/cache.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; +use std::sync::Arc; +use anyhow::{anyhow, Result}; +use sha1::{Sha1, Digest}; +use tokio::sync::Mutex; +use crate::db::DB; +use crate::webp; + +#[derive(Debug)] +pub struct Cache { + pub(crate) dir: PathBuf, + pub(crate) limit: usize, + pub db: DB, + mu: Arc>, +} + +impl Cache { + pub fn new(dir: &str, limit: usize) -> Result { + std::fs::create_dir_all(dir)?; + let db = DB::init(&PathBuf::from(dir).join("cache.db"))?; + + Ok(Self { + dir: PathBuf::from(dir), + limit, + db, + mu: Arc::new(Mutex::new(())), + }) + } + + pub async fn add_from_url(&self, url: &str) -> Result { + let response = reqwest::get(url).await?; + if !response.status().is_success() { + return Err(anyhow!("Bad status: {}", response.status())); + } + + let data = response.bytes().await?; + self.add(url, &data).await + } + + pub async fn ensure_from_url(&self, url: &str) -> Result { + let pk = pk_from_url(url); + + if self.db.get(&pk).is_ok() { + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + return Ok(pk); + } + } + + self.add_from_url(url).await + } + + pub async fn add(&self, url: &str, data: &[u8]) -> Result { + let pk = pk_from_url(url); + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + + let _lock = self.mu.lock().await; + + if !orig_path.exists() { + let img = image::load_from_memory(data)?; + let webp_data = webp::encode_webp(&img)?; + tokio::fs::write(&orig_path, webp_data).await?; + } + + self.db.add(&pk, url)?; + Ok(pk) + } + + pub async fn get(&self, pk: &str) -> Result { + let _lock = self.mu.lock().await; + + self.db.get(pk)?; + self.db.update_hit(pk)?; + + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + Ok(orig_path) + } else { + Err(anyhow!("File not found")) + } + } + + pub async fn purge(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let mut entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.path().is_file() { + tokio::fs::remove_file(entry.path()).await?; + } + } + + self.db.purge().map_err(|e| anyhow!("Database error: {}", e)) + } + + pub async fn consolidate(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let entries = self.db.get_all()?; + + for entry in entries { + let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk)); + if !orig_path.exists() { + match reqwest::get(&entry.source_url).await { + Ok(response) if response.status().is_success() => { + let data = response.bytes().await?; + self.add(&entry.source_url, &data).await?; + } + _ => { + self.db.delete(&entry.pk)?; + } + } + } + } + + let mut dir_entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = dir_entries.next_entry().await? { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(".orig.webp") { + let pk = file_name.trim_end_matches(".orig.webp"); + if self.db.get(pk).is_err() { + tokio::fs::remove_file(path).await?; + } + } + } + } + } + + Ok(()) + } + + pub fn cache_dir(&self) -> String { + self.dir.to_string_lossy().to_string() + } + +} + +fn pk_from_url(url: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(url.as_bytes()); + let result = hasher.finalize(); + hex::encode(&result[..8]) +} \ No newline at end of file diff --git a/pmocovers/src/db.rs b/pmocovers/src/db.rs new file mode 100644 index 00000000..c7de1120 --- /dev/null +++ b/pmocovers/src/db.rs @@ -0,0 +1,118 @@ +use rusqlite::{Connection, params}; +use serde::Serialize; +use chrono::Utc; +use std::path::Path; +use std::sync::Mutex; + +#[cfg(feature = "pmoserver")] +use utoipa::ToSchema; + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "pmoserver", derive(ToSchema))] +pub struct CacheEntry { + /// ClĂ© primaire unique de l'image (hash SHA1 de l'URL) + #[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))] + pub pk: String, + /// URL source de l'image + #[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))] + pub source_url: String, + /// Nombre d'accĂšs Ă  l'image + #[cfg_attr(feature = "pmoserver", schema(example = 42))] + pub hits: i32, + /// Date/heure du dernier accĂšs (RFC3339) + #[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))] + pub last_used: Option, +} + +#[derive(Debug)] +pub struct DB { + conn: Mutex, +} + +impl DB { + pub fn init(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute( + "CREATE TABLE IF NOT EXISTS covers ( + pk TEXT PRIMARY KEY, + source_url TEXT, + hits INTEGER DEFAULT 0, + last_used TEXT + )", + [], + )?; + + Ok(Self { conn: Mutex::new(conn) }) + } + + pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO covers (pk, source_url, hits, last_used) + VALUES (?1, ?2, 0, ?3) + ON CONFLICT(pk) DO UPDATE SET + source_url = excluded.source_url, + last_used = excluded.last_used", + params![pk, url, Utc::now().to_rfc3339()], + )?; + + Ok(()) + } + + pub fn get(&self, pk: &str) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1", + [pk], + |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + }, + ) + } + + pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2", + params![Utc::now().to_rfc3339(), pk], + )?; + + Ok(()) + } + + pub fn purge(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers", [])?; + Ok(()) + } + + pub fn get_all(&self) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC", + )?; + + let entries = stmt.query_map([], |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + })? + .collect::>>()?; + + Ok(entries) + } + + pub fn delete(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?; + Ok(()) + } +} diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs new file mode 100644 index 00000000..ba64c6a5 --- /dev/null +++ b/pmocovers/src/lib.rs @@ -0,0 +1,277 @@ +//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic +//! +//! Cette crate fournit un systĂšme de cache d'images optimisĂ© pour les couvertures d'albums, +//! avec conversion automatique en WebP et gĂ©nĂ©ration de variantes de tailles. +//! +//! ## Vue d'ensemble +//! +//! `pmocovers` gĂšre le tĂ©lĂ©chargement, la conversion, le stockage et la distribution +//! d'images de couvertures d'albums, avec : +//! - Conversion automatique en WebP pour rĂ©duire la taille +//! - GĂ©nĂ©ration de variantes de tailles Ă  la demande +//! - Cache persistant avec base de donnĂ©es SQLite +//! - API HTTP pour rĂ©cupĂ©rer les images +//! +//! ## FonctionnalitĂ©s +//! +//! ### 📩 Gestion du cache +//! - TĂ©lĂ©chargement automatique depuis des URLs +//! - Conversion des images en WebP (format optimisĂ©) +//! - Stockage persistant sur disque +//! - Base de donnĂ©es SQLite pour le tracking +//! +//! ### 🎹 GĂ©nĂ©ration de variantes +//! - Redimensionnement automatique Ă  la demande +//! - CrĂ©ation d'images carrĂ©es avec centrage +//! - Cache des variantes gĂ©nĂ©rĂ©es +//! - Support de multiples tailles +//! +//! ### 📊 Statistiques d'utilisation +//! - Comptage des accĂšs (hits) +//! - Suivi de la derniĂšre utilisation +//! - API de statistiques complĂštes +//! +//! ## Architecture +//! +//! `pmocovers` suit le pattern d'extension des autres crates PMO : +//! +//! - `pmoserver` dĂ©finit un serveur HTTP gĂ©nĂ©rique +//! - `pmocovers` Ă©tend ce serveur avec des mĂ©thodes de cache via un trait +//! - Le serveur n'a pas besoin de connaĂźtre `pmocovers` +//! +//! ## Structure des fichiers +//! +//! ```text +//! pmocovers/ +//! ├── Cargo.toml +//! ├── src/ +//! │ ├── lib.rs # Module principal (ce fichier) +//! │ ├── cache.rs # Gestion du cache +//! │ ├── db.rs # Base de donnĂ©es SQLite +//! │ ├── webp.rs # Conversion et redimensionnement WebP +//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server +//! └── cache/ # RĂ©pertoire de cache (gĂ©nĂ©rĂ©) +//! ├── cache.db # Base SQLite +//! ├── *.orig.webp # Images originales +//! └── *.{size}.webp # Variantes de tailles +//! ``` +//! +//! ## Utilisation +//! +//! ### Exemple basique avec configuration automatique +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Utilise automatiquement la config (pmoconfig) +//! server.init_cover_cache_configured().await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec paramĂštres personnalisĂ©s +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // ParamĂštres personnalisĂ©s +//! server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Utilisation du cache directement +//! +//! ```rust,no_run +//! use pmocovers::Cache; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let cache = Cache::new("./cache", 1000)?; +//! +//! // Ajouter une image depuis une URL +//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?; +//! println!("Image ajoutĂ©e avec clĂ©: {}", pk); +//! +//! // RĂ©cupĂ©rer l'image originale +//! let path = cache.get(&pk).await?; +//! println!("Image stockĂ©e Ă : {:?}", path); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## API HTTP +//! +//! Une fois enregistrĂ© sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles : +//! +//! ### GET /covers/images/{pk} +//! RĂ©cupĂšre l'image originale en WebP +//! +//! ### GET /covers/images/{pk}/{size} +//! RĂ©cupĂšre une variante de taille spĂ©cifique (ex: `/covers/images/abc123/256`) +//! +//! ### GET /covers/stats +//! RĂ©cupĂšre les statistiques du cache (JSON) +//! +//! ## Format des clĂ©s (pk) +//! +//! Les images sont identifiĂ©es par une clĂ© (pk) dĂ©rivĂ©e de l'URL source : +//! - Hash SHA1 de l'URL +//! - EncodĂ© en hexadĂ©cimal (8 premiers octets) +//! - Exemple: `"1a2b3c4d5e6f7a8b"` +//! +//! ## Stockage +//! +//! Les fichiers sont organisĂ©s comme suit : +//! +//! ```text +//! cache/ +//! ├── cache.db # Base SQLite +//! ├── 1a2b3c4d.orig.webp # Image originale +//! ├── 1a2b3c4d.256.webp # Variante 256x256 +//! └── 1a2b3c4d.512.webp # Variante 512x512 +//! ``` +//! +//! ## OpĂ©rations de maintenance +//! +//! ### Purge du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Supprimer tous les fichiers et entrĂ©es DB +//! cache.purge().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Consolidation du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Re-tĂ©lĂ©charger les images manquantes et supprimer les orphelins +//! cache.consolidate().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## DĂ©pendances principales +//! +//! - `image` : Chargement et manipulation d'images +//! - `webp` : Encodage WebP +//! - `rusqlite` : Base de donnĂ©es SQLite +//! - `reqwest` : TĂ©lĂ©chargement HTTP +//! - `sha1` : GĂ©nĂ©ration de clĂ©s +//! +//! ## Voir aussi +//! +//! - [`pmoserver`] : Serveur HTTP Axum +//! - [`pmoapp`] : Application web frontend +//! - [`pmoupnp`] : BibliothĂšque UPnP MediaRenderer + +pub mod cache; +pub mod db; +pub mod webp; + +#[cfg(feature = "pmoserver")] +pub mod api; + +#[cfg(feature = "pmoserver")] +pub mod openapi; + +pub use cache::Cache; +pub use db::{CacheEntry, DB}; + +#[cfg(feature = "pmoserver")] +pub use openapi::ApiDoc; + +use anyhow::Result; +use std::sync::Arc; + +/// Trait pour Ă©tendre un serveur HTTP avec des fonctionnalitĂ©s de cache d'images. +/// +/// Ce trait permet Ă  `pmocovers` d'ajouter des mĂ©thodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dĂ©pendent de `pmocovers`. +/// +/// # Architecture +/// +/// Similaire au pattern utilisĂ© par `pmoapp` pour `WebAppExt`, ce trait permet +/// une extension propre et dĂ©couplĂ©e : +/// +/// - `pmoserver` dĂ©finit un serveur HTTP gĂ©nĂ©rique +/// - `pmocovers` Ă©tend ce serveur avec des mĂ©thodes de cache via ce trait +/// - Le serveur n'a pas besoin de connaĂźtre `pmocovers` +pub trait CoverCacheExt { + /// Initialise le cache d'images et enregistre les routes HTTP. + /// + /// # Arguments + /// + /// * `cache_dir` - RĂ©pertoire de stockage du cache + /// * `limit` - Limite de taille du cache (en nombre d'images) + /// + /// # Returns + /// + /// * `Arc` - Instance partagĂ©e du cache + /// + /// # Routes enregistrĂ©es + /// + /// - `GET /covers/images/{pk}` - Image originale + /// - `GET /covers/images/{pk}/{size}` - Variante de taille + /// - `GET /covers/stats` - Statistiques + /// - `GET /api/covers` - Liste des images (API REST) + /// - `POST /api/covers` - Ajouter une image (API REST) + /// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST) + /// - `GET /swagger-ui` - Documentation interactive + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result>; + + /// Initialise le cache d'images avec la configuration par dĂ©faut. + /// + /// Utilise automatiquement les paramĂštres de `pmoconfig::Config` : + /// - `host.cover_cache.directory` pour le rĂ©pertoire + /// - `host.cover_cache.size` pour la limite de taille + /// + /// # Returns + /// + /// * `Arc` - Instance partagĂ©e du cache + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_cover_cache_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_cover_cache_configured(&mut self) -> Result>; +} + +// ImplĂ©mentation du trait pour pmoserver::Server (feature-gated) +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs new file mode 100644 index 00000000..a86cfd0b --- /dev/null +++ b/pmocovers/src/openapi.rs @@ -0,0 +1,70 @@ +//! Documentation OpenAPI pour l'API REST du cache de couvertures + +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::api::list_images, + crate::api::get_image_info, + crate::api::add_image, + crate::api::delete_image, + crate::api::purge_cache, + crate::api::consolidate_cache, + ), + components( + schemas( + crate::db::CacheEntry, + crate::api::AddImageRequest, + crate::api::AddImageResponse, + crate::api::DeleteImageResponse, + crate::api::ErrorResponse, + ) + ), + tags( + (name = "covers", description = "Gestion du cache d'images de couvertures") + ), + info( + title = "PMOCovers API", + version = "0.1.0", + description = r#" +# API de gestion du cache d'images de couvertures + +Cette API permet de gĂ©rer un cache d'images optimisĂ© pour les couvertures d'albums. + +## FonctionnalitĂ©s + +- **Ajout d'images** : TĂ©lĂ©chargement depuis une URL avec conversion automatique en WebP +- **Consultation** : Liste des images avec statistiques d'utilisation +- **Suppression** : Suppression individuelle ou purge complĂšte +- **Maintenance** : Consolidation du cache pour rĂ©parer les incohĂ©rences + +## Format des images + +Les images sont stockĂ©es au format WebP avec : +- Une version originale (`{pk}.orig.webp`) +- Des variantes de tailles gĂ©nĂ©rĂ©es Ă  la demande (`{pk}.{size}.webp`) + +## ClĂ©s (pk) + +Chaque image est identifiĂ©e par une clĂ© (pk) unique : +- Hash SHA1 des 8 premiers octets de l'URL source +- Encodage hexadĂ©cimal +- Exemple : `1a2b3c4d5e6f7a8b` + +## Statistiques + +Le systĂšme suit automatiquement : +- Le nombre d'accĂšs (hits) +- La date du dernier accĂšs +- L'URL source originale + "#, + contact( + name = "PMOMusic", + ), + license( + name = "MIT", + ), + ) +)] +pub struct ApiDoc; diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs new file mode 100644 index 00000000..18743e5b --- /dev/null +++ b/pmocovers/src/pmoserver_impl.rs @@ -0,0 +1,166 @@ +//! ImplĂ©mentation du trait CoverCacheExt pour le serveur pmoserver +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalitĂ©s de cache d'images en +//! implĂ©mentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implĂ©mentation +//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP. +//! +//! ## Architecture +//! +//! `pmocovers` Ă©tend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`. +//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalitĂ©s Ă  un type +//! externe via un trait, similaire au pattern utilisĂ© par `pmoapp` pour `WebAppExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // Le trait CoverCacheExt est automatiquement disponible +//! let cache = server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::{api, Cache, CoverCacheExt}; +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use pmoserver::Server; +use tracing::{debug, info}; +use std::sync::Arc; +use utoipa::OpenApi; + + + +/// Handler pour GET /covers/images/{pk} +async fn get_cover_image( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 4 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + + match cache.get(pk).await { + Ok(file_path) => { + match tokio::fs::read(&file_path).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + } + } + Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(), + } +} + +/// Handler pour GET /covers/images/{pk}/{size} +async fn get_cover_variant( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk et size du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 5 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + let size = match parts[4].parse::() { + Ok(s) => s, + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(), + }; + + match crate::webp::generate_variant(&cache, pk, size).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(), + } +} + +/// Handler pour GET /covers/stats +async fn get_cover_stats(State(cache): State>) -> Response { + match cache.db.get_all() { + Ok(entries) => Json(entries).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(), + } +} + +impl CoverCacheExt for Server { + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result> { + let cache = Arc::new(Cache::new(cache_dir, limit)?); + + // Enregistrer les routes HTTP classiques + self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await; + self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; + + // Router API RESTful + // Router API RESTful montĂ© sur /api/covers + let api_router = Router::new() + // Liste et ajout + .route( + "/images/", + get(api::list_images) // GET /api/covers + .post(api::add_image) // POST /api/covers + .delete(api::purge_cache), // DELETE /api/covers + ) + // Ressource unique + .route( + "/images/{pk}", + get(api::get_image_info) // GET /api/covers/{pk} + .delete(api::delete_image), // DELETE /api/covers/{pk} + ) + // Action spĂ©cifique + .route( + "/images/consolidate", + post(api::consolidate_cache), // POST /api/covers/consolidate + ) + .with_state(cache.clone()); + + // Documentation OpenAPI via Utoipa + let openapi = crate::ApiDoc::openapi(); + + // Enregistrer l'API avec Swagger UI + // /api/covers/images... et /swagger-ui/covers + self.add_openapi(api_router, openapi, "covers").await; + + Ok(cache) + } + + async fn init_cover_cache_configured(&mut self) -> anyhow::Result> { + let config = pmoconfig::get_config(); + + let cache_dir = config.get_cover_cache_dir()?; + let limit = config.get_cover_cache_size()?; + + info!("cache directory {}, size {}",cache_dir,limit); + + self.init_cover_cache(&cache_dir, limit).await + } +} diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs new file mode 100644 index 00000000..42f1d7c3 --- /dev/null +++ b/pmocovers/src/webp.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use image::{DynamicImage, imageops::FilterType}; +use webp::{Encoder, WebPMemory}; + +pub fn encode_webp(img: &DynamicImage) -> Result> { + let rgb_img = img.to_rgba8(); + let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height()); + let webp_data: WebPMemory = encoder.encode(85.0); + Ok(webp_data.to_vec()) +} + +pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { + let (width, height) = (img.width(), img.height()); + + // Calculer le ratio de mise Ă  l'Ă©chelle + let scale = if width > height { + size as f32 / width as f32 + } else { + size as f32 / height as f32 + }; + + let new_width = (width as f32 * scale) as u32; + let new_height = (height as f32 * scale) as u32; + + // Redimensionner l'image + let resized = img.resize(new_width, new_height, FilterType::Lanczos3); + + // CrĂ©er une image carrĂ©e avec fond transparent + let mut square = DynamicImage::new_rgba8(size, size); + + // Calculer la position pour centrer l'image redimensionnĂ©e + let x = (size - new_width) / 2; + let y = (size - new_height) / 2; + + // Copier l'image redimensionnĂ©e au centre du carrĂ© + image::imageops::overlay(&mut square, &resized, x.into(), y.into()); + + square +} + +pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result> { + let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size)); + + if variant_path.exists() { + return Ok(tokio::fs::read(variant_path).await?); + } + + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + + // Charger l'image de maniĂšre synchrone (image::open n'est pas async) + let img = tokio::task::spawn_blocking(move || { + image::open(orig_path) + }) + .await??; + + let square = ensure_square(&img, size as u32); + let webp_data = encode_webp(&square)?; + + tokio::fs::write(&variant_path, &webp_data).await?; + Ok(webp_data) +} diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 703cfa17..d22de2a0 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -13,6 +13,7 @@ //! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()` //! - ⚡ **Gestion gracieuse** : ArrĂȘt propre sur Ctrl+C +use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse}; use axum::handler::Handler; use axum::response::Redirect; use axum::routing::get; @@ -25,7 +26,6 @@ use std::{net::SocketAddr, sync::Arc}; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; -use crate::logs::{LogState, LoggingOptions, init_logging, log_sse, log_dump}; /// Info serveur sĂ©rialisable #[derive(Clone, Serialize, utoipa::ToSchema)] @@ -149,7 +149,7 @@ impl Server { /// ```ignore /// use pmoupnp::server::Server; /// use rust_embed::RustEmbed; - /// + /// /// #[derive(RustEmbed, Clone)] /// #[folder = "static/"] /// struct Assets; @@ -166,9 +166,9 @@ impl Server { E: RustEmbed + Clone + Send + Sync + 'static, { let serve = ServeEmbed::::new(); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -226,9 +226,9 @@ impl Server { axum_embed::FallbackBehavior::Ok, Some("index.html".to_string()), ); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -290,18 +290,18 @@ impl Server { /// use axum::extract::State; /// use axum::response::sse::{Event, Sse, KeepAlive}; /// use tokio::sync::broadcast; - /// + /// /// #[derive(Clone)] - /// struct LogState { - /// tx: broadcast::Sender + /// struct LogState { + /// tx: broadcast::Sender /// } - /// - /// impl LogState { - /// fn subscribe(&self) -> broadcast::Receiver { - /// self.tx.subscribe() - /// } + /// + /// impl LogState { + /// fn subscribe(&self) -> broadcast::Receiver { + /// self.tx.subscribe() + /// } /// } - /// + /// /// async fn log_sse(State(state): State) -> Sse>> { /// let mut rx = state.subscribe(); /// let stream = async_stream::stream! { @@ -321,9 +321,7 @@ impl Server { T: 'static, S: Clone + Send + Sync + 'static, { - let route = Router::new() - .route("/", get(handler)) - .with_state(state); + let route = Router::new().route("/", get(handler)).with_state(state); let mut r = self.router.write().await; *r = std::mem::take(&mut *r).nest(path, route); @@ -391,14 +389,16 @@ impl Server { } } - /// Ajoute une API documentĂ©e avec OpenAPI + /// Ajoute une API documentĂ©e avec OpenAPI et Swagger UI /// - /// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui` + /// Cette mĂ©thode fusionne le `api_router` fourni avec le router principal du serveur. + /// Chaque appel peut ajouter une nouvelle API distincte, avec sa propre documentation Swagger. /// /// # Arguments /// /// * `api_router` - Router Axum contenant les routes API - /// * `openapi` - SpĂ©cification OpenAPI gĂ©nĂ©rĂ©e par utoipa + /// * `openapi` - SpĂ©cification OpenAPI gĂ©nĂ©rĂ©e par `utoipa` + /// * `name` - Nom unique pour cette API, utilisĂ© pour diffĂ©rencier le chemin Swagger UI et le JSON OpenAPI /// /// # Exemple /// @@ -418,7 +418,7 @@ impl Server { /// paths(get_users), /// components(schemas(User)) /// )] - /// struct ApiDoc; + /// struct ApiDoc1; /// /// #[utoipa::path( /// get, @@ -429,24 +429,64 @@ impl Server { /// Json(vec![]) /// } /// - /// let api_router = Router::new() - /// .route("/users", get(get_users)); + /// #[derive(utoipa::OpenApi)] + /// #[openapi( + /// paths(get_products), + /// components(schemas(Product)) + /// )] + /// struct ApiDoc2; /// - /// server.add_openapi(api_router, ApiDoc::openapi()).await; + /// #[utoipa::path( + /// get, + /// path = "/products", + /// responses((status = 200, description = "List products")) + /// )] + /// async fn get_products() -> Json> { + /// Json(vec![]) + /// } + /// + /// let api_router1 = Router::new().route("/users", get(get_users)); + /// let api_router2 = Router::new().route("/products", get(get_products)); + /// + /// // Ajouter les deux API au serveur, chacune avec son nom unique + /// server.add_openapi(api_router1, ApiDoc1::openapi(), "api1").await; + /// server.add_openapi(api_router2, ApiDoc2::openapi(), "api2").await; /// ``` - pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) { - // Stocker le routeur API + /// + /// RĂ©sultat : + /// + /// - `/users` et `/products` sont accessibles via Axum. + /// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante. + /// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spĂ©cifications OpenAPI respectives. + + pub async fn add_openapi( + &mut self, + api_router: Router, + openapi: utoipa::openapi::OpenApi, + name: &str, // nom unique pour diffĂ©rencier Swagger et OpenAPI + ) { + use axum::routing::get; + use utoipa_swagger_ui::SwaggerUi; + + // Stocker le router API dans self.api_router si tu veux y accĂ©der plus tard let mut api_r = self.api_router.write().await; - *api_r = Some(api_router); + *api_r = Some(api_router.clone()); - // Ajouter Swagger UI - let swagger = SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", openapi); + // GĂ©nĂ©rer des chemins uniques pour Swagger UI et OpenAPI JSON + let swagger_path = format!("/swagger-ui/{}", name); + let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str()); + let openapi_json_path = format!("/api-docs/{}.json", name); + let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str()); + + let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi); + + // Fusionner avec le router principal let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).merge(swagger); + let mut combined = std::mem::take(&mut *r); + combined = combined.merge(api_router).merge(swagger); + *r = combined; } - /// DĂ©marre le serveur HTTP /// /// Lance le serveur sur le port configurĂ© et met en place la gestion @@ -465,7 +505,10 @@ impl Server { /// ``` pub async fn start(&mut self) { let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port)); - info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port); + info!( + "Server {} running at [http://{}:{}](http://{}:{})", + self.name, self.base_url, self.http_port, self.base_url, self.http_port + ); // Merger le routeur API si prĂ©sent let api_router = self.api_router.read().await; @@ -545,8 +588,10 @@ impl Server { let log_state = init_logging(options); // Enregistrer automatiquement les routes de logging - self.add_handler_with_state("/log-sse", log_sse, log_state.clone()).await; - self.add_handler_with_state("/log-dump", log_dump, log_state.clone()).await; + self.add_handler_with_state("/log-sse", log_sse, log_state.clone()) + .await; + self.add_handler_with_state("/log-dump", log_dump, log_state.clone()) + .await; self.log_state = Some(log_state); } @@ -580,7 +625,7 @@ impl ServerBuilder { Self { name: "PMO-Music-Server".to_string(), base_url: config.get_base_url(), - http_port: config.get_http_port() + http_port: config.get_http_port(), } } @@ -598,4 +643,4 @@ impl ServerBuilder { pub fn build(self) -> Server { Server::new(self.name, self.base_url, self.http_port) } -} \ No newline at end of file +}