diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..fb6c2e8d Binary files /dev/null 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 c91972b5..985ac37b 100644 --- a/.pmomusic.yml +++ b/.pmomusic.yml @@ -1,9 +1,15 @@ +host: + http_port: '8080' + cover_cache: + directory: ./.pmomusic_covers + size: 2000 devices: mediarenderer: + mpd_renderer: null fakerenderer: udn: d7eaad15-7d21-4411-926a-bc1eea0713db + mediarenderer: + udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62 mediaserver: qobuz: udn: 28963b75-4c5f-4da7-b10e-ffafd -host: - http_port: '8080' diff --git a/Cargo.lock b/Cargo.lock index 5d372720..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" @@ -469,6 +604,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -554,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" @@ -563,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" @@ -590,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" @@ -809,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" @@ -824,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" @@ -843,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" @@ -853,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" @@ -1117,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" @@ -1124,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" @@ -1165,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" @@ -1187,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" @@ -1203,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" @@ -1240,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" @@ -1255,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" @@ -1277,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" @@ -1284,6 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1297,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" @@ -1314,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" @@ -1323,6 +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" @@ -1397,6 +1885,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "serde", + "windows-sys 0.52.0", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1420,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" @@ -1444,10 +1950,24 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "pmoapp" version = "0.1.0" dependencies = [ + "pmoserver", "rust-embed", ] @@ -1466,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" @@ -1489,7 +2030,6 @@ dependencies = [ "futures", "futures-util", "pmoconfig", - "pmoupnp", "rust-embed", "serde", "serde_json", @@ -1515,10 +2055,12 @@ dependencies = [ "parking_lot", "pmoconfig", "pmodidl", + "pmoserver", + "pmoutils", "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -1531,6 +2073,20 @@ name = "pmoutils" version = "0.1.0" dependencies = [ "get_if_addrs", + "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]] @@ -1557,6 +2113,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +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" @@ -1566,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" @@ -1591,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" @@ -1608,7 +2322,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -1649,7 +2363,9 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1680,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" @@ -1694,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" @@ -1893,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" @@ -1918,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" @@ -1959,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" @@ -2063,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" @@ -2076,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]] @@ -2105,6 +2909,51 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -2126,6 +2975,7 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", @@ -2189,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" @@ -2198,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" @@ -2205,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", ] @@ -2441,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" @@ -2464,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" @@ -2595,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" @@ -2606,7 +3523,7 @@ dependencies = [ "js-sys", "log", "serde", - "thiserror", + "thiserror 2.0.17", "web-sys", ] @@ -2870,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" @@ -2961,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 94b2c9f1..cb1e1bb3 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -7,7 +7,8 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmoupnp = { path = "../pmoupnp"} pmoserver = { path = "../pmoserver" } -pmoapp = { path = "../pmoapp" } +pmocovers = { path = "../pmocovers", features = ["pmoserver"] } +pmoapp = { path = "../pmoapp", features = ["pmoserver"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] } diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 4e75369f..6c8c6248 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -1,57 +1,69 @@ -use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject}; +use pmoupnp::{ + mediarenderer::MEDIA_RENDERER, + ssdp::SsdpServer, + UpnpServer, + UpnpModel, +}; use pmoserver::{ - logs::{log_dump, log_sse, LogState, SseLayer}, + logs::LoggingOptions, ServerBuilder }; -use pmoapp::Webapp; -use tracing_subscriber::Registry; -use tracing_subscriber::prelude::*; +use pmoapp::{Webapp, WebAppExt}; +use pmocovers::CoverCacheExt; use tracing::info; #[tokio::main] async fn main() { - // Charger la config - + // CrĂ©er le serveur let mut server = ServerBuilder::new_configured().build(); - // Ajouter des routes - server - .add_route("/hello", || async { - serde_json::json!({"message": "Hello World"}) - }) - .await; + // 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 { serde_json::json!({"version": "1.0.0"}) }) .await; - server.add_spa::("/app").await; - // GĂšre la sortie des logs et sur le serveur SSE pour l'interface web et sur la console - let log_state = LogState::new(1000); - let subscriber = Registry::default() - .with( - tracing_subscriber::fmt::layer() - .with_target(true) - .with_level(true) - .with_ansi(true), // Couleurs dans le terminal - ) - .with(SseLayer::new(log_state.clone())); - tracing::subscriber::set_global_default(subscriber).unwrap(); + // Ajouter la webapp via le trait WebAppExt + info!("📡 Registering Web application..."); + server.add_webapp_with_redirect::("/app").await; - server - .add_handler_with_state("/log-sse", log_sse, log_state.clone()) - .await; - server - .add_handler_with_state("/log-dump", log_dump, log_state.clone()) - .await; + info!("📡 Registering MediaRenderer..."); + let renderer_instance = server.register_device(MEDIA_RENDERER.clone()) + .await + .expect("Failed to register MediaRenderer routes"); - server.add_redirect("/", "/app").await; + info!("✅ MediaRenderer ready at {}{}", + renderer_instance.base_url(), + renderer_instance.description_route() + ); - info!("{}",AVTTRANSPORT.to_markdown()); - info!("{}",AVTTRANSPORT.scpd_xml()); + // CrĂ©er et dĂ©marrer le serveur SSDP + info!("📡 Starting SSDP discovery..."); + let mut ssdp_server = SsdpServer::new(); + ssdp_server.start().expect("Failed to start SSDP server"); + + // CrĂ©er et enregistrer le device SSDP pour le MediaRenderer + let ssdp_device = renderer_instance + .to_ssdp_device("PMOMusic", "1.0"); + ssdp_server.add_device(ssdp_device); + info!("✅ SSDP announcements sent for MediaRenderer"); server.start().await; server.wait().await; diff --git a/pmoapp/Cargo.toml b/pmoapp/Cargo.toml index 894dea15..8b18b4e2 100644 --- a/pmoapp/Cargo.toml +++ b/pmoapp/Cargo.toml @@ -5,3 +5,11 @@ edition = "2021" [dependencies] rust-embed = "8.5.0" + +[dependencies.pmoserver] +path = "../pmoserver" +optional = true + +[features] +default = [] +pmoserver = ["dep:pmoserver"] diff --git a/pmoapp/src/lib.rs b/pmoapp/src/lib.rs index f1b8ced1..d7e1a37f 100644 --- a/pmoapp/src/lib.rs +++ b/pmoapp/src/lib.rs @@ -1,32 +1,246 @@ //! # pmoapp - Application web UPnP pour PMOMusic //! -//! Cette crate fournit l'application web frontend pour le contrĂŽle UPnP, -//! intĂ©grĂ©e via RustEmbed pour ĂȘtre servie par pmoserver. +//! Cette crate fournit l'application web frontend pour le contrĂŽle et la visualisation +//! des devices UPnP MediaRenderer, intĂ©grĂ©e via RustEmbed pour ĂȘtre servie par pmoserver. +//! +//! ## Vue d'ensemble +//! +//! `pmoapp` est une application Vue.js 3 moderne avec TypeScript qui offre une interface +//! utilisateur pour : +//! - Visualiser les logs systĂšme en temps rĂ©el (Server-Sent Events) +//! - ContrĂŽler les devices UPnP MediaRenderer +//! - Afficher et formater automatiquement le XML dans les logs //! //! ## FonctionnalitĂ©s //! -//! - 📩 **Frontend intĂ©grĂ©** : Application web compilĂ©e et embarquĂ©e dans le binaire -//! - 🎹 **Interface de contrĂŽle** : UI pour gĂ©rer les devices UPnP MediaRenderer -//! - 🚀 **Zero configuration** : Pas besoin de servir des fichiers statiques sĂ©parĂ©s +//! ### 📩 Frontend intĂ©grĂ© +//! - Application web compilĂ©e et embarquĂ©e dans le binaire Rust +//! - Aucun fichier statique externe Ă  gĂ©rer en production +//! - IntĂ©gration via `RustEmbed` pour une distribution simplifiĂ©e +//! +//! ### 🎹 Interface utilisateur +//! - **LogView** : Visualisation des logs en temps rĂ©el avec filtres par niveau +//! - **Auto-scroll** : DĂ©filement automatique des nouveaux logs (dĂ©sactivable) +//! - **Formatage XML** : DĂ©tection et coloration syntaxique automatique du XML +//! - **Design responsive** : Compatible desktop et mobile +//! - **ThĂšme sombre** : Style inspirĂ© de VS Code pour une meilleure lisibilitĂ© +//! +//! ### 🚀 Zero configuration +//! - Pas besoin de serveur web sĂ©parĂ© pour les assets +//! - Les fichiers sont servis directement depuis la mĂ©moire du binaire +//! - Configuration automatique du routing Vue Router +//! +//! ## Architecture +//! +//! ### Stack technique +//! +//! - **Frontend** : Vue.js 3 avec Composition API +//! - **Langage** : TypeScript +//! - **Build** : Vite (rapide, moderne, HMR) +//! - **Routing** : Vue Router +//! - **Markdown** : Marked.js pour le rendu +//! - **SĂ©curitĂ©** : DOMPurify pour la sanitization HTML +//! +//! ### Structure des fichiers +//! +//! ```text +//! pmoapp/ +//! ├── Cargo.toml # DĂ©pendances Rust (rust-embed) +//! ├── src/ +//! │ └── lib.rs # Point d'entrĂ©e Rust (ce fichier) +//! └── webapp/ +//! ├── src/ +//! │ ├── main.ts # Point d'entrĂ©e Vue.js +//! │ ├── App.vue # Composant racine +//! │ ├── router/ # Configuration Vue Router +//! │ └── components/ +//! │ ├── LogView.vue # Visualiseur de logs SSE +//! │ └── ... +//! ├── dist/ # Build output (gĂ©nĂ©rĂ©, non versionnĂ©) +//! ├── package.json # DĂ©pendances npm +//! └── vite.config.ts # Configuration Vite +//! ``` +//! +//! ## Workflow de build +//! +//! ### 1. Build de la webapp (Vue.js) +//! +//! ```bash +//! # Installation des dĂ©pendances +//! cd pmoapp/webapp +//! npm install +//! +//! # Build de production +//! npm run build +//! # GĂ©nĂšre : webapp/dist/index.html, assets/*.js, assets/*.css +//! ``` +//! +//! ### 2. Compilation Rust +//! +//! ```bash +//! cargo build +//! # RustEmbed inclut automatiquement les fichiers de webapp/dist/ +//! ``` +//! +//! ### 3. Utilisation avec Makefile +//! +//! ```bash +//! # Build complet (webapp + Rust) +//! make build +//! +//! # Ou juste la webapp +//! make webapp +//! +//! # Clean +//! make clean +//! ``` //! //! ## Utilisation //! +//! ### Exemple basique +//! //! ```rust,no_run //! use pmoapp::Webapp; //! use pmoserver::ServerBuilder; //! -//! # async fn example() { -//! let mut server = ServerBuilder::new("MyApp").build(); -//! server.add_spa::("/app").await; -//! # } +//! #[tokio::main] +//! async fn main() { +//! let mut server = ServerBuilder::new("MyApp") +//! .http_port(8080) +//! .build(); +//! +//! // Ajouter la webapp comme Single Page Application +//! server.add_spa::("/app").await; +//! +//! // Ajouter une redirection de la racine vers /app +//! server.add_redirect("/", "/app").await; +//! +//! server.start().await; +//! server.wait().await; +//! } //! ``` //! -//! ## Structure +//! ### Exemple avec logs SSE //! -//! La webapp est construite avec Vite et Vue.js, et les fichiers statiques -//! sont embarquĂ©s dans le binaire au moment de la compilation via `RustEmbed`. +//! ```rust,no_run +//! use pmoapp::Webapp; +//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}}; +//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +//! +//! #[tokio::main] +//! async fn main() { +//! // Configuration des logs avec SSE +//! let log_state = LogState::new(1000); // Buffer de 1000 logs +//! tracing_subscriber::registry() +//! .with(tracing_subscriber::fmt::layer()) +//! .with(SseLayer::new(log_state.clone())) +//! .init(); +//! +//! let mut server = ServerBuilder::new("MyApp").build(); +//! +//! // Endpoints SSE pour les logs +//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await; +//! server.add_handler_with_state("/log-dump", pmoserver::logs::log_dump, log_state).await; +//! +//! // Webapp (consommera les logs via /log-sse) +//! server.add_spa::("/app").await; +//! server.add_redirect("/", "/app").await; +//! +//! server.start().await; +//! server.wait().await; +//! } +//! ``` +//! +//! ## DĂ©veloppement +//! +//! ### Mode dĂ©veloppement Vue.js +//! +//! Pour dĂ©velopper la webapp avec Hot Module Replacement : +//! +//! ```bash +//! cd pmoapp/webapp +//! npm run dev +//! # Serveur de dev sur http://localhost:5173 +//! ``` +//! +//! ### Rebuild aprĂšs modifications +//! +//! AprĂšs avoir modifiĂ© le code Vue.js : +//! +//! ```bash +//! # Rebuild webapp + recompile Rust +//! make build +//! +//! # Ou sĂ©parĂ©ment +//! make webapp # Build Vue.js seulement +//! cargo build # Recompile Rust (intĂšgre le nouveau dist/) +//! ``` +//! +//! ## Composants Vue.js +//! +//! ### LogView +//! +//! Composant principal pour la visualisation des logs : +//! +//! - **Connexion SSE** : Stream temps rĂ©el via EventSource +//! - **Filtrage** : Par niveau (TRACE, DEBUG, INFO, WARN, ERROR) +//! - **Auto-scroll** : Activable/dĂ©sactivable +//! - **Formatage** : Markdown + dĂ©tection XML automatique +//! - **Buffer** : Limite Ă  1000 logs en mĂ©moire +//! - **DĂ©duplication** : Évite les logs en double +//! +//! ### Formatage XML +//! +//! Le composant LogView dĂ©tecte automatiquement le XML dans les messages : +//! +//! ``` +//! Input: "INFO: ..." +//! Output: Bloc de code avec coloration syntaxique XML +//! ``` +//! +//! - DĂ©tection via regex : ``, ``, etc.) +//! - Conversion en bloc markdown : ` ```xml ... ``` ` +//! - Rendu avec coloration et scrollbar pour le XML long +//! +//! ## IntĂ©gration avec pmoupnp +//! +//! La webapp communique avec les devices UPnP via les endpoints HTTP fournis par +//! `pmoserver` et `pmoupnp` : +//! +//! - `/log-sse` : Stream de logs (Server-Sent Events) +//! - `/log-dump` : Historique des logs +//! - `/device/*/description.xml` : Descripteurs UPnP +//! - `/service/*/control` : Endpoints de contrĂŽle SOAP +//! - `/service/*/event` : Souscription aux Ă©vĂ©nements UPnP +//! +//! ## Notes de dĂ©ploiement +//! +//! ### Taille du binaire +//! +//! La webapp ajoutera ~150KB au binaire (compressĂ© avec gzip par RustEmbed). +//! +//! ### Cache du navigateur +//! +//! Les assets sont servis avec des hashes dans les noms de fichiers +//! (`index-BBZcSinC.js`) pour un cache busting automatique. +//! +//! ### CompatibilitĂ© navigateurs +//! +//! - Chrome/Edge : ✅ Moderne +//! - Firefox : ✅ Moderne +//! - Safari : ✅ iOS 13+ +//! - IE11 : ❌ Non supportĂ© (utilise ES modules) +//! +//! ## Voir aussi +//! +//! - [`pmoserver`] : Serveur HTTP Axum pour servir la webapp +//! - [`pmoupnp`] : BibliothĂšque UPnP MediaRenderer +//! - [Vue.js Documentation](https://vuejs.org/) +//! - [Vite Documentation](https://vitejs.dev/) use rust_embed::RustEmbed; +use std::future::Future; +use std::pin::Pin; /// Structure reprĂ©sentant l'application web embarquĂ©e. /// @@ -36,16 +250,72 @@ use rust_embed::RustEmbed; /// ## Exemple /// /// ```rust,no_run -/// use pmoapp::Webapp; +/// use pmoapp::{Webapp, WebAppExt}; /// use pmoserver::ServerBuilder; /// /// # async fn example() { /// let mut server = ServerBuilder::new("MyApp").build(); /// -/// // Ajouter la webapp comme SPA sur le chemin /app -/// server.add_spa::("/app").await; +/// // Ajouter la webapp via le trait WebAppExt +/// server.add_webapp::("/app").await; /// # } /// ``` #[derive(RustEmbed, Clone)] #[folder = "webapp/dist"] pub struct Webapp; + +/// Trait pour Ă©tendre un serveur HTTP avec des fonctionnalitĂ©s webapp. +/// +/// Ce trait permet Ă  `pmoapp` d'ajouter des mĂ©thodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dĂ©pendent de `pmoapp`. +/// +/// # Architecture +/// +/// Similaire au pattern utilisĂ© par `pmoupnp` pour `UpnpServer`, ce trait permet +/// une extension propre et dĂ©couplĂ©e : +/// +/// - `pmoserver` dĂ©finit un serveur HTTP gĂ©nĂ©rique +/// - `pmoapp` Ă©tend ce serveur avec des mĂ©thodes webapp via ce trait +/// - Le serveur n'a pas besoin de connaĂźtre `pmoapp` +/// +/// # Exemple d'implĂ©mentation +/// +/// ```ignore +/// impl WebAppExt for pmoserver::Server { +/// fn add_webapp(&mut self, path: &str) -> ... { +/// // DĂ©lĂšgue Ă  la mĂ©thode interne add_spa +/// self.add_spa::(path) +/// } +/// } +/// ``` +pub trait WebAppExt { + /// Ajoute une Single Page Application au serveur. + /// + /// # Arguments + /// + /// * `path` - Le chemin oĂč monter la webapp (ex: "/app") + /// + /// # Type Parameter + /// + /// * `W` - Type RustEmbed contenant les fichiers de la webapp + fn add_webapp(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static; + + /// Ajoute une webapp avec une redirection automatique depuis la racine. + /// + /// # Arguments + /// + /// * `path` - Le chemin oĂč monter la webapp (ex: "/app") + /// + /// # Type Parameter + /// + /// * `W` - Type RustEmbed contenant les fichiers de la webapp + fn add_webapp_with_redirect(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static; +} + +// ImplĂ©mentation du trait pour pmoserver::Server (feature-gated) +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; diff --git a/pmoapp/src/pmoserver_impl.rs b/pmoapp/src/pmoserver_impl.rs new file mode 100644 index 00000000..d5e7db7f --- /dev/null +++ b/pmoapp/src/pmoserver_impl.rs @@ -0,0 +1,57 @@ +//! ImplĂ©mentation du trait WebAppExt pour le serveur pmoserver +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalitĂ©s webapp en +//! implĂ©mentant le trait [`WebAppExt`](crate::WebAppExt). Cette implĂ©mentation +//! permet d'enregistrer facilement des webapps embarquĂ©es sur le serveur. +//! +//! ## Architecture +//! +//! `pmoapp` Ă©tend `pmoserver::Server` sans que `pmoserver` connaisse `pmoapp`. +//! C'est le pattern d'extension : `pmoapp` ajoute des fonctionnalitĂ©s Ă  un type +//! externe via un trait, similaire au pattern utilisĂ© par `pmoupnp` pour `UpnpServer`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmoapp::{Webapp, WebAppExt}; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() { +//! let mut server = ServerBuilder::new("MyApp").build(); +//! +//! // Le trait WebAppExt est automatiquement disponible +//! server.add_webapp::("/app").await; +//! +//! // Ou avec redirection +//! server.add_webapp_with_redirect::("/app").await; +//! # } +//! ``` + +use crate::WebAppExt; +use pmoserver::Server; +use rust_embed::RustEmbed; +use std::future::Future; +use std::pin::Pin; + +impl WebAppExt for Server { + fn add_webapp(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static, + { + let path = path.to_string(); + Box::pin(async move { + self.add_spa::(&path).await; + }) + } + + fn add_webapp_with_redirect(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static, + { + let path = path.to_string(); + Box::pin(async move { + self.add_spa::(&path).await; + self.add_redirect("/", &path).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..a277c097 --- /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"); + 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/${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", { + 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/${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", { + 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/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..bd4a5af5 --- /dev/null +++ b/pmocovers/src/pmoserver_impl.rs @@ -0,0 +1,175 @@ +//! 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, warn}; +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(); + + warn!("{:?}",parts); + + if parts.len() != 2 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[1]; + + 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() != 3 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[1]; + let size = match parts[2].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 pour servir les images + let image_router = Router::new() + .route("/{pk}", get(get_cover_image)) + .route("/{pk}/{size}", get(get_cover_variant)) + .with_state(cache.clone()); + + self.add_router("/covers/images", image_router).await; + self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; + + // Router API RESTful + // Router API RESTful qui sera nestĂ© sous /api/covers par add_openapi + let api_router = Router::new() + // Liste et ajout + .route( + "/", + get(api::list_images) // GET /api/covers + .post(api::add_image) // POST /api/covers + .delete(api::purge_cache), // DELETE /api/covers + ) + // Ressource unique + .route( + "/{pk}", + get(api::get_image_info) // GET /api/covers/{pk} + .delete(api::delete_image), // DELETE /api/covers/{pk} + ) + // Action spĂ©cifique + .route( + "/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 + // Le router sera nestĂ© automatiquement sous /api/covers par add_openapi + // Routes finales: /api/covers, /api/covers/{pk}, /api/covers/consolidate + // Swagger UI sera disponible Ă  /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/Cargo.toml b/pmoserver/Cargo.toml index d5d74ddd..c2388bb8 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -21,7 +21,3 @@ axum-embed = "0.1.0" rust-embed = "8.7.2" utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } - -[dependencies.pmoupnp] -path = "../pmoupnp" -default-features = false diff --git a/pmoserver/src/lib.rs b/pmoserver/src/lib.rs index 8125fa06..9cea71a4 100644 --- a/pmoserver/src/lib.rs +++ b/pmoserver/src/lib.rs @@ -20,7 +20,6 @@ //! //! - [`server`] : ImplĂ©mentation du serveur principal et du builder //! - [`logs`] : SystĂšme de logs SSE pour monitoring en temps rĂ©el -//! - `upnp_impl` : ImplĂ©mentation du trait `pmoupnp::UpnpServer` (privĂ©) //! //! ## Exemple d'utilisation //! @@ -53,27 +52,25 @@ //! //! ## IntĂ©gration UPnP //! -//! Le serveur implĂ©mente automatiquement le trait `pmoupnp::UpnpServer`, permettant -//! de connecter des devices UPnP : +//! Le serveur peut ĂȘtre Ă©tendu avec UPnP via le trait `pmoupnp::UpnpServer`. +//! L'implĂ©mentation est fournie par `pmoupnp` (feature `pmoserver`), permettant +//! de connecter des devices UPnP sans que `pmoserver` dĂ©pende de `pmoupnp` : //! //! ```rust,no_run -//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER}; -//! use pmoupnp::devices::DeviceInstance; +//! use pmoupnp::{UpnpServer, mediarenderer::MEDIA_RENDERER}; //! use pmoserver::ServerBuilder; -//! use std::sync::Arc; //! //! # async fn example() { //! let mut server = ServerBuilder::new("MediaRenderer").build(); -//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER)); +//! let device = MEDIA_RENDERER.create_instance(); //! -//! // Le device enregistre automatiquement ses routes +//! // Le trait UpnpServer est automatiquement disponible (implĂ©mentĂ© dans pmoupnp) //! device.register_urls(&mut server).await; //! # } //! ``` pub mod server; pub mod logs; -mod upnp_impl; pub use server::{Server, ServerBuilder, ServerInfo}; -pub use logs::{LogState, SseLayer, log_sse, log_dump}; +pub use logs::{LogState, SseLayer, log_sse, log_dump, init_logging, LoggingOptions}; diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index 6ff83ece..1d05e274 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -19,6 +19,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; +use tracing_subscriber::{Registry, layer::SubscriberExt}; /// ReprĂ©sente une entrĂ©e de log #[derive(Debug, Clone, Serialize)] @@ -157,3 +158,60 @@ fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool { allowed } + +/// Options d'initialisation du systĂšme de logging +#[derive(Debug, Clone)] +pub struct LoggingOptions { + /// CapacitĂ© du buffer circulaire (nombre d'entrĂ©es conservĂ©es) + pub buffer_capacity: usize, + /// Activer la sortie vers stderr/stdout + pub enable_console: bool, +} + +impl Default for LoggingOptions { + fn default() -> Self { + Self { + buffer_capacity: 1000, + enable_console: true, + } + } +} + +/// Initialise le systĂšme de logging avec SSE et optionnellement la console +/// +/// # Arguments +/// * `options` - Options de configuration du logging +/// +/// # Retourne +/// Le `LogState` qui peut ĂȘtre utilisĂ© pour ajouter les routes de logging au serveur +/// +/// # Exemple +/// ```rust,no_run +/// use pmoserver::logs::{init_logging, LoggingOptions}; +/// +/// let log_state = init_logging(LoggingOptions { +/// buffer_capacity: 1000, +/// enable_console: true, +/// }); +/// ``` +pub fn init_logging(options: LoggingOptions) -> LogState { + let log_state = LogState::new(options.buffer_capacity); + + let subscriber = Registry::default().with(SseLayer::new(log_state.clone())); + + if options.enable_console { + let subscriber = subscriber.with( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true), + ); + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + } else { + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + } + + log_state +} diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 0f621ce9..3f8f559a 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -13,15 +13,18 @@ //! - 📚 **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; +use axum::routing::{get, post}; use axum::{Json, Router}; use axum_embed::ServeEmbed; use pmoconfig::get_config; use rust_embed::RustEmbed; use serde::Serialize; -use std::{net::SocketAddr, sync::Arc}; +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; @@ -29,11 +32,8 @@ use utoipa_swagger_ui::SwaggerUi; /// Info serveur sĂ©rialisable #[derive(Clone, Serialize, utoipa::ToSchema)] pub struct ServerInfo { - /// Nom du serveur pub name: String, - /// URL de base pub base_url: String, - /// Port HTTP pub http_port: u16, } @@ -45,6 +45,7 @@ pub struct Server { router: Arc>, api_router: Arc>>, join_handle: Option>, + log_state: Option, } impl Server { @@ -70,6 +71,7 @@ impl Server { router: Arc::new(RwLock::new(Router::new())), api_router: Arc::new(RwLock::new(None)), join_handle: None, + log_state: None, } } @@ -77,8 +79,7 @@ impl Server { let config = get_config(); let url = config.get_base_url(); let port = config.get_http_port(); - - return Self::new("PMO-Music-Server", url, port); + Self::new("PMO-Music-Server", url, port) } /// Ajoute une route JSON dynamique @@ -109,11 +110,10 @@ impl Server { pub async fn add_route(&mut self, path: &str, f: F) where F: Fn() -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, + Fut: Future + Send + 'static, T: Serialize + Send + 'static, { let f = Arc::new(f); - let handler = { let f = f.clone(); move || { @@ -125,53 +125,81 @@ impl Server { let route = Router::new().route("/", get(handler)); let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; } - /// Ajoute un rĂ©pertoire de fichiers statiques - /// - /// Sert des fichiers embarquĂ©s via `RustEmbed`. Les fichiers sont compilĂ©s - /// dans le binaire Ă  la compilation. - /// - /// # Arguments - /// - /// * `path` - Chemin oĂč monter les fichiers statiques - /// - /// # Type Parameter - /// - /// * `E` - Type RustEmbed dĂ©finissant le rĂ©pertoire Ă  servir - /// - /// # Exemple - /// - /// ```ignore - /// use pmoupnp::server::Server; - /// use rust_embed::RustEmbed; - /// - /// #[derive(RustEmbed, Clone)] - /// #[folder = "static/"] - /// struct Assets; - /// - /// # #[tokio::main] - /// # async fn main() { - /// let mut server = Server::new("Test", "http://localhost:3000", 3000); - /// server.add_dir::("/assets").await; - /// // Les fichiers de static/ sont accessibles via /assets/* - /// # } - /// ``` + /// Ajoute un handler Axum standard + pub async fn add_handler(&mut self, path: &str, handler: H) + where + H: Handler + Clone + 'static, + T: 'static, + { + let route = Router::new().route("/", get(handler.clone())); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un handler POST avec Ă©tat + pub async fn add_post_handler_with_state(&mut self, path: &str, handler: H, state: S) + where + H: Handler + Clone + 'static, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let route = Router::new() + .route("/", post(handler.clone())) + .with_state(state.clone()); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un handler avec Ă©tat + pub async fn add_handler_with_state(&mut self, path: &str, handler: H, state: S) + where + H: Handler + Clone + 'static, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let route = Router::new() + .route("/", get(handler.clone())) + .with_state(state.clone()); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un rĂ©pertoire statique pub async fn add_dir(&mut self, path: &str) where 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); + + let route = Router::new().fallback_service(serve); + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) } else { - let route = Router::new().fallback_service(serve); - *r = std::mem::take(&mut *r).nest(path, route); - } + std::mem::take(&mut *r).nest(path, route) + }; } /// Ajoute une Single Page Application (SPA) @@ -223,130 +251,15 @@ 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); + + let route = Router::new().fallback_service(serve); + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) } else { - let route = Router::new().fallback_service(serve); - *r = std::mem::take(&mut *r).nest(path, route); - } - } - - /// Ajoute un handler Axum personnalisĂ© - /// - /// Pour des cas d'usage avancĂ©s nĂ©cessitant un contrĂŽle complet sur le handler. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum - /// - /// # Exemple - /// - /// ```rust,no_run - /// # use pmoupnp::server::Server; - /// # use axum::response::Html; - /// # #[tokio::main] - /// # async fn main() { - /// # let mut server = Server::new("Test", "http://localhost:3000", 3000); - /// async fn custom_handler() -> Html<&'static str> { - /// Html("

Custom Response

") - /// } - /// - /// server.add_handler("/custom", custom_handler).await; - /// # } - /// ``` - pub async fn add_handler(&mut self, path: &str, handler: H) - where - H: Handler, - T: 'static, - { - let route = Router::new().route("/", get(handler)); - - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); - } - - /// Ajoute un handler avec state (pour SSE, extracteurs, etc.) - /// - /// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc. - /// IdĂ©al pour Server-Sent Events (SSE), WebSockets ou tout handler nĂ©cessitant un Ă©tat partagĂ©. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum avec extracteurs - /// * `state` - État partagĂ© (doit ĂȘtre Clone + Send + Sync) - /// - /// # Exemple avec SSE - /// - /// ```ignore - /// use pmoupnp::server::Server; - /// use axum::extract::State; - /// use axum::response::sse::{Event, Sse, KeepAlive}; - /// use tokio::sync::broadcast; - /// - /// #[derive(Clone)] - /// struct LogState { - /// tx: broadcast::Sender - /// } - /// - /// 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! { - /// while let Ok(msg) = rx.recv().await { - /// yield Ok(Event::default().data(msg)); - /// } - /// }; - /// Sse::new(stream).keep_alive(KeepAlive::default()) - /// } - /// - /// let log_state = LogState { tx: broadcast::channel(100).0 }; - /// server.add_handler_with_state("/logs", log_sse, log_state).await; - /// ``` - pub async fn add_handler_with_state(&mut self, path: &str, handler: H, state: S) - where - H: Handler, - T: 'static, - S: Clone + Send + Sync + 'static, - { - 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); - } - - /// Ajoute un handler POST avec state - /// - /// Similaire Ă  `add_handler_with_state` mais pour les requĂȘtes POST. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum pour POST - /// * `state` - État partagĂ© - pub async fn add_post_handler_with_state(&mut self, path: &str, handler: H, state: S) - where - H: Handler, - T: 'static, - S: Clone + Send + Sync + 'static, - { - let route = Router::new() - .route("/", axum::routing::post(handler)) - .with_state(state); - - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); + std::mem::take(&mut *r).nest(path, route) + }; } /// Ajoute une redirection HTTP @@ -369,33 +282,32 @@ impl Server { /// server.add_redirect("/", "/app").await; /// # } /// ``` + pub async fn add_redirect(&mut self, from: &str, to: &str) { let to = to.to_string(); - let handler = move || { - let to = to.clone(); - async move { Redirect::permanent(&to) } + let make_handler = || { + let target = to.clone(); + get(move || async move { Redirect::permanent(&target) }) }; let mut r = self.router.write().await; - - if from == "/" { - // Pour la racine, utiliser merge au lieu de nest - let route = Router::new().route("/", get(handler)); - *r = std::mem::take(&mut *r).merge(route); + *r = if from == "/" { + std::mem::take(&mut *r).merge(Router::new().route("/", make_handler())) } else { - let route = Router::new().route("/", get(handler)); - *r = std::mem::take(&mut *r).nest(from, route); - } + std::mem::take(&mut *r).nest(from, Router::new().route("/", make_handler())) + }; } - /// 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 /// @@ -415,7 +327,7 @@ impl Server { /// paths(get_users), /// components(schemas(User)) /// )] - /// struct ApiDoc; + /// struct ApiDoc1; /// /// #[utoipa::path( /// get, @@ -426,22 +338,76 @@ 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 : + /// + /// - `/api/api1/users` et `/api/api2/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, + ) { let mut api_r = self.api_router.write().await; - *api_r = Some(api_router); + *api_r = Some(api_router.clone()); + drop(api_r); - // Ajouter Swagger UI - let swagger = SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", openapi); + 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); + + let base_path = format!("/api/{}", name); + let nested_router = Router::new().nest(&base_path, api_router); let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).merge(swagger); + *r = std::mem::take(&mut *r).merge(nested_router).merge(swagger); + } + /// Ajoute un sous-router au serveur + /// + /// - Si `path` est "/", merge directement au router principal + /// - Sinon, nest le router sous le chemin donnĂ© + pub async fn add_router(&mut self, path: &str, sub_router: Router) { + let mut r = self.router.write().await; + + let combined = if path == "/" { + // Merge directement Ă  la racine + r.clone().merge(sub_router) + } else { + // Sous-chemin => nest + let normalized = format!("/{}", path.trim_start_matches('/')); + r.clone().nest(&normalized, sub_router) + }; + + *r = combined; } /// DĂ©marre le serveur HTTP @@ -462,18 +428,12 @@ 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); - - // Merger le routeur API si prĂ©sent - let api_router = self.api_router.read().await; - if let Some(api_r) = api_router.as_ref() { - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest("/api", api_r.clone()); - } - drop(api_router); + info!( + "Server {} running at [http://{}:{}](http://{}:{})", + self.name, self.base_url, self.http_port, self.base_url, self.http_port + ); let router = self.router.clone(); - let server_task = tokio::spawn(async move { let r = router.read().await.clone(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); @@ -508,6 +468,47 @@ impl Server { http_port: self.http_port, } } + + /// Initialise le systĂšme de logging et enregistre les routes de logs + /// + /// Cette mĂ©thode configure le systĂšme de tracing avec SSE et optionnellement la console, + /// puis enregistre automatiquement les routes `/log-sse` et `/log-dump`. + /// + /// # Arguments + /// + /// * `options` - Options de configuration du logging + /// + /// # Exemple + /// + /// ```rust,no_run + /// # use pmoserver::{ServerBuilder, logs::LoggingOptions}; + /// # #[tokio::main] + /// # async fn main() { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Initialiser les logs avec console + /// server.init_logging(LoggingOptions::default()).await; + /// + /// // Ou sans console + /// server.init_logging(LoggingOptions { + /// buffer_capacity: 1000, + /// enable_console: false, + /// }).await; + /// + /// server.start().await; + /// # } + /// ``` + pub async fn init_logging(&mut self, options: LoggingOptions) { + 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.log_state = Some(log_state); + } } /// Builder pattern @@ -538,7 +539,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(), } } @@ -556,4 +557,4 @@ impl ServerBuilder { pub fn build(self) -> Server { Server::new(self.name, self.base_url, self.http_port) } -} \ No newline at end of file +} diff --git a/pmoserver/src/upnp_impl.rs b/pmoserver/src/upnp_impl.rs deleted file mode 100644 index d877c108..00000000 --- a/pmoserver/src/upnp_impl.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! ImplĂ©mentation du trait UpnpServer pour le serveur pmoserver -//! -//! Ce module fournit l'implĂ©mentation du trait [`pmoupnp::UpnpServer`] pour -//! le [`Server`](crate::server::Server) de pmoserver, permettant aux devices -//! et services UPnP d'enregistrer automatiquement leurs endpoints HTTP. -//! -//! ## Architecture -//! -//! L'implĂ©mentation fait le pont entre : -//! - Les pointeurs de fonction du trait `UpnpServer` (agnostiques du framework web) -//! - Les handlers Axum (spĂ©cifiques Ă  l'implĂ©mentation `pmoserver`) -//! -//! Chaque mĂ©thode du trait crĂ©e un wrapper qui : -//! 1. Convertit les pointeurs de fonction en closures compatibles Axum -//! 2. DĂ©lĂšgue l'enregistrement aux mĂ©thodes internes du `Server` -//! 3. Retourne une future qui se rĂ©sout une fois le handler enregistrĂ© -//! -//! ## Exemple d'utilisation -//! -//! ```rust,no_run -//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER}; -//! use pmoupnp::devices::DeviceInstance; -//! use pmoserver::ServerBuilder; -//! use std::sync::Arc; -//! -//! # async fn example() { -//! let mut server = ServerBuilder::new("MyRenderer").build(); -//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER)); -//! -//! // Le trait UpnpServer est automatiquement disponible -//! device.register_urls(&mut server).await; -//! # } -//! ``` - -use crate::server::Server; -use pmoupnp::{UpnpServer, server::{Response, HeaderMap, Request}}; -use std::future::Future; -use std::pin::Pin; -use axum::extract::State; - -impl UpnpServer for Server { - fn add_handler(&mut self, path: &str, handler: F) -> Pin + Send + '_>> - where - F: Fn() -> Fut + Send + Sync + 'static + Clone, - Fut: Future + Send + 'static, - { - let path = path.to_string(); - Box::pin(async move { - Self::add_handler(self, &path, handler).await; - }) - } - - fn add_post_handler_with_state( - &mut self, - path: &str, - handler: fn(State, String) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static, - { - let path = path.to_string(); - - // CrĂ©er un wrapper qui convertit le fn pointer en handler Axum - let wrapper = move |State(s): State, body: String| -> Pin + Send>> { - handler(State(s), body) - }; - - Box::pin(async move { - Self::add_post_handler_with_state(self, &path, wrapper, state).await; - }) - } - - fn add_handler_with_state( - &mut self, - path: &str, - handler: fn(State, HeaderMap, Request) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static, - { - let path = path.to_string(); - - // CrĂ©er un wrapper qui convertit le fn pointer en handler Axum - let wrapper = move |State(s): State, headers: HeaderMap, req: Request| -> Pin + Send>> { - handler(State(s), headers, req) - }; - - Box::pin(async move { - Self::add_handler_with_state(self, &path, wrapper, state).await; - }) - } -} diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index d6bf20b4..b6b761d3 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -6,6 +6,8 @@ edition = "2024" [dependencies] pmoconfig = { path = "../pmoconfig" } pmodidl = { path = "../pmodidl"} +pmoutils = { path = "../pmoutils" } +pmoserver = { path = "../pmoserver" } url = "2.5.7" uuid = "1.18.1" diff --git a/pmoupnp/src/actions/action_instance.rs b/pmoupnp/src/actions/action_instance.rs index 49ca2938..02e95fd7 100644 --- a/pmoupnp/src/actions/action_instance.rs +++ b/pmoupnp/src/actions/action_instance.rs @@ -3,8 +3,6 @@ use std::sync::Arc; use xmltree::{Element, XMLNode}; use crate::actions::Action; -use crate::actions::Argument; -use crate::actions::ArgumentSet; use crate::actions::ArgInstanceSet; use crate::actions::ActionInstance; use crate::UpnpInstance; diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 9c74eb71..8a9695ad 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -87,6 +87,11 @@ impl UpnpInstance for DeviceInstance { format!("uuid:{}_{}", model.udn_prefix(), uuid::Uuid::new_v4()) }; + // Obtenir l'IP locale et le port depuis la configuration + let local_ip = pmoutils::guess_local_ip(); + let port = pmoconfig::get_config().get_http_port(); + let server_base_url = format!("http://{}:{}", local_ip, port); + Self { object: UpnpObjectType { name: model.get_name().to_string(), @@ -94,7 +99,7 @@ impl UpnpInstance for DeviceInstance { }, model: Arc::new(model.clone()), udn, - server_base_url: "http://localhost:8080".to_string(), + server_base_url, services: RwLock::new(HashMap::new()), devices: RwLock::new(HashMap::new()), } @@ -184,8 +189,9 @@ impl DeviceInstance { } /// Retourne la route du device (chemin relatif). + /// Utilise l'UDN pour garantir l'unicitĂ© si plusieurs devices du mĂȘme type existent. pub fn route(&self) -> String { - format!("/device/{}", self.get_name()) + format!("/device/{}", self.udn()) } /// Retourne la route de description du device. @@ -245,7 +251,7 @@ impl DeviceInstance { } /// Enregistre toutes les URLs du device et de ses services dans le serveur. - pub fn register_urls<'a, S: crate::UpnpServer + ?Sized>(&'a self, server: &'a mut S) -> std::pin::Pin> + 'a>> { + pub fn register_urls<'a>(&'a self, server: &'a mut pmoserver::Server) -> std::pin::Pin> + 'a>> { Box::pin(async move { info!( "✅ Device description for {} available at: {}{}", @@ -316,10 +322,7 @@ impl DeviceInstance { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let mut xml = String::from_utf8_lossy(&xml_output).to_string(); - - // Ajouter l'en-tĂȘte XML - xml.insert_str(0, "\n"); + let xml = String::from_utf8_lossy(&xml_output).to_string(); ( StatusCode::OK, @@ -327,4 +330,45 @@ impl DeviceInstance { xml, ).into_response() } + + /// CrĂ©e un SsdpDevice configurĂ© pour ce device UPnP. + /// + /// Cette mĂ©thode simplifie la crĂ©ation d'un device SSDP en configurant automatiquement : + /// - L'UDN du device + /// - Le type de device + /// - La location (URL de description) + /// - Le serveur (User-Agent avec OS/version dĂ©tectĂ© automatiquement) + /// - Les types de notification pour tous les services + /// + /// # Arguments + /// + /// * `app_name` - Nom de l'application (ex: "PMOMusic") + /// * `app_version` - Version de l'application (ex: "1.0") + /// + /// # Exemple + /// + /// ```ignore + /// let renderer_instance = MEDIA_RENDERER.create_instance(); + /// let ssdp_device = renderer_instance.to_ssdp_device("PMOMusic", "1.0"); + /// ssdp_server.add_device(ssdp_device); + /// ``` + pub fn to_ssdp_device(&self, app_name: &str, app_version: &str) -> crate::ssdp::SsdpDevice { + let location = format!("{}{}", self.base_url(), self.description_route()); + let os_string = pmoutils::get_os_string(); + let server_string = format!("{} UPnP/1.1 {}/{}", os_string, app_name, app_version); + + let mut ssdp_device = crate::ssdp::SsdpDevice::new( + self.udn().to_string(), + self.model.device_type(), + location, + server_string, + ); + + // Ajouter les types de notification pour chaque service + for service in self.services() { + ssdp_device.add_notification_type(service.service_type()); + } + + ssdp_device + } } diff --git a/pmoupnp/src/devices/device_methods.rs b/pmoupnp/src/devices/device_methods.rs index 96fd9b3b..cf4b76e8 100644 --- a/pmoupnp/src/devices/device_methods.rs +++ b/pmoupnp/src/devices/device_methods.rs @@ -1,10 +1,11 @@ //! ImplĂ©mentation des traits UPnP pour Device. +use std::sync::Arc; use xmltree::{Element, XMLNode}; use crate::{ devices::{Device, DeviceInstance}, - UpnpObject, UpnpModel, + UpnpObject, UpnpModel, UpnpInstance, }; impl UpnpObject for Device { @@ -115,4 +116,19 @@ impl UpnpObject for Device { impl UpnpModel for Device { type Instance = DeviceInstance; + + /// CrĂ©e une instance du device avec ses services dĂ©jĂ  instanciĂ©s. + /// + /// Les services sont créés dans DeviceInstance::new(), cette mĂ©thode + /// Ă©tablit uniquement les liens bidirectionnels parent-enfant. + fn create_instance(&self) -> Arc { + let instance = Arc::new(DeviceInstance::new(self)); + + // Établir le lien parent pour chaque service + for service in instance.services() { + service.set_device(Arc::clone(&instance)); + } + + instance + } } diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 9930f3d0..a5cd62d9 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -1,17 +1,18 @@ mod object_trait; mod object_set; +mod server; pub mod actions; pub mod devices; pub mod mediarenderer; -pub mod server; pub mod services; +pub mod soap; +pub mod ssdp; pub mod state_variables; pub mod value_ranges; pub mod variable_types; -// Re-exports -pub use server::UpnpServer; + use std::{collections::HashMap, sync::Arc}; @@ -19,6 +20,7 @@ use std::{collections::HashMap, sync::Arc}; use std::sync::RwLock; pub use crate::object_trait::*; +pub use crate::server::UpnpServer; #[derive(Debug, Clone)] pub struct UpnpObjectType { diff --git a/pmoupnp/src/object_set.rs b/pmoupnp/src/object_set.rs index 224ce185..2d99ebec 100644 --- a/pmoupnp/src/object_set.rs +++ b/pmoupnp/src/object_set.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use std::sync::RwLock; -use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpSet, UpnpTypedObject}; +use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpTypedObject}; /// ImplĂ©mentation du clonage profond pour `UpnpObjectSet`. /// diff --git a/pmoupnp/src/object_trait.rs b/pmoupnp/src/object_trait.rs index 99c151e9..f7b0f2a8 100644 --- a/pmoupnp/src/object_trait.rs +++ b/pmoupnp/src/object_trait.rs @@ -112,10 +112,7 @@ pub trait UpnpObject: Clone + Debug { elem.write_with_config(&mut buf, config) .expect("Failed to write XML"); - let mut xml_string = "\n".to_string(); - xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8")); - - xml_string + String::from_utf8(buf).expect("Invalid UTF-8") } /// Convertit l'objet en reprĂ©sentation Markdown. diff --git a/pmoupnp/src/server.rs b/pmoupnp/src/server.rs index 007c8342..6b5a288e 100644 --- a/pmoupnp/src/server.rs +++ b/pmoupnp/src/server.rs @@ -1,262 +1,22 @@ -//! Trait pour les serveurs UPnP -//! -//! Ce module dĂ©finit le trait [`UpnpServer`] qui permet de connecter -//! des devices UPnP Ă  n'importe quelle implĂ©mentation de serveur web. -//! -//! ## Architecture -//! -//! Le trait `UpnpServer` dĂ©finit une interface minimale permettant aux devices -//! et services UPnP d'enregistrer leurs endpoints HTTP sans dĂ©pendre d'une -//! implĂ©mentation de serveur spĂ©cifique. -//! -//! ## SĂ©paration des responsabilitĂ©s -//! -//! - **pmoupnp** : DĂ©finit le trait `UpnpServer` et l'utilise via des contraintes gĂ©nĂ©riques -//! - **pmoserver** : Fournit une implĂ©mentation concrĂšte basĂ©e sur Axum -//! - **Autres crates** : Peuvent fournir leurs propres implĂ©mentations (actix-web, warp, etc.) -//! -//! ## Exemple d'utilisation -//! -//! ```rust,no_run -//! use pmoupnp::{UpnpServer, devices::{Device, DeviceInstance}}; -//! use std::sync::Arc; -//! -//! # async fn example(mut server: S) { -//! // CrĂ©er un device -//! let device = Device::new( -//! "MyDevice".to_string(), -//! "MyDeviceType".to_string(), -//! "Friendly Name".to_string(), -//! ); -//! let device_instance = Arc::new(DeviceInstance::new(&device)); -//! -//! // Le device enregistre automatiquement ses routes UPnP -//! device_instance.register_urls(&mut server).await; -//! # } -//! ``` -//! -//! ## ImplĂ©mentation -//! -//! Pour implĂ©menter ce trait, votre serveur doit fournir trois mĂ©thodes -//! pour enregistrer des handlers HTTP asynchrones : -//! -//! ```rust,no_run -//! use pmoupnp::UpnpServer; -//! use std::future::Future; -//! use std::pin::Pin; -//! -//! struct MyServer { -//! // votre implĂ©mentation -//! } -//! -//! impl UpnpServer for MyServer { -//! fn add_handler(&mut self, path: &str, handler: F) -//! -> Pin + Send + '_>> -//! where -//! F: Fn() -> Fut + Send + Sync + 'static + Clone, -//! Fut: Future + Send + 'static, -//! { -//! // Enregistrer le handler pour GET requests -//! # todo!() -//! } -//! -//! fn add_post_handler_with_state( -//! &mut self, -//! path: &str, -//! handler: fn(axum::extract::State, String) -//! -> Pin + Send>>, -//! state: S, -//! ) -> Pin + Send + '_>> -//! where -//! S: Clone + Send + Sync + 'static, -//! { -//! // Enregistrer le handler pour POST avec body -//! # todo!() -//! } -//! -//! fn add_handler_with_state( -//! &mut self, -//! path: &str, -//! handler: fn(axum::extract::State, -//! pmoupnp::server::HeaderMap, -//! pmoupnp::server::Request) -//! -> Pin + Send>>, -//! state: S, -//! ) -> Pin + Send + '_>> -//! where -//! S: Clone + Send + Sync + 'static, -//! { -//! // Enregistrer le handler avec accĂšs complet Ă  la requĂȘte -//! # todo!() -//! } -//! } -//! ``` +use std::sync::Arc; -use std::future::Future; -use std::pin::Pin; +use pmoserver::Server; -/// Type alias pour la rĂ©ponse HTTP (basĂ© sur Axum). -/// -/// UtilisĂ© pour Ă©viter une dĂ©pendance directe sur axum dans les signatures de trait, -/// tout en restant compatible avec les types Axum. -pub type Response = axum::response::Response; +use crate::devices::errors::DeviceError; +use crate::devices::{Device, DeviceInstance}; +use crate::UpnpModel; -/// Type alias pour les en-tĂȘtes HTTP (basĂ© sur Axum). -pub type HeaderMap = axum::http::HeaderMap; +pub trait UpnpServer { + async fn register_device(&mut self, device: Arc) -> Result,DeviceError> ; -/// Type alias pour la requĂȘte HTTP (basĂ© sur Axum). -pub type Request = axum::extract::Request; - -/// Trait pour les serveurs compatibles UPnP. -/// -/// Ce trait dĂ©finit l'interface minimale qu'un serveur web doit implĂ©menter -/// pour supporter l'enregistrement automatique des endpoints UPnP par les -/// [`DeviceInstance`](crate::devices::DeviceInstance) et -/// [`ServiceInstance`](crate::services::ServiceInstance). -/// -/// ## Contraintes -/// -/// - `Send + Sync` : Le serveur doit ĂȘtre partageable entre threads -/// -/// ## MĂ©thodes -/// -/// Les trois mĂ©thodes permettent d'enregistrer diffĂ©rents types de handlers : -/// -/// 1. **`add_handler`** : Handler GET simple sans Ă©tat -/// 2. **`add_post_handler_with_state`** : Handler POST avec Ă©tat et body texte (pour SOAP) -/// 3. **`add_handler_with_state`** : Handler gĂ©nĂ©rique avec accĂšs complet (pour SUBSCRIBE/UNSUBSCRIBE) -/// -/// ## ImplĂ©mentations -/// -/// - **pmoserver::Server** : ImplĂ©mentation basĂ©e sur Axum (fournie par la crate `pmoserver`) -pub trait UpnpServer: Send + Sync { - /// Ajoute un handler GET pour un chemin donnĂ©. - /// - /// UtilisĂ© principalement pour servir les descripteurs XML des devices et services. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/device/MediaRenderer/description.xml`) - /// * `handler` - Une closure asynchrone qui gĂ©nĂšre la rĂ©ponse - /// - /// # Retour - /// - /// Une future qui se rĂ©sout quand le handler est enregistrĂ©. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::UpnpServer; - /// use axum::response::IntoResponse; - /// - /// # async fn example(mut server: S) { - /// server.add_handler("/description.xml", || async { - /// "".into_response() - /// }).await; - /// # } - /// ``` - fn add_handler(&mut self, path: &str, handler: F) -> Pin + Send + '_>> - where - F: Fn() -> Fut + Send + Sync + 'static + Clone, - Fut: Future + Send + 'static; - - /// Ajoute un handler POST avec Ă©tat pour un chemin donnĂ©. - /// - /// UtilisĂ© pour les endpoints de contrĂŽle SOAP des services UPnP. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/service/AVTransport/control`) - /// * `handler` - Un pointeur de fonction qui traite la requĂȘte SOAP - /// * `state` - L'Ă©tat partagĂ© (typiquement une `ServiceInstance`) - /// - /// # Retour - /// - /// Une future qui se rĂ©sout quand le handler est enregistrĂ©. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::{UpnpServer, server::Response}; - /// use axum::extract::State; - /// use std::pin::Pin; - /// use std::future::Future; - /// - /// fn soap_handler( - /// State(service): State, - /// body: String, - /// ) -> Pin + Send>> { - /// Box::pin(async move { - /// // Traiter la requĂȘte SOAP - /// axum::response::Response::default() - /// }) - /// } - /// - /// # async fn example(mut server: S) { - /// server.add_post_handler_with_state( - /// "/control", - /// soap_handler, - /// "ServiceName".to_string(), - /// ).await; - /// # } - /// ``` - fn add_post_handler_with_state( - &mut self, - path: &str, - handler: fn(axum::extract::State, String) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static; - - /// Ajoute un handler avec Ă©tat et accĂšs complet Ă  la requĂȘte. - /// - /// UtilisĂ© pour les endpoints d'Ă©vĂ©nements (SUBSCRIBE/UNSUBSCRIBE) qui nĂ©cessitent - /// un accĂšs aux en-tĂȘtes HTTP et Ă  la mĂ©thode HTTP. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/service/AVTransport/event`) - /// * `handler` - Un pointeur de fonction avec accĂšs complet Ă  la requĂȘte - /// * `state` - L'Ă©tat partagĂ© (typiquement une `ServiceInstance`) - /// - /// # Retour - /// - /// Une future qui se rĂ©sout quand le handler est enregistrĂ©. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::{UpnpServer, server::{Response, HeaderMap, Request}}; - /// use axum::extract::State; - /// use std::pin::Pin; - /// use std::future::Future; - /// - /// fn event_handler( - /// State(service): State, - /// headers: HeaderMap, - /// req: Request, - /// ) -> Pin + Send>> { - /// Box::pin(async move { - /// // Traiter SUBSCRIBE/UNSUBSCRIBE - /// axum::response::Response::default() - /// }) - /// } - /// - /// # async fn example(mut server: S) { - /// server.add_handler_with_state( - /// "/event", - /// event_handler, - /// "ServiceName".to_string(), - /// ).await; - /// # } - /// ``` - fn add_handler_with_state( - &mut self, - path: &str, - handler: fn(axum::extract::State, HeaderMap, Request) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static; } + +impl UpnpServer for Server { + async fn register_device(&mut self, device: Arc) -> Result,DeviceError> { + let di = device.create_instance(); + + di.register_urls(self).await?; + + Ok(di) + } +} \ No newline at end of file diff --git a/pmoupnp/src/services/mod.rs b/pmoupnp/src/services/mod.rs index cd22ad46..15a48c68 100644 --- a/pmoupnp/src/services/mod.rs +++ b/pmoupnp/src/services/mod.rs @@ -447,10 +447,7 @@ impl Service { elem.write_with_config(&mut buf, config) .expect("Failed to write XML"); - let mut xml_string = "\n".to_string(); - xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8")); - - xml_string + String::from_utf8(buf).expect("Invalid UTF-8") } } diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index 7fab502d..76796619 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -304,7 +304,7 @@ impl ServiceInstance { /// # Errors /// /// Retourne une erreur si l'enregistrement des routes Ă©choue. - pub async fn register_urls(&self, server: &mut S) -> Result<(), ServiceError> { + pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> { let device = self.device.read().unwrap(); let device_name = device.as_ref().map(|d| d.get_name().clone()).unwrap_or_else(|| "unknown".to_string()); let server_url = device.as_ref().map(|d| d.base_url().to_string()).unwrap_or_default(); @@ -395,11 +395,8 @@ impl ServiceInstance { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let mut xml = String::from_utf8_lossy(&xml_output).to_string(); - - // Ajouter l'en-tĂȘte XML - xml.insert_str(0, "\n"); - + let xml = String::from_utf8_lossy(&xml_output).to_string(); + ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs new file mode 100644 index 00000000..7e9d4c7e --- /dev/null +++ b/pmoupnp/src/soap/builder.rs @@ -0,0 +1,101 @@ +//! Construction de rĂ©ponses SOAP + +use std::collections::HashMap; +use xmltree::{Element, XMLNode}; + +/// Construit une rĂ©ponse SOAP UPnP +/// +/// # Arguments +/// +/// * `service_urn` - URN du service (ex: "urn:schemas-upnp-org:service:AVTransport:1") +/// * `action` - Nom de l'action (ex: "GetPositionInfo") +/// * `values` - Map des valeurs de retour +/// +/// # Returns +/// +/// XML SOAP formatĂ© en String +pub fn build_soap_response( + service_urn: &str, + action: &str, + values: HashMap, +) -> Result { + // Construire l'Ă©lĂ©ment de rĂ©ponse + // Format: + let response_name = format!("{}Response", action); + let mut response_elem = Element::new(&response_name); + response_elem.namespace = Some(service_urn.to_string()); + response_elem + .attributes + .insert("xmlns:u".to_string(), service_urn.to_string()); + + // Ajouter les valeurs de retour + for (key, value) in values { + let mut child = Element::new(&key); + child.children.push(XMLNode::Text(value)); + response_elem.children.push(XMLNode::Element(child)); + } + + // Construire le Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(response_elem)); + + // Construire l'Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.attributes.insert( + "s:encodingStyle".to_string(), + "http://schemas.xmlsoap.org/soap/encoding/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + // SĂ©rialiser en XML + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_response() { + let mut values = HashMap::new(); + values.insert("Track".to_string(), "5".to_string()); + values.insert("TrackDuration".to_string(), "00:03:45".to_string()); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "GetPositionInfo", + values, + ) + .unwrap(); + + assert!(xml.contains("GetPositionInfoResponse")); + assert!(xml.contains("5")); + assert!(xml.contains("00:03:45")); + assert!(xml.contains("xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")); + } + + #[test] + fn test_build_empty_response() { + let values = HashMap::new(); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "Stop", + values, + ) + .unwrap(); + + assert!(xml.contains("StopResponse")); + assert!(xml.contains("xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\"")); + } +} diff --git a/pmoupnp/src/soap/envelope.rs b/pmoupnp/src/soap/envelope.rs new file mode 100644 index 00000000..3ab19418 --- /dev/null +++ b/pmoupnp/src/soap/envelope.rs @@ -0,0 +1,45 @@ +//! Structures de l'enveloppe SOAP + +use xmltree::Element; + +/// Enveloppe SOAP complĂšte +#[derive(Debug, Clone)] +pub struct SoapEnvelope { + /// En-tĂȘte SOAP optionnel + pub header: Option, + + /// Corps SOAP contenant l'action ou la rĂ©ponse + pub body: SoapBody, +} + +/// En-tĂȘte SOAP +#[derive(Debug, Clone)] +pub struct SoapHeader { + /// Contenu XML brut de l'en-tĂȘte + pub content: Element, +} + +/// Corps SOAP +#[derive(Debug, Clone)] +pub struct SoapBody { + /// Contenu XML brut du corps + pub content: Element, +} + +impl SoapEnvelope { + /// CrĂ©e une nouvelle enveloppe SOAP + pub fn new(body: SoapBody) -> Self { + Self { + header: None, + body, + } + } + + /// CrĂ©e une nouvelle enveloppe avec header + pub fn with_header(header: SoapHeader, body: SoapBody) -> Self { + Self { + header: Some(header), + body, + } + } +} diff --git a/pmoupnp/src/soap/fault.rs b/pmoupnp/src/soap/fault.rs new file mode 100644 index 00000000..b79ce855 --- /dev/null +++ b/pmoupnp/src/soap/fault.rs @@ -0,0 +1,173 @@ +//! SOAP Faults pour UPnP + +use xmltree::{Element, XMLNode}; + +/// Erreur SOAP (Fault) +#[derive(Debug, Clone)] +pub struct SoapFault { + /// Code d'erreur (ex: "s:Client", "401") + pub fault_code: String, + + /// Description de l'erreur + pub fault_string: String, + + /// DĂ©tails UPnP optionnels + pub upnp_error: Option, +} + +/// Erreur UPnP spĂ©cifique +#[derive(Debug, Clone)] +pub struct UpnpError { + /// Code d'erreur UPnP (ex: "401", "501") + pub error_code: String, + + /// Description de l'erreur + pub error_description: String, +} + +impl SoapFault { + /// CrĂ©e un fault SOAP simple + pub fn new(fault_code: String, fault_string: String) -> Self { + Self { + fault_code, + fault_string, + upnp_error: None, + } + } + + /// CrĂ©e un fault SOAP avec erreur UPnP + pub fn with_upnp_error( + fault_code: String, + fault_string: String, + error_code: String, + error_description: String, + ) -> Self { + Self { + fault_code, + fault_string, + upnp_error: Some(UpnpError { + error_code, + error_description, + }), + } + } +} + +/// Construit un SOAP Fault XML +/// +/// # Arguments +/// +/// * `fault_code` - Code du fault (ex: "s:Client") +/// * `fault_string` - Message d'erreur +/// * `upnp_error_code` - Code d'erreur UPnP optionnel (ex: "401") +/// * `upnp_error_desc` - Description d'erreur UPnP optionnelle +/// +/// # Returns +/// +/// XML SOAP Fault formatĂ© +pub fn build_soap_fault( + fault_code: &str, + fault_string: &str, + upnp_error_code: Option<&str>, + upnp_error_desc: Option<&str>, +) -> Result { + // Construire l'Ă©lĂ©ment Fault + let mut fault = Element::new("s:Fault"); + + // faultcode + let mut faultcode_elem = Element::new("faultcode"); + faultcode_elem + .children + .push(XMLNode::Text(fault_code.to_string())); + fault.children.push(XMLNode::Element(faultcode_elem)); + + // faultstring + let mut faultstring_elem = Element::new("faultstring"); + faultstring_elem + .children + .push(XMLNode::Text(fault_string.to_string())); + fault.children.push(XMLNode::Element(faultstring_elem)); + + // detail (si erreur UPnP) + if let (Some(code), Some(desc)) = (upnp_error_code, upnp_error_desc) { + let mut detail = Element::new("detail"); + + let mut upnp_error = Element::new("UPnPError"); + upnp_error.attributes.insert( + "xmlns".to_string(), + "urn:schemas-upnp-org:control-1-0".to_string(), + ); + + let mut error_code_elem = Element::new("errorCode"); + error_code_elem + .children + .push(XMLNode::Text(code.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_code_elem)); + + let mut error_desc_elem = Element::new("errorDescription"); + error_desc_elem + .children + .push(XMLNode::Text(desc.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_desc_elem)); + + detail.children.push(XMLNode::Element(upnp_error)); + fault.children.push(XMLNode::Element(detail)); + } + + // Construire le Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(fault)); + + // Construire l'Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + // SĂ©rialiser + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_simple_fault() { + let xml = build_soap_fault("s:Client", "Invalid Action", None, None).unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("s:Client")); + assert!(xml.contains("Invalid Action")); + assert!(!xml.contains("UPnPError")); + } + + #[test] + fn test_build_upnp_fault() { + let xml = build_soap_fault( + "s:Client", + "UPnP Error", + Some("401"), + Some("Invalid Action"), + ) + .unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("401")); + assert!(xml.contains("Invalid Action")); + } +} diff --git a/pmoupnp/src/soap/mod.rs b/pmoupnp/src/soap/mod.rs new file mode 100644 index 00000000..06dd7868 --- /dev/null +++ b/pmoupnp/src/soap/mod.rs @@ -0,0 +1,89 @@ +//! # Module SOAP - Simple Object Access Protocol +//! +//! Ce module implĂ©mente le support SOAP pour UPnP, permettant l'invocation d'actions +//! et la gestion des rĂ©ponses/erreurs. +//! +//! ## FonctionnalitĂ©s +//! +//! - ✅ Parsing d'enveloppes SOAP +//! - ✅ Extraction d'actions UPnP avec arguments +//! - ✅ Construction de rĂ©ponses SOAP +//! - ✅ Gestion des SOAP Faults +//! - ✅ Support des namespaces UPnP +//! +//! ## Architecture +//! +//! - [`SoapEnvelope`] : Enveloppe SOAP complĂšte +//! - [`SoapAction`] : Action UPnP extraite +//! - [`SoapResponse`] : RĂ©ponse UPnP +//! - [`SoapFault`] : Erreur SOAP +//! +//! ## Example +//! +//! ```ignore +//! use pmoupnp::soap::{parse_soap_action, build_soap_response}; +//! +//! // Parser une action SOAP +//! let body = r#" +//! +//! +//! +//! 0 +//! 1 +//! +//! +//! "#; +//! +//! let action = parse_soap_action(body.as_bytes()).unwrap(); +//! assert_eq!(action.name, "Play"); +//! assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); +//! +//! // Construire une rĂ©ponse +//! let mut values = std::collections::HashMap::new(); +//! values.insert("CurrentTrack".to_string(), "5".to_string()); +//! let response = build_soap_response( +//! "urn:schemas-upnp-org:service:AVTransport:1", +//! "GetPositionInfo", +//! values +//! ).unwrap(); +//! ``` + +mod envelope; +mod parser; +mod builder; +mod fault; + +pub use envelope::{SoapEnvelope, SoapHeader, SoapBody}; +pub use parser::{parse_soap_action, SoapAction}; +pub use builder::build_soap_response; +pub use fault::{SoapFault, build_soap_fault}; + +/// Codes d'erreur SOAP UPnP standards +pub mod error_codes { + /// Action invalide + pub const INVALID_ACTION: &str = "401"; + + /// Arguments invalides + pub const INVALID_ARGS: &str = "402"; + + /// Action Ă©chouĂ©e + pub const ACTION_FAILED: &str = "501"; + + /// Argument manquant + pub const ARGUMENT_VALUE_INVALID: &str = "600"; + + /// Argument hors limites + pub const ARGUMENT_VALUE_OUT_OF_RANGE: &str = "601"; + + /// Action optionnelle non implĂ©mentĂ©e + pub const OPTIONAL_ACTION_NOT_IMPLEMENTED: &str = "602"; + + /// MĂ©moire insuffisante + pub const OUT_OF_MEMORY: &str = "603"; + + /// Erreur humaine lisible + pub const HUMAN_INTERVENTION_REQUIRED: &str = "604"; + + /// Argument sous forme de chaĂźne trop long + pub const STRING_ARGUMENT_TOO_LONG: &str = "605"; +} diff --git a/pmoupnp/src/soap/parser.rs b/pmoupnp/src/soap/parser.rs new file mode 100644 index 00000000..d5d243f9 --- /dev/null +++ b/pmoupnp/src/soap/parser.rs @@ -0,0 +1,149 @@ +//! Parser SOAP pour actions UPnP + +use super::{SoapBody, SoapEnvelope, SoapHeader}; +use std::collections::HashMap; +use std::io::BufReader; +use xmltree::Element; + +/// Action UPnP extraite d'une enveloppe SOAP +#[derive(Debug, Clone)] +pub struct SoapAction { + /// Nom de l'action (ex: "Play", "SetAVTransportURI") + pub name: String, + + /// Namespace de l'action (ex: "urn:schemas-upnp-org:service:AVTransport:1") + pub namespace: Option, + + /// Arguments de l'action + pub args: HashMap, +} + +/// Erreur de parsing SOAP +#[derive(Debug, thiserror::Error)] +pub enum SoapParseError { + #[error("XML parse error: {0}")] + XmlError(#[from] xmltree::ParseError), + + #[error("Missing SOAP Envelope")] + MissingEnvelope, + + #[error("Missing SOAP Body")] + MissingBody, + + #[error("No action found in SOAP Body")] + NoAction, +} + +/// Parse une action SOAP Ă  partir de bytes XML +pub fn parse_soap_action(xml: &[u8]) -> Result { + let envelope = parse_soap_envelope(xml)?; + extract_action_from_body(&envelope.body) +} + +/// Parse une enveloppe SOAP complĂšte +pub fn parse_soap_envelope(xml: &[u8]) -> Result { + let reader = BufReader::new(xml); + let root = Element::parse(reader)?; + + // VĂ©rifier que c'est bien une Envelope + if !root.name.ends_with("Envelope") { + return Err(SoapParseError::MissingEnvelope); + } + + // Extraire Header (optionnel) + let header = root + .get_child("Header") + .or_else(|| root.children.iter().find_map(|n| n.as_element())) + .filter(|e| e.name.ends_with("Header")) + .map(|e| SoapHeader { + content: e.clone(), + }); + + // Extraire Body (obligatoire) + let body_elem = root + .get_child("Body") + .or_else(|| root.children.iter().find_map(|n| { + n.as_element() + .filter(|e| e.name.ends_with("Body")) + })) + .ok_or(SoapParseError::MissingBody)?; + + let body = SoapBody { + content: body_elem.clone(), + }; + + Ok(SoapEnvelope { header, body }) +} + +/// Extrait l'action UPnP du corps SOAP +fn extract_action_from_body(body: &SoapBody) -> Result { + // Le Body contient un Ă©lĂ©ment enfant qui est l'action + // Format: ... + + let action_elem = body + .content + .children + .iter() + .find_map(|n| n.as_element()) + .ok_or(SoapParseError::NoAction)?; + + let name = action_elem.name.clone(); + let namespace = action_elem.namespace.clone(); + + // Extraire les arguments (enfants directs de l'action) + let mut args = HashMap::new(); + for child in &action_elem.children { + if let Some(elem) = child.as_element() { + let arg_name = elem.name.clone(); + let arg_value = elem.get_text().unwrap_or_default().to_string(); + args.insert(arg_name, arg_value); + } + } + + Ok(SoapAction { + name, + namespace, + args, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_action() { + let xml = r#" + + + + 0 + 1 + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Play"); + assert_eq!( + action.namespace, + Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()) + ); + assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); + assert_eq!(action.args.get("Speed"), Some(&"1".to_string())); + } + + #[test] + fn test_parse_action_no_args() { + let xml = r#" + + + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Stop"); + assert!(action.args.is_empty()); + } +} diff --git a/pmoupnp/src/ssdp/device.rs b/pmoupnp/src/ssdp/device.rs new file mode 100644 index 00000000..2b1af1ec --- /dev/null +++ b/pmoupnp/src/ssdp/device.rs @@ -0,0 +1,58 @@ +//! ReprĂ©sentation d'un device SSDP + +/// Device SSDP avec ses mĂ©tadonnĂ©es pour les annonces +#[derive(Debug, Clone)] +pub struct SsdpDevice { + /// UUID du device (sans le prĂ©fixe "uuid:") + pub uuid: String, + + /// Type du device (ex: "urn:schemas-upnp-org:device:MediaRenderer:1") + pub device_type: String, + + /// URL de la description du device + pub location: String, + + /// Identifiant du serveur (ex: "Linux/5.0 UPnP/1.1 PMOMusic/1.0") + pub server: String, + + /// Liste des types de notification (NT) Ă  annoncer + /// Typiquement: [uuid:xxx, device_type, services...] + pub notification_types: Vec, +} + +impl SsdpDevice { + /// CrĂ©e un nouveau device SSDP + pub fn new( + uuid: String, + device_type: String, + location: String, + server: String, + ) -> Self { + // Construction automatique des NTs standards + let notification_types = vec![ + format!("uuid:{}", uuid), + "upnp:rootdevice".to_string(), + device_type.clone(), + ]; + + Self { + uuid, + device_type, + location, + server, + notification_types, + } + } + + /// Ajoute un type de notification (ex: pour un service) + pub fn add_notification_type(&mut self, nt: String) { + if !self.notification_types.contains(&nt) { + self.notification_types.push(nt); + } + } + + /// Retourne la liste des types de notification + pub fn get_notification_types(&self) -> &[String] { + &self.notification_types + } +} diff --git a/pmoupnp/src/ssdp/mod.rs b/pmoupnp/src/ssdp/mod.rs new file mode 100644 index 00000000..17e5cb97 --- /dev/null +++ b/pmoupnp/src/ssdp/mod.rs @@ -0,0 +1,38 @@ +//! # Module SSDP - Simple Service Discovery Protocol +//! +//! Ce module implĂ©mente le protocole SSDP (Simple Service Discovery Protocol) pour UPnP, +//! permettant la dĂ©couverte automatique des devices sur le rĂ©seau. +//! +//! ## FonctionnalitĂ©s +//! +//! - ✅ Envoi de NOTIFY alive/byebye en multicast +//! - ✅ RĂ©ponse aux M-SEARCH en unicast +//! - ✅ Gestion multi-devices avec types de notification +//! - ✅ Annonces pĂ©riodiques automatiques +//! - ✅ ArrĂȘt propre avec byebye +//! +//! ## Architecture +//! +//! - [`SsdpServer`] : Serveur SSDP principal gĂ©rant les devices +//! - [`SsdpDevice`] : ReprĂ©sentation d'un device pour SSDP +//! +//! ## Constants SSDP +//! +//! - **Multicast Address**: 239.255.255.250:1900 +//! - **Max-Age**: 1800 secondes (30 minutes) +//! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2) + +mod device; +mod server; + +pub use device::SsdpDevice; +pub use server::SsdpServer; + +/// Adresse multicast SSDP +pub const SSDP_MULTICAST_ADDR: &str = "239.255.255.250"; + +/// Port SSDP +pub const SSDP_PORT: u16 = 1900; + +/// DurĂ©e de validitĂ© des annonces (en secondes) +pub const MAX_AGE: u32 = 1800; diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs new file mode 100644 index 00000000..4f729242 --- /dev/null +++ b/pmoupnp/src/ssdp/server.rs @@ -0,0 +1,304 @@ +//! Serveur SSDP + +use super::{SsdpDevice, SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tracing::{info, warn}; + +/// Serveur SSDP gĂ©rant les annonces et dĂ©couvertes +pub struct SsdpServer { + /// Devices enregistrĂ©s (UUID -> Device) + devices: Arc>>, + + /// Socket UDP pour SSDP + socket: Option>, +} + +impl SsdpServer { + /// CrĂ©e un nouveau serveur SSDP + pub fn new() -> Self { + Self { + devices: Arc::new(RwLock::new(HashMap::new())), + socket: None, + } + } + + /// DĂ©marre le serveur SSDP + /// + /// # Returns + /// + /// `Ok(())` si le dĂ©marrage a rĂ©ussi, `Err` sinon + pub fn start(&mut self) -> std::io::Result<()> { + let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); + let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?; + + // Rejoindre le groupe multicast + socket.join_multicast_v4( + &SSDP_MULTICAST_ADDR.parse().unwrap(), + &"0.0.0.0".parse().unwrap(), + )?; + + socket.set_read_timeout(Some(Duration::from_secs(1)))?; + socket.set_multicast_loop_v4(false)?; + + let socket = Arc::new(socket); + self.socket = Some(socket.clone()); + + info!("✅ SSDP server started on {}", addr); + + // Lancer les goroutines d'annonces pĂ©riodiques et d'Ă©coute M-SEARCH + self.start_periodic_announcements(socket.clone()); + self.start_msearch_listener(socket.clone()); + + Ok(()) + } + + /// Ajoute un device et envoie un alive initial + pub fn add_device(&self, device: SsdpDevice) { + let uuid = device.uuid.clone(); + let mut devices = self.devices.write().unwrap(); + devices.insert(uuid.clone(), device.clone()); + drop(devices); + + // Envoyer alive pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_alive(socket, &device, nt); + } + } + } + + /// Supprime un device et envoie un byebye + pub fn remove_device(&self, uuid: &str) { + let mut devices = self.devices.write().unwrap(); + if let Some(device) = devices.remove(uuid) { + drop(devices); + + // Envoyer byebye pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_byebye(socket, &device, nt); + } + } + } + } + + /// Envoie un NOTIFY alive + fn send_alive(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY alive for {}: {}", usn, e), + } + } + + /// Envoie un NOTIFY byebye + fn send_byebye(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + NT: {}\r\n\ + NTS: ssdp:byebye\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, nt, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("👋 NOTIFY byebye: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY byebye for {}: {}", usn, e), + } + } + + /// DĂ©marre les annonces pĂ©riodiques (toutes les MAX_AGE/2 secondes) + fn start_periodic_announcements(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + let period = Duration::from_secs((MAX_AGE / 2) as u64); + + std::thread::spawn(move || { + loop { + std::thread::sleep(period); + + let devices = devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + Self::send_alive_static(&socket, device, nt); + } + } + } + }); + } + + /// Version statique de send_alive pour les threads + fn send_alive_static(socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive (periodic): {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send periodic NOTIFY alive for {}: {}", usn, e), + } + } + + /// DĂ©marre l'Ă©coute des M-SEARCH + fn start_msearch_listener(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match socket.recv_from(&mut buf) { + Ok((n, src)) => { + let data = String::from_utf8_lossy(&buf[..n]); + if data.starts_with("M-SEARCH") { + if let Some(st) = Self::parse_st(&data) { + let devices = devices.read().unwrap(); + for device in devices.values() { + Self::handle_msearch(&socket, &src, &st, device); + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, continuer + continue; + } + Err(e) => { + warn!("❌ SSDP read error: {}", e); + } + } + } + }); + } + + /// Parse le champ ST d'un M-SEARCH + fn parse_st(data: &str) -> Option { + for line in data.lines() { + if line.to_uppercase().starts_with("ST:") { + let st = line[3..].trim().to_string(); + info!("✅ M-SEARCH received with ST={}", st); + return Some(st); + } + } + None + } + + /// RĂ©pond Ă  un M-SEARCH + fn handle_msearch(socket: &UdpSocket, src: &SocketAddr, st: &str, device: &SsdpDevice) { + let mut nts = Vec::new(); + + if st == "ssdp:all" { + nts.extend(device.get_notification_types().iter().cloned()); + } else if device.get_notification_types().contains(&st.to_string()) { + nts.push(st.to_string()); + } else { + return; // Pas de match + } + + for nt in nts { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT"); + + let resp = format!( + "HTTP/1.1 200 OK\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + DATE: {}\r\n\ + EXT:\r\n\ + LOCATION: {}\r\n\ + SERVER: {}\r\n\ + ST: {}\r\n\ + USN: {}\r\n\ + \r\n", + MAX_AGE, date, device.location, device.server, nt, usn + ); + + match socket.send_to(resp.as_bytes(), src) { + Ok(_) => info!( + "📡 M-SEARCH response sent to {} with ST={}\n
\n\n```\n{}\n```\n
\n", + src, nt, resp + ), + Err(e) => warn!("❌ Failed to send M-SEARCH response to {}: {}", src, e), + } + } + } +} + +impl Default for SsdpServer { + fn default() -> Self { + Self::new() + } +} + +impl Drop for SsdpServer { + fn drop(&mut self) { + // Envoyer byebye pour tous les devices + if let Some(ref socket) = self.socket { + info!("✅ Shutting down SSDP server, sending byebye for all devices"); + let devices = self.devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + self.send_byebye(socket, device, nt); + } + } + } + } +} diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml index 439ebc85..e971e145 100644 --- a/pmoutils/Cargo.toml +++ b/pmoutils/Cargo.toml @@ -4,4 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] -get_if_addrs = "0.5.3" \ No newline at end of file +get_if_addrs = "0.5.3" +os_info = "3.8" \ No newline at end of file diff --git a/pmoutils/src/lib.rs b/pmoutils/src/lib.rs index e578b603..68b03512 100644 --- a/pmoutils/src/lib.rs +++ b/pmoutils/src/lib.rs @@ -17,4 +17,36 @@ /// ``` mod ip_utils; -pub use ip_utils::guess_local_ip; \ No newline at end of file +pub use ip_utils::guess_local_ip; + +/// Retourne une chaĂźne dĂ©crivant le systĂšme d'exploitation et sa version. +/// +/// Utilise la crate `os_info` pour obtenir de maniĂšre portable et fiable +/// les informations sur le systĂšme d'exploitation courant. +/// +/// # Format +/// - macOS: "macOS/15.1" ou "Mac OS/10.15.7" +/// - Linux: "Linux/6.5.0" ou "Ubuntu/22.04" +/// - Windows: "Windows/10.0.19045" +/// - Autre: "{OS}/Unknown" +/// +/// # Exemples +/// +/// ``` +/// use pmoutils::get_os_string; +/// +/// let os = get_os_string(); +/// println!("OS: {}", os); // Ex: "Linux/6.5.0" +/// ``` +pub fn get_os_string() -> String { + let info = os_info::get(); + let os_type = format!("{:?}", info.os_type()); + + // Obtenir la version si disponible + let version = info.version(); + if version != &os_info::Version::Unknown { + format!("{}/{}", os_type, version) + } else { + format!("{}/Unknown", os_type) + } +} \ No newline at end of file