diff --git a/Cargo.lock b/Cargo.lock index 998566d4c9b64e51611c56ebc10c2a870a86858c..2055200c09c0012f751add3eab1779a7faf06258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "ab_glyph" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79faae4620f45232f599d9bc7b290f88247a0834162c4495ab2f02d60004adfb" +checksum = "ec3672c180e71eeaaac3a541fbbc5f5ad4def8b747c595ad30d674e43049f7b0" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -89,9 +89,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -150,11 +150,10 @@ checksum = "3aa2999eb46af81abb65c2d30d446778d7e613b60bbf4e174a027e80f90a3c14" [[package]] name = "amadeus" -version = "0.0.1" +version = "3.0.1" dependencies = [ "anyhow", "async-channel", - "async-trait", "chrono", "derive_more", "futures", @@ -299,9 +298,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe7e0dd0ac5a401dc116ed9f9119cf9decc625600474cb41f0fc0a0050abc9a" +checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" dependencies = [ "enumflags2", "futures-channel", @@ -822,9 +821,9 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc8b54b395f2fcfbb3d90c47b01c7f444d94d05bdeb775811dec868ac3bbc26" +checksum = "bcfcc3cd946cb52f0bbfdbbcfa2f4e24f75ebb6c0e1002f7c25904fada18b9ec" dependencies = [ "proc-macro2", "quote", @@ -897,9 +896,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.24" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" +checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" dependencies = [ "jobserver", "libc", @@ -946,9 +945,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.18" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -956,9 +955,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.18" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstyle", "clap_lex", @@ -967,9 +966,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.29" +version = "4.5.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8937760c3f4c60871870b8c3ee5f9b30771f792a7045c48bcbba999d7d6b3b8e" +checksum = "9646e2e245bf62f45d39a0f3f36f1171ad1ea0d6967fd114bca72cb02a8fcdfb" dependencies = [ "clap", ] @@ -1198,7 +1197,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1220,7 +1219,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "quote", "syn 1.0.109", @@ -1229,7 +1228,7 @@ dependencies = [ [[package]] name = "cosmic-settings-daemon" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#01ee80cd975ad3f41a47738ed21d778a7cd07552" +source = "git+https://github.com/pop-os/dbus-settings-bindings#931f5db558bf3fcb572ff4e18f7f1618a7430046" dependencies = [ "zbus 4.4.0", ] @@ -1260,7 +1259,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "almost", "cosmic-config", @@ -1865,15 +1864,15 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d5ac82bdbbd872ce0dfea4e848a678cfcfdc0d210a5b20549b8c659fec0392" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" [[package]] name = "font-types" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f0189ccb084f77c5523e08288d418cbaa09c451a08515678a0aa265df9a8b60" +checksum = "dda6e36206148f69fc6ecb1bb6c0dedd7ee469f3db1d0dc2045beea28430ca43" dependencies = [ "bytemuck", ] @@ -1971,9 +1970,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1986,9 +1985,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1996,15 +1995,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2014,9 +2013,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -2048,9 +2047,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -2059,21 +2058,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2140,9 +2139,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gl_generator" @@ -2563,7 +2562,7 @@ dependencies = [ [[package]] name = "iced" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "dnd", "iced_accessibility", @@ -2581,7 +2580,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "accesskit", "accesskit_winit", @@ -2590,7 +2589,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "bitflags 2.6.0", "dnd", @@ -2610,7 +2609,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "futures", "iced_core", @@ -2623,7 +2622,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "bitflags 2.6.0", "bytemuck", @@ -2647,7 +2646,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2659,7 +2658,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "dnd", "iced_core", @@ -2671,7 +2670,7 @@ dependencies = [ [[package]] name = "iced_style" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "iced_core", "once_cell", @@ -2681,7 +2680,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "bytemuck", "cosmic-text", @@ -2698,7 +2697,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "as-raw-xcb-connection", "bitflags 2.6.0", @@ -2727,7 +2726,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "dnd", "iced_renderer", @@ -2743,7 +2742,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "dnd", "iced_graphics", @@ -2821,12 +2820,12 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.0", ] [[package]] @@ -2890,9 +2889,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "itoa" @@ -2942,9 +2941,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -3017,8 +3016,10 @@ dependencies = [ name = "kurisu_api" version = "8.0.1" dependencies = [ + "derive_more", "hashbrown 0.15.0", "lektor_utils", + "log", "serde", "serde_json", "sha256", @@ -3041,7 +3042,6 @@ name = "lektor_lib" version = "8.0.1" dependencies = [ "anyhow", - "async-trait", "futures", "lektor_payloads", "lektor_utils", @@ -3071,8 +3071,8 @@ name = "lektor_nkdb" version = "8.0.1" dependencies = [ "anyhow", - "async-trait", "chrono", + "derive_more", "futures", "hashbrown 0.15.0", "kurisu_api", @@ -3098,6 +3098,7 @@ dependencies = [ "axum", "futures", "lektor_nkdb", + "lektor_procmacros", "lektor_utils", "serde", "serde_json", @@ -3151,7 +3152,6 @@ name = "lektord" version = "8.0.1" dependencies = [ "anyhow", - "async-trait", "axum", "clap", "futures", @@ -3181,10 +3181,10 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#228eb4d70d581be88bacb1e261106a58603d847b" +source = "git+https://github.com/pop-os/libcosmic.git#8da25f94e9c363c8c6b93284adf4cf6e07bc3a45" dependencies = [ "apply", - "ashpd 0.9.1", + "ashpd 0.9.2", "chrono", "cosmic-config", "cosmic-settings-daemon", @@ -3215,6 +3215,7 @@ dependencies = [ "tracing", "unicode-segmentation", "url", + "ustr", "zbus 4.4.0", ] @@ -3289,7 +3290,6 @@ name = "lkt" version = "8.0.1" dependencies = [ "anyhow", - "async-trait", "chrono", "clap", "clap_complete", @@ -3336,11 +3336,11 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lru" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.0", ] [[package]] @@ -3808,21 +3808,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.20.1" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" -dependencies = [ - "portable-atomic", -] +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "option-ext" @@ -3885,11 +3882,11 @@ dependencies = [ [[package]] name = "owned_ttf_parser" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490d3a563d3122bf7c911a59b0add9389e5ec0f5f0c3ac6b91ff235a0e6a7f90" +checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" dependencies = [ - "ttf-parser 0.24.1", + "ttf-parser 0.25.0", ] [[package]] @@ -4110,12 +4107,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" -[[package]] -name = "portable-atomic" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -4176,9 +4167,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] @@ -4340,9 +4331,9 @@ checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" [[package]] name = "read-fonts" -version = "0.20.0" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c141b9980e1150201b2a3a32879001c8f975fe313ec3df5471a9b5c79a880cd" +checksum = "fb94d9ac780fdcf9b6b252253f7d8f221379b84bd3573131139b383df69f85e1" dependencies = [ "bytemuck", "font-types", @@ -4657,9 +4648,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.13" +version = "0.23.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" dependencies = [ "once_cell", "ring", @@ -4937,9 +4928,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "skrifa" -version = "0.20.0" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abea4738067b1e628c6ce28b2c216c19e9ea95715cdb332680e821c3bec2ef23" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" dependencies = [ "bytemuck", "read-fonts", @@ -5155,9 +5146,9 @@ dependencies = [ [[package]] name = "swash" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93cdc334a50fcc2aa3f04761af3b28196280a6aaadb1ef11215c478ae32615ac" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" dependencies = [ "skrifa", "yazi", @@ -5245,12 +5236,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" dependencies = [ "rustix 0.38.37", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -5366,7 +5357,6 @@ dependencies = [ "bytes", "libc", "mio 1.0.2", - "parking_lot 0.12.3", "pin-project-lite", "signal-hook-registry", "socket2 0.5.7", @@ -5553,9 +5543,9 @@ checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" [[package]] name = "ttf-parser" -version = "0.24.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +checksum = "5902c5d130972a0000f60860bfbf46f7ca3db5391eddfedd1b8728bd9dc96c0e" [[package]] name = "type-map" @@ -5604,9 +5594,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-bidi-mirroring" @@ -5713,6 +5703,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "ustr" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e904a2279a4a36d2356425bb20be271029cc650c335bc82af8bfae30085a3d0" +dependencies = [ + "ahash", + "byteorder", + "lazy_static", + "parking_lot 0.12.3", + "serde", +] + [[package]] name = "usvg" version = "0.37.0" @@ -5813,9 +5816,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -5824,9 +5827,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -5839,9 +5842,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", @@ -5851,9 +5854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5861,9 +5864,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -5874,9 +5877,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-timer" @@ -6029,9 +6032,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 693ef9df0dd66186ae4b1bcd4487ed1dd9bd1b9c..638ca35acb8a20e935462f3f507fbbfa105fb55c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ [workspace.package] edition = "2021" authors = [ - "Maël MARTIN <mael.martin@protonmail.com>", + "Maëlle MARTIN <maelle.martin@proton.me>", "Louis GOYARD <elliu@hashi.re>", "Loïc ALLEGRE <lallegre26@gmail.com>", "Kevin COCCHI <salixor@pm.me>", @@ -44,7 +44,7 @@ zbus = { version = "*", default-features = false, features = ["tokio"] } chrono = { version = "*", default-features = false, features = ["clock"] } sha256 = { version = "*", default-features = false, features = ["async"] } anyhow = { version = "*", default-features = false, features = ["std"] } -regex = { version = "*", default-features = false, features = ["std", "perf"] } +regex = { version = "*", default-features = false, features = ["std"] } log = "*" rand = "*" base64 = "*" @@ -63,6 +63,7 @@ lektor_utils = { path = "lektor_utils" } lektor_mpris = { path = "lektor_mpris" } lektor_payloads = { path = "lektor_payloads" } lektor_procmacros = { path = "lektor_procmacros" } +lektor_nkdb = { path = "lektor_nkdb" } # Data Structures hashbrown = { version = "*", features = ["serde"] } @@ -71,24 +72,20 @@ async-channel = { version = "*", default-features = false } # Serialization & Deserialization toml = "*" serde_json = { version = "*", default-features = false, features = [ - "std", - "preserve_order", + "std", "preserve_order" ] } serde = { version = "*", default-features = false, features = [ - "rc", - "std", - "derive", + "rc", "std", "derive" ] } # Async stuff async-trait = "*" futures-util = "*" -futures = { version = "*", default-features = false, features = [ - "std", - "async-await", +futures = { version = "*", default-features = false, features = ["std", "async-await"] } +tokio-stream = { version = "*", default-features = false, features = ["net"]} +tokio = { version = "*", default-features = false, features = [ + "rt-multi-thread", "net", "time", "sync", "fs", "signal" ] } -tokio = { version = "*", features = [ "full" ] } -tokio-stream = { version = "*", features = [ "net" ], default-features = false } # Web stuff reqwest = { version = "*", default-features = false, features = [ @@ -101,22 +98,15 @@ axum = { version = "*", default-features = false, features = [ "macros", "tokio", ] } -tower = { version = "*", features = ["util"] } -hyper-util = { version = "*", features = ["tokio", "server-auto", "http1"] } -hyper = { version = "*", default-features = false, features = [ - "http1", - "server", -] } +tower = { version = "*", default-features = false, features = ["util"] } +hyper-util = { version = "*", default-features = false, features = ["http1", "tokio", "server-auto"] } +hyper = { version = "*", default-features = false, features = ["http1", "server"] } # Arguments -roff = "*" +roff = "*" clap_complete = { version = "*", default-features = false } -clap = { version = "*", default-features = false, features = [ - "usage", - "help", - "std", - "wrap_help", - "derive", +clap = { version = "*", default-features = false, features = [ + "usage", "help", "std", "wrap_help", "derive" ] } # Proc macro things diff --git a/README.md b/README.md index d702083403795916ef97238c9d159bc28ea54c2a..f8ce1125caec5c544c09e32424a93269f2afcc16 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [](https://matrix.to/#/#baka-dev-lektor:iiens.net) [](https://git.iiens.net/martin2018/lektor/-/commits/master) [](https://git.iiens.net/martin2018/lektor/-/tags) -[](https://git.iiens.net/martin2018/lektor/-/tags) +[](https://git.iiens.net/martin2018/lektor/-/tags) <a href="https://github.com/iced-rs/iced"> <img src="https://gist.githubusercontent.com/hecrj/ad7ecd38f6e47ff3688a38c79fd108f0/raw/74384875ecbad02ae2a926425e9bcafd0695bade/color.svg" width="100px"> </a> diff --git a/amadeus/Cargo.toml b/amadeus/Cargo.toml index dc72b513fb815cfb372cb57b0d9cfb20cce45086..df2c16122137037391c5ee3fff1ab47480b034d8 100644 --- a/amadeus/Cargo.toml +++ b/amadeus/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "amadeus" description = "Amadeus-RS, graphical interface for lektord" -version = "0.0.1" +version = "3.0.1" rust-version.workspace = true edition.workspace = true @@ -31,7 +31,6 @@ futures-util.workspace = true tokio.workspace = true reqwest.workspace = true futures.workspace = true -async-trait.workspace = true i18n-embed-fl.workspace = true rust-embed.workspace = true diff --git a/amadeus/i18n/en/amadeus.ftl b/amadeus/i18n/en/amadeus.ftl index 570f09ac5211e49fb71d7438a6470addb8fa588e..64b16d88b8421abf0c646495c6296febd4ae88cb 100644 --- a/amadeus/i18n/en/amadeus.ftl +++ b/amadeus/i18n/en/amadeus.ftl @@ -12,6 +12,10 @@ settings = Settings playback = Playback page-id = Page { $num } +empty-queue = Empty Queue +empty-history = Empty History +empty-playlists = Empty playlists + next-kara = Next kara prev-kara = Previous kara toggle-playback = Play/Pause @@ -27,7 +31,7 @@ home = Home queue = Queue search = Search playlists = Playlists -playlist = Playlist { $name } +playlist = Playlist “{ $name }†log-level = Log level icon-theme = Icon theme @@ -47,3 +51,7 @@ epoch = { $what } epoch user = User config = { $what } configuration url-of = { $what } URL + +kara = Kara +click-to-dl = Click to download + diff --git a/amadeus/i18n/es-ES/amadeus.ftl b/amadeus/i18n/es-ES/amadeus.ftl index 4f30527ff4d0ec1934d86299c3c5ff18b4ed46f4..53619d631fdcbb094d895307384c106a2c06acbb 100644 --- a/amadeus/i18n/es-ES/amadeus.ftl +++ b/amadeus/i18n/es-ES/amadeus.ftl @@ -12,6 +12,10 @@ settings = Ajustes playback = Reproducción page-id = Pagina { $num } +empty-queue = No hay nada en la cola de reproducción +empty-history = No has escuchado nada recientemente +empty-playlists = No tienes listas de reproducción + next-kara = Póxima kara prev-kara = Previo kara toggle-playback = Alternar reprod. @@ -27,7 +31,7 @@ home = Inicio queue = Cola de reproducción search = Buscar playlists = Listas de reproducción -playlist = Lista { $name } +playlist = Lista “{ $name }†log-level = Nivel de registro icon-theme = Tema de icono @@ -47,3 +51,7 @@ epoch = Época de { $what } user = Usuario·a config = Ajustes de { $what } url-of = Dirección de { $what } + +kara = Kara +click-to-dl = Haga clic para descargar + diff --git a/amadeus/i18n/fr-FR/amadeus.ftl b/amadeus/i18n/fr-FR/amadeus.ftl new file mode 100644 index 0000000000000000000000000000000000000000..8d581be677a351980f6b1e08f27deeaa588012a5 --- /dev/null +++ b/amadeus/i18n/fr-FR/amadeus.ftl @@ -0,0 +1,57 @@ +app-title = Amadeus + +unimplemented = Il faut implémenter { $what } +error-found = Une erreur est survenue + +about = À propos de +edit = Éditer +view = Vue +history = Historique +welcome = Bonjour! +settings = Paramettres +playback = Letcure +page-id = Page { $num } + +empty-queue = La queue est vide +empty-history = L'historique est vide +empty-playlists = Il n'y a pas de listes de lecture de disponibles + +next-kara = Kara suivant +prev-kara = Kara précédent +toggle-playback = Play/Pause +play-playback = Play +pause-playback = Pause +stop-playback = Stop +playback-shuffle = Mélanger +playback-crop = Recadrer +playback-clear = Effacer +menu-queue = Queue + +home = Accueil +queue = Queue +search = Chercher +playlists = Listes de lecture +playlist = Liste « { $name } » + +log-level = Log level +icon-theme = Theme d'icones +dark-theme = Theme sombre +address-ip = Address +scheme = Scheme +kurisu-token = Token pour Kurisu +open-url = Ouvrir l'URL { $url } +token = Token +port = Port +status = Status +connected = Connecté +disconnected = Déconnecté +version = Version +info-about = Information sur { $what } +epoch = Époch { $what } +user = Utilisateur +config = Configuration de { $what } +url-of = URL pour { $what } + +kara = Kara +click-to-dl = Clicker pour télécharger + diff --git a/amadeus/rsc/icons/fontawesome/crop.svg b/amadeus/rsc/icons/fontawesome/crop.svg new file mode 100644 index 0000000000000000000000000000000000000000..eae7672457ab832a79f159b1a2c682b720133a8c --- /dev/null +++ b/amadeus/rsc/icons/fontawesome/crop.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M128 32c0-17.7-14.3-32-32-32S64 14.3 64 32l0 32L32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l32 0 0 256c0 35.3 28.7 64 64 64l224 0 0-64-224 0 0-352zM384 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32 32 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-32 0 0-256c0-35.3-28.7-64-64-64L160 64l0 64 224 0 0 352z"/></svg> diff --git a/amadeus/rsc/icons/fontawesome/retry.svg b/amadeus/rsc/icons/fontawesome/retry.svg new file mode 100644 index 0000000000000000000000000000000000000000..50c27ae92b2b335e61d62ce4e24a8ddcb6c30341 --- /dev/null +++ b/amadeus/rsc/icons/fontawesome/retry.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M463.5 224l8.5 0c13.3 0 24-10.7 24-24l0-128c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8l119.5 0z"/></svg> diff --git a/amadeus/src/app.rs b/amadeus/src/app.rs index df1846dc8dd431a00b90592da15814a989476bfc..359180a2b8a2291676ab79c0b750a1026a22a2b0 100644 --- a/amadeus/src/app.rs +++ b/amadeus/src/app.rs @@ -1,9 +1,12 @@ +//! Implements the whole application model. + mod bottom_bar; mod context_pages; mod kard; mod menu; mod pages; mod progress_bar; +mod subscriptions; use crate::{ app::{ @@ -14,23 +17,23 @@ use crate::{ config::{Config, LogLevel}, fl, store::Store, - subscriptions, }; use cosmic::{ app::{Command, Core}, - iced::{ - alignment::{Horizontal, Vertical}, - Length, Subscription, - }, + iced::{Length, Subscription}, + prelude::*, + style, theme, widget, Application, +}; +use futures::{ prelude::*, - theme, widget, Application, + stream::{self, FuturesUnordered}, }; -use lektor_lib::ConnectConfig; +use lektor_lib::{requests, ConnectConfig}; use lektor_payloads::{ - KId, PlayStateWithCurrent, Playlist, PlaylistName, Priority, PRIORITY_LENGTH, PRIORITY_VALUES, + KId, Kara, PlayStateWithCurrent, Priority, SearchFrom, PRIORITY_LENGTH, PRIORITY_VALUES, }; use lektor_utils::{config::SocketScheme, open}; -use pages::search::{FilterAtom, FilterAtomId}; +use pages::search::FilterAtomId; use std::{ borrow::Cow, collections::HashMap, @@ -87,13 +90,22 @@ pub struct AppModel { tmp_remote_token: String, } +/// The state of lektord that we queried. #[derive(Debug, Clone, Default)] enum LektordState { + /// Lektord is disconnected. #[default] Disconnected, + + /// Lektord is connected. Connected { + /// A version string, which is the build string from git. version: String, + + /// The DB epoch. last_epoch: Option<u64>, + + /// The state of the playback. state: Option<lektor_payloads::PlayStateWithCurrent>, }, } @@ -126,8 +138,9 @@ impl LektordState { } /// A command to send to the lektord instance. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum LektordCommand { + // Playback stuff PlaybackToggle, PlaybackPlay, PlaybackPause, @@ -135,9 +148,33 @@ pub enum LektordCommand { PlaybackNext, PlaybackPrevious, + // Queue stuff QueueShuffle, QueueClear, QueueCrop, + QueueGet, + + // QueueLevel stuff + QueueLevelShuffle(Priority), + QueueLevelClear(Priority), + QueueLevelGet(Priority), + + // History stuff + HistoryClear, + HistoryGet, + + // Playlists stuff + PlaylistsGet, + + // Playlist stuff + PlaylistDelete(KId), + PlaylistRemoveKara(KId, KId), + PlaylistGetContent(KId), + PlaylistShuffleContent(KId), + + // Misc stuff + DownloadKaraInfo(KId), + DownloadKarasInfo(Vec<KId>), } /// Something changed with the config. @@ -162,33 +199,40 @@ pub enum LektordMessage { Connected(lektor_payloads::Infos), PlaybackUpdate(lektor_payloads::PlayStateWithCurrent), - DownloadKaraInfo(KId), - DownloadKarasInfo(Vec<KId>), + DownloadedKaraInfo(Kara), + DownloadedKarasInfo(Vec<Kara>), ChangedQueue([Vec<KId>; PRIORITY_LENGTH]), ChangedQueueLevel(Priority, Vec<KId>), ChangedHistory(Vec<KId>), - ChangedAvailablePlaylists(Vec<PlaylistName>), - ChangedPlaylistContent(PlaylistName, Playlist), + ChangedAvailablePlaylists(Vec<KId>), + ChangedPlaylistContent(KId, Vec<KId>), + ChangedPlaylistsContent(Vec<(KId, Vec<KId>)>), } /// Messages emitted by the application and its widgets. #[derive(Debug, Clone)] pub enum Message { + // Misc UI things OpenUrl(&'static str), OpenKaraInfo(KId), ToggleContextPage(ContextPage), + // Update the configuration UpdateConfig(ConfigMessage), + // Lektord stuff SendCommand(LektordCommand), - Lektord(LektordMessage), + LektordUpdate(LektordMessage), + // Search stuff ChangeFilterStageBuffer(String), AddFilterAtomFromStageBuffer, RemoveFilterAtom(FilterAtomId), + QueryWithFiltersResults(Vec<KId>), ClearFilters, QueryWithFilters, + SearchOnlyAuthor(String), } /// Create a COSMIC application from the app model @@ -255,15 +299,18 @@ impl Application for AppModel { fn header_center(&self) -> Vec<Element<Self::Message>> { let is_connected = !matches!(self.lektord_state, LektordState::Disconnected); macro_rules! icon { - ($icon:ident => $action:ident) => {{ + ($icon:ident => $action:ident $(| $true:ident)?) => {{ let button = widget::icon::from_svg_bytes(crate::icons::$icon) .symbolic(true) .apply(widget::button::icon); Element::from(match is_connected { - true => button.on_press(Message::SendCommand(LektordCommand::$action)), - false => button, + true => icon!(@ $($true)?, button).on_press(Message::SendCommand(LektordCommand::$action)), + false => icon!(@ $($true)?, button), }) }}; + + (@ destructive, $expr:expr) => { $expr.style(style::Button::AppletIcon) }; + (@ , $expr:expr) => { $expr }; } let pre = [ @@ -274,7 +321,8 @@ impl Application for AppModel { let post = [ icon!(NEXT => PlaybackNext), icon!(SHUFFLE => QueueShuffle), - icon!(METEOR => QueueClear), + icon!(CROP => QueueCrop | destructive), + icon!(METEOR => QueueClear | destructive), Element::from(widget::horizontal_space(Length::Fill)), ]; @@ -322,25 +370,21 @@ impl Application for AppModel { (self.core.window.show_context).then(|| match self.context_page { ContextPage::About => context_pages::about::view(self), ContextPage::Settings => context_pages::config::view(self), + ContextPage::KaraInfo(kid) => context_pages::kara_info::view(self.store.get(kid)), }) } /// Describes the interface based on the current state of the application model. fn view(&self) -> Element<Self::Message> { - let page = match self.nav.active_data::<Page>() { - Some(Page::Home) => pages::home::view(), - Some(Page::Queue) => pages::queue::view(&self.store), - Some(Page::History) => pages::history::view(&self.store), - Some(Page::Search) => pages::search::view(self), - Some(Page::Playlists) => pages::playlists::view(&self.store), - Some(Page::Playlist(name)) => pages::playlist::view(&self.store, name), - page => pages::not_found(page), - } - .apply(widget::container) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Horizontal::Center) - .align_y(Vertical::Top); + let page = match self.nav.active_data::<Page>().copied() { + Some(Page::Home) => pages::home::view().into(), + Some(Page::Queue) => pages::queue::view(&self.store).into(), + Some(Page::History) => pages::history::view(&self.store).into(), + Some(Page::Search) => pages::search::view(self).into(), + Some(Page::Playlists) => pages::playlists::view(&self.store).into(), + Some(Page::Playlist(id)) => pages::playlist::view(&self.store, id).into(), + page => pages::not_found(page).into(), + }; widget::column() .push(page) @@ -372,7 +416,7 @@ impl Application for AppModel { match message { Message::UpdateConfig(message) => self.update_config(message), - Message::Lektord(message) => self.handle_lektord_message(message), + Message::LektordUpdate(message) => self.handle_lektord_message(message), Message::OpenUrl(url) => self.open_url(url), Message::ToggleContextPage(context_page) => self.toggle_context_page(context_page), @@ -396,13 +440,32 @@ impl Application for AppModel { } Message::ClearFilters => { self.search_filter.clear(); - self.search_results.clear();; + self.search_results.clear(); Command::none() } - Message::QueryWithFilters => { - log::error!("query with filters"); + Message::QueryWithFiltersResults(results) => { + self.search_results = results; + Command::none() + } + Message::SearchOnlyAuthor(author) => { + log::error!("implement search of only author {author}, need to clear everything and do the query"); Command::none() } + Message::QueryWithFilters => { + let config = self.connect_config.clone(); + let filters: Vec<_> = self.search_filter.iter_cloned().collect(); + cosmic::command::future(async move { + requests::search_karas(&*config.read().await, SearchFrom::Database, filters) + .await + .map(|matches| { + cosmic::app::message::app(Message::QueryWithFiltersResults(matches)) + }) + .unwrap_or_else(|err| { + log::error!("failed to query with filters: {err}"); + cosmic::app::message::none() + }) + }) + } } } @@ -429,7 +492,7 @@ impl AppModel { fn update_connect_config(&self) -> Command<Message> { let config = self.config.get_connect_config(); let connect_config = self.connect_config.clone(); - cosmic::command::future(async { + cosmic::command::future(async move { *connect_config.write_owned().await = config; cosmic::app::message::none() }) @@ -443,7 +506,7 @@ impl AppModel { self.context_page = context_page; self.core.window.show_context = true; } - self.set_context_title(context_page.title()); + self.set_context_title(self.context_page.title()); Command::none() } @@ -542,9 +605,9 @@ impl AppModel { fn handle_lektord_message(&mut self, message: LektordMessage) -> Command<Message> { use LektordMessage::*; match message { - // Asked to download metadata informations. - DownloadKaraInfo(kid) => log::error!("download kara info {kid}"), - DownloadKarasInfo(kids) => log::error!("download kara info {kids:?}"), + // Downloaded metadata informations. + DownloadedKaraInfo(kara) => self.store.set(kara), + DownloadedKarasInfo(karas) => karas.into_iter().for_each(|kara| self.store.set(kara)), // Disconnected, if any query failed we set the disconnected status Disconnected => { @@ -576,14 +639,38 @@ impl AppModel { } // Down here, got updates from lektord. - ChangedAvailablePlaylists(names) => self.store.keep_playlists(names), - ChangedPlaylistContent(name, plt) => self.store.set_playlist(name, plt), + ChangedAvailablePlaylists(names) => { + let config = self.connect_config.clone(); + let playlists = self.store.keep_playlists(names); + return cosmic::command::future(async move { + let updated_playlists = stream::iter(playlists) + .zip(stream::repeat_with(move || config.clone())) + .then(|(id, config)| async move { + let config = config.read().await; + requests::get_playlist_content(config.as_ref(), id) + .await + .map(|content| (id, content)) + }) + .collect::<FuturesUnordered<_>>() + .await; + Message::LektordUpdate(ChangedPlaylistsContent( + updated_playlists.into_iter().flatten().collect::<Vec<_>>(), + )) + }); + } + ChangedHistory(kids) => self.store.set_history(kids), + ChangedQueueLevel(lvl, kids) => self.store.set_queue_level(lvl, kids), ChangedQueue(mut queue) => PRIORITY_VALUES.iter().for_each(|&level| { let kids = mem::take(&mut queue[level.index()]); self.store.set_queue_level(level, kids); }), + + ChangedPlaylistContent(name, plt) => self.store.set_playlist_content(name, plt), + ChangedPlaylistsContent(changes) => changes + .into_iter() + .for_each(|(name, plt)| self.store.set_playlist_content(name, plt)), } Command::none() } @@ -592,11 +679,16 @@ impl AppModel { fn send_command(&self, cmd: LektordCommand) -> Command<Message> { let config = self.connect_config.clone(); use lektor_payloads::*; + macro_rules! msg { + ($msg:ident $(($($args:expr),+))?) => { + cosmic::app::message::app(Message::LektordUpdate(LektordMessage::$msg $(($($args),+))?)) + }; + } macro_rules! cmd { ($req:ident ($($arg:expr),*) $(, $res:pat => $handle:expr)?) => { cosmic::command::future(async move { cmd!(@handle $req: - lektor_lib::requests::$req(config.read().await.as_ref() $(, $arg)*).await, + requests::$req(config.read().await.as_ref() $(, $arg)*).await, $($res => $handle)? ) }) @@ -620,12 +712,40 @@ impl AppModel { LektordCommand::PlaybackNext => cmd!(play_next()), LektordCommand::PlaybackPrevious => cmd!(play_previous()), - LektordCommand::QueueShuffle => cmd!(shuffle_queue()), + LektordCommand::QueueShuffle => cmd!(shuffle_queue_range(..)), + LektordCommand::QueueClear => cmd!(remove_range_from_queue(..)), + LektordCommand::QueueCrop => cmd!(remove_range_from_queue(1..)), + LektordCommand::QueueGet => cmd!(get_queue_range(..), queue => { + msg!(ChangedQueue(queue.into_iter().fold(<[Vec<KId>; PRIORITY_LENGTH]>::default(), |mut ret, (level, kid)| { + ret[level.index()].push(kid); + ret + }))) + }), + LektordCommand::QueueLevelShuffle(lvl) => cmd!(shuffle_level_queue(lvl)), + LektordCommand::QueueLevelClear(lvl) => cmd!(remove_level_from_queue(lvl)), + LektordCommand::QueueLevelGet(lvl) => cmd!(get_queue_level(lvl), queue => { + msg!(ChangedQueueLevel(lvl, queue)) + }), - cmd => { - log::error!("need to implement {cmd:?}"); - Command::none() - } + LektordCommand::DownloadKaraInfo(kid) => cmd!(get_kara_by_kid(kid), kara => { + msg!(DownloadedKaraInfo(kara)) + }), + LektordCommand::DownloadKarasInfo(kids) => cmd!(get_karas_by_kid(kids), karas => { + msg!(DownloadedKarasInfo(karas)) + }), + + LektordCommand::HistoryClear => cmd!(remove_range_from_history(..)), + LektordCommand::HistoryGet => cmd!(get_history_range(..), history => { + msg!(ChangedHistory(history)) + }), + + LektordCommand::PlaylistGetContent(id) => cmd!(get_playlist_content(id), content => { + msg!(ChangedPlaylistContent(id, content)) + }), + LektordCommand::PlaylistsGet => todo!(), + LektordCommand::PlaylistShuffleContent(_) => todo!(), + LektordCommand::PlaylistDelete(_) => todo!(), + LektordCommand::PlaylistRemoveKara(..) => todo!(), } } } diff --git a/amadeus/src/app/bottom_bar.rs b/amadeus/src/app/bottom_bar.rs index e9f5528894f1e98d8a120e81ce6449a5c0e5fba7..f4d15607a64e1822ce2417b9754662937de59d32 100644 --- a/amadeus/src/app/bottom_bar.rs +++ b/amadeus/src/app/bottom_bar.rs @@ -1,5 +1,6 @@ use crate::{ - app::{AppModel, LektordMessage, Message}, + app::{AppModel, LektordCommand, Message}, + fl, store::KaraOrId, }; use cosmic::{ @@ -10,7 +11,9 @@ use cosmic::{ Alignment, Length, }, iced_core::text::Wrap, - style, theme, widget, Apply, Element, + prelude::*, + style, theme, + widget::{self, tooltip::Position}, }; use lektor_payloads::{KId, Kara}; @@ -79,7 +82,7 @@ fn view_left_part<'a>(kara: &Kara) -> Element<'a, Message> { .style(theme::Text::Color( theme::active().cosmic().accent_text_color().into(), )) - .font(font::FONT_LIGHT) + .font(font::light()) .wrap(Wrap::None); widget::column() @@ -87,7 +90,7 @@ fn view_left_part<'a>(kara: &Kara) -> Element<'a, Message> { .push(source) .apply(widget::button::custom) .style(style::Button::Transparent) - .on_press(Message::OpenKaraInfo(kara.id.clone())) + .on_press(Message::OpenKaraInfo(kara.id)) .apply(widget::container) .align_x(Horizontal::Left) .align_y(Vertical::Center) @@ -104,7 +107,8 @@ fn view_kara_id<'a>(kid: KId) -> Element<'a, Message> { .wrap(Wrap::None) .apply(widget::button::custom) .style(style::Button::Transparent) - .on_press(Message::Lektord(LektordMessage::DownloadKaraInfo(kid))) + .on_press(Message::SendCommand(LektordCommand::DownloadKaraInfo(kid))) + .apply(|btn| widget::tooltip(btn, fl!("click-to-dl"), Position::Top)) .apply(widget::container) .align_x(Horizontal::Left) .align_y(Vertical::Center) @@ -118,7 +122,7 @@ fn view_kara_id<'a>(kid: KId) -> Element<'a, Message> { pub fn view<'a>(app: &AppModel) -> Element<'a, Message> { match app.lektord_state.current_kid() { None => return widget::row().into(), - Some(kid) => match app.store.get(kid) { + Some(&kid) => match app.store.get(kid) { KaraOrId::Kara(kara) => vec![view_left_part(kara), view_right_part(kara)], KaraOrId::Id(kid) => vec![view_kara_id(kid)], } diff --git a/amadeus/src/app/context_pages.rs b/amadeus/src/app/context_pages.rs index e22a40fa2d1567f42546ca56f9f9e9b6c2d704b7..f74507bc738cbb230c2f8cf5ad0f56d135436cbd 100644 --- a/amadeus/src/app/context_pages.rs +++ b/amadeus/src/app/context_pages.rs @@ -1,23 +1,33 @@ use crate::fl; +use derive_more::Display; +use lektor_payloads::KId; pub mod about; pub mod config; +pub mod kara_info; /// The context page to display in the context drawer. This is the pane on the right that can be /// hidden or shown. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Display)] pub enum ContextPage { + /// Show the about page, with informations on Amadeus and the Connected lektord instance. #[default] + #[display("{}", fl!("about"))] About, + /// Show the settings page for Amadeus. + #[display("{}", fl!("settings"))] Settings, + + /// Show more informations about a kara than what is shown in tables (more tags, time, size, + /// etc...) + #[display("{}", fl!("kara"))] + KaraInfo(KId), } impl ContextPage { + /// The title of the context page. pub fn title(&self) -> String { - match self { - Self::About => fl!("about"), - Self::Settings => fl!("settings"), - } + self.to_string() } } diff --git a/amadeus/src/app/context_pages/about.rs b/amadeus/src/app/context_pages/about.rs index 7b8e61534eeb1255efd9fcc3687b7a832bfd02dc..da39f35309a66ffb6e91d38bcea7d5dfce023f3d 100644 --- a/amadeus/src/app/context_pages/about.rs +++ b/amadeus/src/app/context_pages/about.rs @@ -7,6 +7,7 @@ use cosmic::{ theme, widget, Apply as _, Element, }; +/// Show more informations about Amadeus and the connected Lektord instance (if connected.) pub fn view(app: &AppModel) -> Element<Message> { macro_rules! url { ($icon:ident => $url:literal) => {{ diff --git a/amadeus/src/app/context_pages/config.rs b/amadeus/src/app/context_pages/config.rs index 6452dbe40423c2422cae9bbec08ad160f13514db..d6ba36249807421de9768d66c207fa774d70f3f2 100644 --- a/amadeus/src/app/context_pages/config.rs +++ b/amadeus/src/app/context_pages/config.rs @@ -7,6 +7,7 @@ use cosmic::{iced::Length, theme, widget, Element}; use lektor_utils::config::{SocketScheme, UserConfig}; use std::sync::LazyLock; +/// View and edit the settings for Amadeus pub fn view(app: &AppModel) -> Element<Message> { use {std::borrow::Cow::*, ConfigMessage::*, Message::*}; diff --git a/amadeus/src/app/context_pages/kara_info.rs b/amadeus/src/app/context_pages/kara_info.rs new file mode 100644 index 0000000000000000000000000000000000000000..39096063c9cf4fb1d004bd58c9172e0f4541b57b --- /dev/null +++ b/amadeus/src/app/context_pages/kara_info.rs @@ -0,0 +1,24 @@ +use crate::{ + app::{LektordCommand, Message}, + fl, + store::KaraOrId, +}; +use cosmic::{ + prelude::*, + style, + widget::{self, tooltip::Position}, +}; + +/// View more details about a single kara. +pub fn view<'a>(kara_or_kid: KaraOrId<'_>) -> Element<'a, Message> { + match kara_or_kid { + KaraOrId::Id(kid) => widget::text::monotext(kid.to_string()) + .apply(widget::button::custom) + .style(style::Button::Transparent) + .on_press(Message::SendCommand(LektordCommand::DownloadKaraInfo(kid))) + .apply(|btn| widget::tooltip(btn, fl!("click-to-dl"), Position::Left)) + .apply(Element::from), + + KaraOrId::Kara(kara) => widget::text::monotext(format!("{kara:#?}")).into(), + } +} diff --git a/amadeus/src/app/kard.rs b/amadeus/src/app/kard.rs index a2d3e307b4485d322d290d2c9aca1e340b111a27..3bdb3c05acc74fb9508c9394882c51e876e75a52 100644 --- a/amadeus/src/app/kard.rs +++ b/amadeus/src/app/kard.rs @@ -1,12 +1,17 @@ +//! Contains everything to display kara in a card (here more precisely a row.) + use crate::{ - app::{LektordMessage, Message}, + app::{LektordCommand, Message}, + fl, store::KaraOrId, }; use cosmic::{ cosmic_theme::Spacing, font, iced::{Alignment, Length}, - style, theme, widget, Apply, Element, + prelude::*, + style, theme, + widget::{self, tooltip::Position}, }; use lektor_payloads::Kara; @@ -19,12 +24,12 @@ fn kara_title<'a>(kara: &Kara) -> Element<'a, Message> { .map(|num| format!("{}{num} - {}", kara.song_type, kara.song_source)) .unwrap_or_else(|| format!("{} - {}", kara.song_type, kara.song_source)) .apply(widget::text::text) - .font(font::FONT_LIGHT) + .font(font::light()) .into(), ]) .apply(widget::button::custom) .style(style::Button::Transparent) - .on_press(Message::OpenKaraInfo(kara.id.clone())) + .on_press(Message::OpenKaraInfo(kara.id)) .apply(Element::from) } @@ -78,15 +83,15 @@ pub fn view<'a>(kara_or_id: KaraOrId) -> Element<'a, Message> { .style(style::Text::Color( theme::active().cosmic().destructive_text_color().into(), )) + .font(font::mono()) .apply(widget::button::custom) .style(style::Button::Transparent) - .on_press(Message::Lektord(LektordMessage::DownloadKaraInfo( - kid.clone(), - ))) - .apply(Element::from)], + .on_press(Message::SendCommand(LektordCommand::DownloadKaraInfo(kid))) + .apply(|btn| widget::tooltip(btn, fl!("click-to-dl"), Position::Top)) + .into()], KaraOrId::Kara(kara) => vec![ kara_title(kara), - widget::horizontal_space(Length::Fill).apply(Element::from), + widget::horizontal_space(Length::Fill).into(), kara_tags(kara), // TODO: Add the controls here... ], diff --git a/amadeus/src/app/menu.rs b/amadeus/src/app/menu.rs index 4e78b6845db56bb46b1ac57838de9a96a1a1f5d4..50d8f142b57ca1c34c3c7cd13f3e1d8283504d1c 100644 --- a/amadeus/src/app/menu.rs +++ b/amadeus/src/app/menu.rs @@ -1,3 +1,5 @@ +//! Contains everything about the application menu, from the top-left-hand corner + use crate::{ app::{context_pages::ContextPage, LektordCommand, Message}, fl, diff --git a/amadeus/src/app/pages.rs b/amadeus/src/app/pages.rs index 0e1e03f7bb641e5e19deec211f29b44a898d76fc..0d2fc73a314eefbfcdeee0967d7274ef1f430dec 100644 --- a/amadeus/src/app/pages.rs +++ b/amadeus/src/app/pages.rs @@ -1,17 +1,19 @@ +//! Contains everything to help display the main pages of the application. + use crate::{app::Message, fl}; use cosmic::{ - font, + cosmic_theme::Spacing, iced::{ alignment::{Horizontal, Vertical}, Alignment, Length, }, - prelude::CollectionWidget as _, + prelude::*, style, theme, widget::{self, icon, nav_bar, Icon}, - Apply as _, Element, }; use derive_more::Display; -use std::sync::Arc; +use lektor_payloads::KId; +use std::marker; pub mod history; pub mod home; @@ -21,7 +23,7 @@ pub mod queue; pub mod search; /// The page to display in the application. -#[derive(Default, Debug, Eq, PartialEq, Clone, Display)] +#[derive(Default, Debug, Eq, PartialEq, Clone, Copy, Display)] #[non_exhaustive] pub enum Page { #[default] @@ -40,11 +42,12 @@ pub enum Page { #[display("{}", fl!("playlists"))] Playlists, - #[display("{}", fl!("playlist", name = _0.as_ref()))] - Playlist(Arc<str>), + #[display("{}", fl!("playlist"))] + Playlist(KId), } impl Page { + /// Get the icon associated with the page, to be displayed along with the [nav_bar::Model]. pub fn icon(&self) -> Icon { let icon = match self { Page::Home => crate::icons::USER, @@ -57,6 +60,7 @@ impl Page { } } +/// Get the navigation bar for the pages. pub fn nav_bar_model() -> nav_bar::Model { macro_rules! insert { ($b:expr, $page:ident) => { @@ -74,7 +78,8 @@ pub fn nav_bar_model() -> nav_bar::Model { .build() } -pub fn not_found(page: Option<&Page>) -> Element<Message> { +/// We got a page that was not implemented, or no page at all. +pub fn not_found<'a>(page: Option<Page>) -> impl Into<Element<'a, Message>> { widget::column() .push(widget::text::title1(fl!("error-found"))) .push_maybe( @@ -86,35 +91,245 @@ pub fn not_found(page: Option<&Page>) -> Element<Message> { .height(Length::Fill) .align_x(Horizontal::Center) .align_y(Vertical::Center) - .into() } -pub fn empty_page_label<'a>(title: impl AsRef<str>) -> widget::Row<'a, Message> { - vec![ - (title.as_ref().to_string()) - .apply(widget::text::title2) - .style(style::Text::Color( - theme::active().cosmic().warning_text_color().into(), - )) - .font(font::FONT_LIGHT) - .into(), - widget::horizontal_space(Length::Fill).into(), - ] - .apply(widget::row::with_children) - .width(Length::Fill) - .padding(theme::active().cosmic().space_m()) +/// Helper for the icons at the right of the title in pages. +#[must_use] +struct PageViewControl<'a> { + icon: &'static [u8], + message: Message, + on_non_empty_content: bool, + is_destructive_icon: bool, + marker: marker::PhantomData<Element<'a, Message>>, +} + +impl<'a> PageViewControl<'a> { + /// Create a new control with an icon and an associated message. + pub fn new(icon: &'static [u8], message: Message) -> Self { + Self { + icon, + message, + on_non_empty_content: false, + is_destructive_icon: false, + marker: marker::PhantomData, + } + } + + /// Make the button a destructive one. + pub fn destructive(self) -> Self { + Self { + is_destructive_icon: true, + ..self + } + } + + /// Make the button active only when there is some content in the page. + pub fn need_content(self) -> Self { + Self { + on_non_empty_content: true, + ..self + } + } + + /// Turn the helper into a proper [Element]. Needs to pass a flag to know if the page is empty + /// or not. + fn into_maybe_element(self, page_is_empty: bool) -> Element<'a, Message> { + let Spacing { space_xxs, .. } = theme::active().cosmic().spacing; + let Self { + icon, + message, + on_non_empty_content, + is_destructive_icon, + marker: _, + } = self; + + let button = widget::icon::from_svg_bytes(icon) + .symbolic(true) + .apply(widget::button::icon) + .on_press_maybe((!page_is_empty || !on_non_empty_content).then_some(message)) + .padding(space_xxs) + .width(32) + .height(32); + + match is_destructive_icon { + true => button.style(style::Button::Destructive).into(), + false => button.into(), + } + } +} + +/// Helper to render main pages of the application. +#[derive(Default)] +#[must_use] +struct PageView<'a> { + /// The title of the page. If none then we have an error page. + title: Option<Element<'a, Message>>, + + /// Tells wether the title is a custom one. If the title is custom, then it's up to the caller + /// to pad the controls correctly, we won't insert the space in the [Self::view] function. + title_is_custom: bool, + + /// If the page is empty (no content), we can display an alterntive title. + empty_title: Option<Element<'a, Message>>, + + /// If we force the fact that there is content on the page (because the state of an ignore + /// element changed… like with the search thing…). + trust_me_i_have_content: bool, + + /// The controls, at the right of the title or alternative title, from left icon to the right + /// one. The first element of the tuple is the data for the SVG icon, the second is wether the + /// button is enabled only when there is content in the page, the last one is the message to + /// send when the button is pressed. + controls: Vec<PageViewControl<'a>>, + + /// The content of the page. From the top one to the bottom one. + content: Vec<Element<'a, Message>>, + + /// The number of elements in [Self::content] that are ignore to decide if the page is empty. + ignore_content_count: usize, +} + +impl<'a> PageView<'a> { + /// Set the title of the page. If the title is not set, a default page that shows that an error + /// was encountred will be shown. + pub fn title(self, title: impl ToString) -> Self { + Self { + title: Some(widget::text::title2(title.to_string()).into()), + ..self + } + } + + /// Like [Self::title], but can use any [cosmic::Element]. + pub fn custom_title(self, title: impl Into<Element<'a, Message>>) -> Self { + Self { + title: Some(title.into()), + title_is_custom: true, + ..self + } + } + + /// If set, when the page is empty, this title will be shown instead of the normal one. + pub fn empty_title(self, title: impl ToString) -> Self { + Self { + empty_title: Some(widget::text::title2(title.to_string()).into()), + ..self + } + } + + /// Sets the title like [Self::title] with the first passed string and the empty title like + /// [Self::empty_title] with the second passed string. + pub fn titles(self, title: impl ToString, empty: impl ToString) -> Self { + self.title(title).empty_title(empty) + } + + /// Adds an element to the page. Elements are pushed at the bottom of the page. + pub fn push(mut self, element: impl Into<Element<'a, Message>>) -> Self { + self.content.push(element.into()); + self + } + + /// Adds an element to the page. Elements are pushed at the bottom of the page. The pushed + /// element will be ignore to decide if the page is empty or not. This is usefull for when we + /// push more controls or informations, but are not relevent about if we have data in the page. + pub fn push_and_ignore_for_empty(mut self, element: impl Into<Element<'a, Message>>) -> Self { + self.content.push(element.into()); + self.ignore_content_count += 1; + self + } + + /// Adds an element to the page. Elements are pushed at the bottom of the page. If the push + /// flag is false, the element is not pushed into the content list. + pub fn push_when<E>(self, push: bool, cb: impl FnOnce() -> E) -> Self + where + E: Into<Element<'a, Message>>, + { + match push { + true => self.push(cb()), + false => self, + } + } + + /// Push elements from a thing that can be turned into an iterator. + pub fn extend(self, iter: impl IntoIterator<Item = impl Into<Element<'a, Message>>>) -> Self { + iter.into_iter().fold(self, |this, e| this.push(e)) + } + + /// Push a button in the control part of the page's title bar. Buttons are pushed at the right + /// of the control pane. + pub fn controls(self, controls: impl IntoIterator<Item = PageViewControl<'a>>) -> Self { + controls.into_iter().fold(self, |mut this, control| { + this.controls.push(control); + this + }) + } + + /// Set the flag about having content to true, this forces the check to see if the page is + /// empty or not to false, thus activating some buttons if needed. + pub fn has_content(self, flag: bool) -> Self { + Self { + trust_me_i_have_content: flag, + ..self + } + } + + /// Consumte the setting struct and returns the page. + pub fn view(self) -> impl Into<Element<'a, Message>> { + let Some(title) = self.title else { + // Not found, got an error… + return widget::column() + .push( + widget::text::title1(fl!("error-found")).style(style::Text::Color( + theme::active().cosmic().warning_text_color().into(), + )), + ) + .align_items(Alignment::Center) + .apply(widget::container) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center); + }; + + // Handle the page. + let is_empty = + (self.content.len() <= self.ignore_content_count) && !self.trust_me_i_have_content; + let title = match is_empty { + true => self.empty_title.unwrap_or(title), + false => title, + }; + let Spacing { space_m, .. } = theme::active().cosmic().spacing; + + let title_row = page_title(title, self.title_is_custom, self.controls, is_empty); + widget::column::with_capacity(1 + self.content.len()) + .push(title_row) + .extend(self.content.into_iter()) + .spacing(space_m) + .apply(widget::container) + .padding(theme::active().cosmic().space_m()) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Top) + } } -pub fn page_label<'a>(title: impl AsRef<str>) -> widget::Row<'a, Message> { - vec![ - (title.as_ref().to_string()) - .apply(widget::text::title2) - .style(style::Text::Default) - // .font(font::FONT_LIGHT) - .into(), - widget::horizontal_space(Length::Fill).into(), - ] - .apply(widget::row::with_children) - .width(Length::Fill) - .padding(theme::active().cosmic().space_m()) +fn page_title<'a>( + title: impl Into<Element<'a, Message>>, + title_is_custom: bool, + controls: Vec<PageViewControl<'a>>, + page_is_empty: bool, +) -> impl Into<Element<'a, Message>> { + let Spacing { space_xxs, .. } = theme::active().cosmic().spacing; + widget::row::with_capacity(2 + controls.len()) + .push(title) + .push_maybe((!title_is_custom).then(|| widget::horizontal_space(Length::Fill))) + .extend( + controls + .into_iter() + .map(|control| control.into_maybe_element(page_is_empty)), + ) + .width(Length::Fill) + .height(48) + .align_items(Alignment::Center) + .spacing(space_xxs) } diff --git a/amadeus/src/app/pages/history.rs b/amadeus/src/app/pages/history.rs index 459628a7f9985d5cf7db6b8d350d0256caf61caf..7f768e0503eb6f527cf9733eadae1e509423390f 100644 --- a/amadeus/src/app/pages/history.rs +++ b/amadeus/src/app/pages/history.rs @@ -1,16 +1,34 @@ use crate::{ - app::{kard, pages, Message}, + app::{ + kard, + pages::{PageView, PageViewControl}, + LektordCommand, Message, + }, + fl, store::Store, }; -use cosmic::{widget, Apply as _, Element}; +use cosmic::{prelude::*, widget}; -pub fn view(store: &Store) -> Element<Message> { - match store.iter_history().count() == 0 { - true => pages::empty_page_label("The history is empty").apply(Element::<Message>::from), - false => (store.iter_history()) - .fold(widget::list_column(), |list, kid| { +/// Display the history page. +pub fn view(store: &Store) -> impl Into<Element<Message>> { + PageView::default() + .titles(fl!("history"), fl!("empty-history")) + .controls([ + PageViewControl::new( + crate::icons::RETRY, + Message::SendCommand(LektordCommand::HistoryGet), + ), + PageViewControl::new( + crate::icons::METEOR, + Message::SendCommand(LektordCommand::HistoryClear), + ) + .destructive() + .need_content(), + ]) + .push_when(!store.iter_history().is_empty(), || { + (store.iter_history()).fold(widget::list_column(), |list, kid| { list.add(kard::view(store.get(kid))) }) - .apply(Element::<Message>::from), - } + }) + .view() } diff --git a/amadeus/src/app/pages/home.rs b/amadeus/src/app/pages/home.rs index 9bf742d780d174570c2d9d53f4c237a7f18a2d3d..71d31c083db8fe707988f7542e4ba0046d07496e 100644 --- a/amadeus/src/app/pages/home.rs +++ b/amadeus/src/app/pages/home.rs @@ -1,9 +1,10 @@ use crate::{ - app::{pages, Message}, + app::{pages::PageView, Message}, fl, }; -use cosmic::{Apply as _, Element}; +use cosmic::prelude::*; -pub fn view<'a>() -> Element<'a, Message> { - pages::page_label(fl!("home")).apply(Element::<Message>::from) +/// Display the home page. +pub fn view<'a>() -> impl Into<Element<'a, Message>> { + PageView::default().title(fl!("home")).view() } diff --git a/amadeus/src/app/pages/playlist.rs b/amadeus/src/app/pages/playlist.rs index 4151fff2fe7b80e9e067e337c7654b30ac1afc86..a12026625e141e30ed1dabcf4519a17a9efe481f 100644 --- a/amadeus/src/app/pages/playlist.rs +++ b/amadeus/src/app/pages/playlist.rs @@ -1,23 +1,84 @@ use crate::{ - app::{kard, pages, Message}, + app::{ + kard, + pages::{PageView, PageViewControl}, + LektordCommand, Message, + }, fl, store::Store, }; -use cosmic::{widget, Apply as _, Element}; +use cosmic::{prelude::*, style, widget}; +use lektor_payloads::{KId, PlaylistInfo}; -pub fn view<'a>(store: &'a Store, playlist: &str) -> Element<'a, Message> { - match store.iter_playlist_content(playlist).count() == 0 { - true => pages::empty_page_label(format!("The playlist {playlist} is empty")) - .apply(Element::<Message>::from), - false => vec![ - pages::page_label(fl!("playlist", name = playlist)).apply(Element::<Message>::from), - (store.iter_playlist_content(playlist)) - .fold(widget::list_column(), |list, kid| { - list.add(kard::view(store.get(kid))) - }) - .apply(Element::<Message>::from), - ] - .apply(widget::row::with_children) - .apply(Element::<Message>::from), - } +/// Display the page about a specific playlist. +pub fn view(store: &Store, id: KId) -> impl Into<Element<Message>> { + let Some(infos) = store.playlist(id) else { + todo!() + }; + + let name = infos + .name() + .map(|name| fl!("playlist", name = name)) + .unwrap_or_else(|| "untitled".to_string()); + + let owners = infos + .infos() + .into_iter() + .flat_map(PlaylistInfo::owners) + .map(|owner| -> Element<'_, Message> { + widget::text::body(owner) + .apply(widget::button::custom) + .style(style::Button::Transparent) + .into() + }); + + let (created_at, updated_at) = infos + .infos() + .map(|infos| (infos.created_at(), infos.updated_at())) + .unwrap_or_default(); + + let infos = widget::settings::section() + .add(widget::settings::item( + "owners", + widget::row::with_children(owners.collect()), + )) + .add(widget::settings::item( + "created at", + widget::text::body(created_at.format("%Y-%m-%d %H:%M:%S").to_string()), + )) + .add(widget::settings::item( + "last modified at", + widget::text::body(updated_at.format("%Y-%m-%d %H:%M:%S").to_string()), + )); + + let display_playlist_content = || { + (store.iter_playlist_content(id)).fold(widget::list_column(), |list, kid| { + list.add(kard::view(store.get(kid))) + }) + }; + + PageView::default() + .titles(&name, format!("The playlist {name} is empty")) + .controls([ + PageViewControl::new( + crate::icons::SHUFFLE, + Message::SendCommand(LektordCommand::PlaylistShuffleContent(id)), + ) + .need_content(), + PageViewControl::new( + crate::icons::RETRY, + Message::SendCommand(LektordCommand::PlaylistGetContent(id)), + ), + PageViewControl::new( + crate::icons::METEOR, + Message::SendCommand(LektordCommand::PlaylistDelete(id)), + ) + .destructive(), + ]) + .push_and_ignore_for_empty(infos) + .push_when( + !store.iter_playlist_content(id).is_empty(), + display_playlist_content, + ) + .view() } diff --git a/amadeus/src/app/pages/playlists.rs b/amadeus/src/app/pages/playlists.rs index 839f0db919038fada07f07536079b7c314c3b9ed..51745b73d0e136c4cd084e24921b39ae7dafbf70 100644 --- a/amadeus/src/app/pages/playlists.rs +++ b/amadeus/src/app/pages/playlists.rs @@ -1,10 +1,33 @@ use crate::{ - app::{pages, Message}, + app::{ + pages::{PageView, PageViewControl}, + LektordCommand, Message, + }, fl, + playlist::Playlist, store::Store, }; -use cosmic::{Apply as _, Element}; +use cosmic::{prelude::*, widget}; -pub fn view(_store: &Store) -> Element<Message> { - pages::page_label(fl!("playlists")).apply(Element::<Message>::from) +fn view_playlist_card(playlist: &Playlist) -> impl Into<Element<Message>> { + widget::text(playlist.name().unwrap_or("untitled")) +} + +/// Display all playlists in a page. +pub fn view(store: &Store) -> impl Into<Element<Message>> { + PageView::default() + .titles(fl!("playlists"), fl!("empty-playlists")) + .controls([PageViewControl::new( + crate::icons::RETRY, + Message::SendCommand(LektordCommand::PlaylistsGet), + )]) + .push_when(store.iter_playlists().count() != 0, || { + store + .iter_playlists() + .map(view_playlist_card) + .map(Into::into) + .collect::<Vec<_>>() + .apply(widget::flex_row) + }) + .view() } diff --git a/amadeus/src/app/pages/queue.rs b/amadeus/src/app/pages/queue.rs index 86f851c6b8429c2f8581bad58b7399557ebc4927..53938deb186e236a14c0d3afb306bdc4a95cf73e 100644 --- a/amadeus/src/app/pages/queue.rs +++ b/amadeus/src/app/pages/queue.rs @@ -1,14 +1,36 @@ use crate::{ - app::{kard, pages, Message}, + app::{kard, pages::PageView, LektordCommand, Message}, fl, store::Store, }; -use cosmic::{widget, Apply as _, Element}; +use cosmic::{prelude::*, style, widget}; use lektor_payloads::{Priority, PRIORITY_VALUES}; -fn view_queue_level(store: &Store, level: Priority) -> Element<Message> { - // TODO: Add the controls - let header = widget::text::title1(format!("{} {level}", fl!("queue"))); +use super::{page_title, PageViewControl}; + +fn view_queue_level(store: &Store, level: Priority) -> impl IntoIterator<Item = Element<Message>> { + let title = page_title( + widget::text::title2(format!("{} {level}", fl!("queue"))).style(style::Text::Default), + false, + vec![ + PageViewControl::new( + crate::icons::SHUFFLE, + Message::SendCommand(LektordCommand::QueueLevelShuffle(level)), + ) + .need_content(), + PageViewControl::new( + crate::icons::RETRY, + Message::SendCommand(LektordCommand::QueueLevelGet(level)), + ), + PageViewControl::new( + crate::icons::METEOR, + Message::SendCommand(LektordCommand::QueueLevelClear(level)), + ) + .need_content() + .destructive(), + ], + store.iter_queue_level(level).is_empty(), + ); let content = store .iter_queue_level(level) @@ -16,20 +38,42 @@ fn view_queue_level(store: &Store, level: Priority) -> Element<Message> { list.add(kard::view(store.get(kid))) }); - vec![header.into(), content.into()] - .apply(widget::column::with_children) - .into() + [title.into(), content.into()] } -pub fn view(store: &Store) -> Element<Message> { - match store.iter_queue().count() == 0 { - true => pages::empty_page_label("The queue is empty").apply(Element::<Message>::from), - false => (PRIORITY_VALUES.iter().rev().copied()) - .flat_map(|level| { - (store.iter_queue_level(level).count() != 0).then(|| view_queue_level(store, level)) - }) - .collect::<Vec<Element<Message>>>() - .apply(widget::column::with_children) - .into(), - } +/// Displays the page with the queue. +pub fn view(store: &Store) -> impl Into<Element<Message>> { + PageView::default() + .titles(fl!("queue"), fl!("empty-queue")) + .controls([ + PageViewControl::new( + crate::icons::SHUFFLE, + Message::SendCommand(LektordCommand::QueueShuffle), + ) + .need_content(), + PageViewControl::new( + crate::icons::RETRY, + Message::SendCommand(LektordCommand::QueueGet), + ), + PageViewControl::new( + crate::icons::CROP, + Message::SendCommand(LektordCommand::QueueCrop), + ) + .need_content() + .destructive(), + PageViewControl::new( + crate::icons::METEOR, + Message::SendCommand(LektordCommand::QueueClear), + ) + .need_content() + .destructive(), + ]) + .extend( + (PRIORITY_VALUES.iter().rev()) + .flat_map(|&lvl| { + (!store.iter_queue_level(lvl).is_empty()).then(|| view_queue_level(store, lvl)) + }) + .flatten(), + ) + .view() } diff --git a/amadeus/src/app/pages/search.rs b/amadeus/src/app/pages/search.rs index df9f906736a1e05ca5cae5446469ceb829e1ceb9..b427ed70c11b2b806b459eb81a05b4d3cccab1c0 100644 --- a/amadeus/src/app/pages/search.rs +++ b/amadeus/src/app/pages/search.rs @@ -1,7 +1,6 @@ -use crate::{ - app::{kard, pages, AppModel, Message}, - fl, -}; +//! Contains everything to implement search from Amadeus. + +use crate::app::{kard, pages::PageView, AppModel, Message}; use cosmic::{iced::Alignment, prelude::*, style, theme, widget}; use lektor_payloads::KaraBy; use std::{ @@ -10,6 +9,8 @@ use std::{ sync::atomic::{AtomicUsize, Ordering}, }; +use super::PageViewControl; + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FilterAtom(FilterAtomId, KaraBy); @@ -19,10 +20,6 @@ pub struct FilterAtomId(usize); #[derive(Debug, Default)] pub struct Filter(String, Vec<FilterAtom>); -fn vertical_space<'a>() -> Element<'a, Message> { - widget::vertical_space(theme::active().cosmic().space_m()).into() -} - macro_rules! icon { ($icon:ident) => { widget::icon::from_svg_bytes(crate::icons::$icon) @@ -48,19 +45,19 @@ impl FilterAtomId { impl FilterAtom { fn view(&self) -> Element<Message> { - let (maybe_icon, text) = match &self.1 { - KaraBy::Id(id) => (Some(icon!(HASHTAG)), id.to_string()), - KaraBy::Query(query) => (None, query.clone()), - KaraBy::Tag((name, None)) => (Some(icon!(TAG)), name.clone()), - KaraBy::Tag((name, Some(value))) => (Some(icon!(TAGS)), format!("{name}:{value}")), - KaraBy::SongType(song_type) => (None, song_type.to_string()), - KaraBy::SongOrigin(song_origin) => (None, song_origin.to_string()), - KaraBy::Author(author) => (Some(icon!(USER)), author.clone()), - KaraBy::Playlist(playlist) => (Some(icon!(FOLDER)), playlist.clone()), + let (icon, text) = match &self.1 { + KaraBy::Id(id) => (icon!(HASHTAG), id.to_string()), + KaraBy::Query(query) => (icon!(FILTER), query.clone()), + KaraBy::Tag((name, None)) => (icon!(TAG), name.clone()), + KaraBy::Tag((name, Some(value))) => (icon!(TAGS), format!("{name}:{value}")), + KaraBy::SongType(song_type) => (icon!(HASHTAG), song_type.to_string()), + KaraBy::SongOrigin(song_origin) => (icon!(HASHTAG), song_origin.to_string()), + KaraBy::Author(author) => (icon!(USER), author.clone()), + KaraBy::Playlist(playlist) => (icon!(FOLDER), playlist.clone()), }; widget::row::with_capacity(2) - .push_maybe(maybe_icon) + .push(icon) .push(widget::text(text)) .align_items(Alignment::Center) .spacing(theme::active().cosmic().space_xxxs()) @@ -72,54 +69,24 @@ impl FilterAtom { } impl Filter { - fn can_be_clear(&self) -> bool { - !self.1.is_empty() || !self.0.is_empty() - } - - fn can_be_submited(&self) -> bool { - !self.1.is_empty() - } - - fn view(&self) -> Element<Message> { - let space_xxs = theme::active().cosmic().space_xxs(); - - let staging_text_input = widget::text_input( + fn view_staging_row(&self) -> impl Into<Element<Message>> { + widget::text_input( "@author | tag:value | tag: | #playlist | OP | anime | ...", &self.0, ) .on_submit(Message::AddFilterAtomFromStageBuffer) .on_input(Message::ChangeFilterStageBuffer) - .on_clear(Message::ChangeFilterStageBuffer(String::default())); + .on_clear(Message::ChangeFilterStageBuffer(String::default())) + } - let button_commit_filters = widget::button::custom(icon!(FILTER)) - .on_press_maybe(self.can_be_submited().then_some(Message::QueryWithFilters)) - .padding(space_xxs) - .height(32) - .width(32); + fn view_filters_row(&self) -> impl Into<Element<Message>> { + (self.1.iter().map(FilterAtom::view).collect::<Vec<_>>()) + .apply(widget::flex_row) + .spacing(theme::active().cosmic().space_xxs()) + } - let button_clear_filters = widget::button::custom(icon!(METEOR)) - .on_press_maybe(self.can_be_clear().then_some(Message::ClearFilters)) - .style(style::Button::Destructive) - .padding(space_xxs) - .height(32) - .width(32); - - let staging_row = widget::row::with_capacity(3) - .push(staging_text_input) - .push(button_commit_filters) - .push(button_clear_filters) - .align_items(Alignment::Center) - .spacing(space_xxs) - .into(); - - Element::from(widget::column::with_children(vec![ - staging_row, - vertical_space(), - (self.1.iter().map(FilterAtom::view).collect::<Vec<_>>()) - .apply(widget::flex_row) - .spacing(space_xxs) - .apply(Element::<Message>::from), - ])) + pub fn is_empty(&self) -> bool { + self.1.is_empty() } pub fn clear(&mut self) { @@ -132,11 +99,17 @@ impl Filter { } pub fn commit(&mut self) { - let atom: FilterAtom = self.0.parse().expect("infallible"); - if !(self.1.iter()).any(|commited| commited.0 == atom.0 || commited.1 == atom.1) { - self.1.push(atom); + if !self.0.is_empty() { + let atom: FilterAtom = self.0.parse().expect("infallible"); + if !(self.1.iter()).any(|commited| commited.0 == atom.0 || commited.1 == atom.1) { + self.1.push(atom); + } + self.0.clear(); } - self.0.clear(); + } + + pub fn iter_cloned(&self) -> impl Iterator<Item = KaraBy> + '_ { + self.1.iter().map(|FilterAtom(_, filter)| filter.clone()) } pub fn remove(&mut self, id: FilterAtomId) { @@ -148,15 +121,22 @@ impl Filter { } } -pub fn view(app: &AppModel) -> Element<Message> { - widget::column::with_capacity(4) - .push(pages::page_label(fl!("search"))) - .push(app.search_filter.view()) - .push(vertical_space()) - .push_maybe((!app.search_results.is_empty()).then(|| { - (app.search_results.iter()).fold(widget::list_column(), |list, kid| { +/// Display the search page. +pub fn view(app: &AppModel) -> impl Into<Element<Message>> { + PageView::default() + .custom_title(app.search_filter.view_staging_row()) + .controls([ + PageViewControl::new(crate::icons::FILTER, Message::QueryWithFilters).need_content(), + PageViewControl::new(crate::icons::METEOR, Message::ClearFilters) + .need_content() + .destructive(), + ]) + .has_content(!app.search_filter.is_empty() || !app.search_results.is_empty()) + .push_and_ignore_for_empty(app.search_filter.view_filters_row()) + .push_when(!app.search_results.is_empty(), || { + (app.search_results.iter()).fold(widget::list_column(), |list, &kid| { list.add(kard::view(app.store.get(kid))) }) - })) - .into() + }) + .view() } diff --git a/amadeus/src/app/progress_bar.rs b/amadeus/src/app/progress_bar.rs index 67ad1d345bb07d142656dc51db3238d1bb16b40c..2bfde63cd6d96455539ee7e10c2d0ac686820472 100644 --- a/amadeus/src/app/progress_bar.rs +++ b/amadeus/src/app/progress_bar.rs @@ -1,10 +1,15 @@ +//! Contains things to display the progress bar of the currently playing kara. + use crate::app::{AppModel, Message}; -use cosmic::{iced::Alignment, style, theme, widget, Apply as _, Element}; +use cosmic::{font, iced::Alignment, prelude::*, style, theme, widget}; +/// Utility to format the time in a `MM:SS` way. Note that we can show more that 59 minutes, no +/// kara should be an hour long anyway. fn format_time(secs: f32) -> String { format!("{}:{}", secs / 60.0, secs % 60.0) } +/// View the progress bar for the currently playing kara. pub fn view(app: &AppModel) -> Element<Message> { let Some((time, duration)) = app.lektord_state.current_times() else { return widget::row().into(); @@ -13,7 +18,7 @@ pub fn view(app: &AppModel) -> Element<Message> { widget::row::with_children(vec![ format_time(time) .apply(widget::text::monotext) - .font(cosmic::font::FONT_LIGHT) + .font(font::light()) .style(style::Text::Accent) .into(), widget::progress_bar(0.0..=duration, time) @@ -23,7 +28,7 @@ pub fn view(app: &AppModel) -> Element<Message> { .into(), format_time(duration) .apply(widget::text::monotext) - .font(cosmic::font::FONT_LIGHT) + .font(font::light()) .style(style::Text::Accent) .into(), ]) diff --git a/amadeus/src/subscriptions.rs b/amadeus/src/app/subscriptions.rs similarity index 100% rename from amadeus/src/subscriptions.rs rename to amadeus/src/app/subscriptions.rs diff --git a/amadeus/src/subscriptions/playback.rs b/amadeus/src/app/subscriptions/playback.rs similarity index 87% rename from amadeus/src/subscriptions/playback.rs rename to amadeus/src/app/subscriptions/playback.rs index df1ad3b42bd62a44e5f3bcd918bf36f9df5f0efc..c5329911d580cea193f18199534d4bff18536c1a 100644 --- a/amadeus/src/subscriptions/playback.rs +++ b/amadeus/src/app/subscriptions/playback.rs @@ -23,12 +23,12 @@ impl Suscription { loop { match requests::get_status(config.read().await.as_ref()).await { Ok(state) => { - _ = channel.send(Lektord(PlaybackUpdate(state))).await; + _ = channel.send(LektordUpdate(PlaybackUpdate(state))).await; tokio::time::sleep(Duration::from_secs(1)).await; } Err(err) => { log::debug!("failed to connect to lektord: {err}"); - _ = channel.send(Lektord(Disconnected)).await; + _ = channel.send(LektordUpdate(Disconnected)).await; tokio::time::sleep(config.read().await.retry).await; } } diff --git a/amadeus/src/subscriptions/updates.rs b/amadeus/src/app/subscriptions/updates.rs similarity index 90% rename from amadeus/src/subscriptions/updates.rs rename to amadeus/src/app/subscriptions/updates.rs index 5a4b77ed76a8931c0ec27af0fef1dddfc7cb573a..1ddc792d6a63dd6b6e8b801be59f9090a22e7d51 100644 --- a/amadeus/src/subscriptions/updates.rs +++ b/amadeus/src/app/subscriptions/updates.rs @@ -23,11 +23,11 @@ impl Suscription { 'connect: loop { log::debug!("try initial connection"); let Ok(infos) = requests::get_infos(config.read().await.as_ref()).await else { - _ = channel.send(Lektord(Disconnected)).await; + _ = channel.send(LektordUpdate(Disconnected)).await; tokio::time::sleep(config.read().await.retry).await; continue 'connect; }; - _ = channel.send(Lektord(Connected(infos))).await; + _ = channel.send(LektordUpdate(Connected(infos))).await; loop { log::debug!("here we want to query updates for the queue, history, etc…"); diff --git a/amadeus/src/config.rs b/amadeus/src/config.rs index ba840ea87b70ada166361305d6c2d3f26a991437..24e21a14dcf0aeb7b4bbcf6dade2a797c672113b 100644 --- a/amadeus/src/config.rs +++ b/amadeus/src/config.rs @@ -8,6 +8,7 @@ use std::{ time::Duration, }; +/// The configuration struct of our application. #[derive(Debug, Clone, CosmicConfigEntry, Eq, PartialEq)] #[version = 1] pub struct Config { @@ -22,6 +23,7 @@ pub struct Config { kurisu_url: String, } +/// The default URL for the Kurisu website. pub const KURISU_URL: &str = "https://kurisu.iiens.net"; impl Config { @@ -72,6 +74,7 @@ impl Default for Config { } } +/// Wrapper to get a default address which is the localhost one. #[derive(Debug, Clone, Copy, Eq, PartialEq, Display, From, Into, Serialize, Deserialize)] #[display("{socket_addr}")] #[repr(transparent)] @@ -88,6 +91,7 @@ impl Default for Host { } } +/// Wrapper to get a default log level, and merge the warn and error ones. #[derive(Debug, Clone, Copy, Eq, PartialEq, Display, Into)] #[display("{_0}")] #[repr(transparent)] diff --git a/amadeus/src/i18n.rs b/amadeus/src/i18n.rs index 4769970a8688475ca4cc2d7a4df8930e696f5854..1b893e0c4f399e51d70c38f790cac8b5ca1576e6 100644 --- a/amadeus/src/i18n.rs +++ b/amadeus/src/i18n.rs @@ -14,16 +14,18 @@ pub fn init(requested_languages: &[LanguageIdentifier]) { } } -// Get the `Localizer` to be used for localizing this library. +/// Get the `Localizer` to be used for localizing this library. #[must_use] pub fn localizer() -> Box<dyn Localizer> { Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) } +/// Localizations for amadeus. #[derive(rust_embed::RustEmbed)] #[folder = "i18n/"] struct Localizations; +/// The language loader for the application. pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| { let loader: FluentLanguageLoader = fluent_language_loader!(); loader diff --git a/amadeus/src/icons.rs b/amadeus/src/icons.rs index 26396513d82985105671467c664642371f5463fd..28320f56464f4f0bc2830b343c9d024d19322325 100644 --- a/amadeus/src/icons.rs +++ b/amadeus/src/icons.rs @@ -4,6 +4,7 @@ macro_rules! icon { ($icon:ident : $file:literal) => { #[allow(unused)] + #[doc = concat!("The ", stringify!($icon), " icon, from 'rsc/icons/", $file, ".svg'")] pub const $icon: &[u8] = include_bytes!(concat!("../rsc/icons/", $file, ".svg")); }; } @@ -44,3 +45,5 @@ icon!(STOP: "fontawesome/stop"); icon!(SHUFFLE: "fontawesome/shuffle"); icon!(PLAY: "fontawesome/play"); icon!(PAUSE: "fontawesome/pause"); +icon!(RETRY: "fontawesome/retry"); +icon!(CROP: "fontawesome/crop"); diff --git a/amadeus/src/lib.rs b/amadeus/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..eef7803a42993c99939c8b44993bd5ff490e75f7 --- /dev/null +++ b/amadeus/src/lib.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod config; +pub mod i18n; +mod icons; +mod playlist; +mod store; + +include!(concat!(env!("OUT_DIR"), "/amadeus_build_infos.rs")); diff --git a/amadeus/src/main.rs b/amadeus/src/main.rs index 0978fba8cb5820c90f7bf5f728399af44bd924d4..973b25d780c40e74245d7a27c14b54bdcb7eeb36 100644 --- a/amadeus/src/main.rs +++ b/amadeus/src/main.rs @@ -1,17 +1,10 @@ -mod app; -mod config; -mod i18n; -mod icons; -mod store; -mod subscriptions; - -include!(concat!(env!("OUT_DIR"), "/amadeus_build_infos.rs")); - +use amadeus::{app, config, i18n}; use anyhow::Context as _; use cosmic::cosmic_config::{self, CosmicConfigEntry as _}; use lektor_utils::{appimage, logger}; fn main() -> anyhow::Result<()> { + // Read the config. Also keep the cosmic thing that will allow us to write said config. let config = cosmic_config::Config::new(app::APP_ID, config::Config::VERSION).map(|ctx| { match config::Config::get_entry(&ctx) .inspect_err(|(errs, _)| errs.iter().for_each(|e| log::error!("config error: {e}"))) @@ -20,6 +13,7 @@ fn main() -> anyhow::Result<()> { } })?; + // Setup the logger. We ignore specific things around libcosmic and reqwest. logger::Builder::default() .level(config.0.log_level()) .filter_targets([ @@ -28,9 +22,11 @@ fn main() -> anyhow::Result<()> { .init() .context("failed to init logger")?; + // On linux, detect if we are inside an AppImage. #[cfg(target_os = "linux")] appimage::detect_appimage()?; + // Launch the application. i18n::init(&i18n_embed::DesktopLanguageRequester::requested_languages()); cosmic::app::run::<app::AppModel>(cosmic::app::Settings::default(), config)?; Ok(()) diff --git a/amadeus/src/playlist.rs b/amadeus/src/playlist.rs new file mode 100644 index 0000000000000000000000000000000000000000..dcff1f075788e6e770ad8259945ac2b28842564a --- /dev/null +++ b/amadeus/src/playlist.rs @@ -0,0 +1,27 @@ +use lektor_payloads::{KId, PlaylistInfo}; +use std::mem; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Playlist { + infos: Option<PlaylistInfo>, + content: Vec<KId>, +} + +impl Playlist { + pub fn name(&self) -> Option<&str> { + let infos = self.infos.as_ref()?; + (!infos.name().is_empty()).then_some(infos.name()) + } + + pub fn content(&self) -> impl Iterator<Item = KId> + '_ { + self.content.iter().copied() + } + + pub fn set_content(&mut self, new: Vec<KId>) { + _ = mem::replace(&mut self.content, new); + } + + pub fn infos(&self) -> Option<&PlaylistInfo> { + self.infos.as_ref() + } +} diff --git a/amadeus/src/store.rs b/amadeus/src/store.rs index c6df4c90dac5ec4384df6aa4426545349a323de8..6c1c7fba8dbc1360bd927ef70d43065b71a12a13 100644 --- a/amadeus/src/store.rs +++ b/amadeus/src/store.rs @@ -4,12 +4,12 @@ mod history; mod playlist_content; mod playlists; -mod queue; mod queue_level; +use crate::playlist::Playlist; use hashbrown::HashMap; -use lektor_payloads::{KId, Kara, Playlist, PlaylistName, Priority, PRIORITY_LENGTH}; -use std::{fmt, mem}; +use lektor_payloads::{KId, Kara, Priority, PRIORITY_LENGTH}; +use std::mem; /// Stores the kara or its id if the [Kara] struct was not already cached in the [Store]. #[derive(Debug, Clone)] @@ -30,7 +30,7 @@ pub struct Store { karas: HashMap<KId, Kara>, /// All the informations about the playlists. - playlists: HashMap<PlaylistName, Playlist>, + playlists: HashMap<KId, Playlist>, /// The history, insert at the begin, remove at the end. history: Vec<KId>, @@ -52,22 +52,30 @@ impl Store { /// Set the content of a playlist. Insert it if it didn't exists. Update it if the entry /// existed in the store. - pub fn set_playlist(&mut self, name: PlaylistName, plt: Playlist) { - _ = self.playlists.insert(name, plt); + pub fn set_playlist_content(&mut self, id: KId, plt: Vec<KId>) { + self.playlists.entry(id).or_default().set_content(plt); } - /// Keep playlists only if their name is specified in the passed [Vec]. - pub fn keep_playlists(&mut self, names: Vec<PlaylistName>) { - self.playlists.retain(|key, _| names.contains(key)); + /// Keep playlists only if their name is specified in the passed [Vec]. Returns the new + /// playlists in a vector. + pub fn keep_playlists(&mut self, ids: Vec<KId>) -> Vec<KId> { + self.playlists.retain(|key, _| ids.contains(key)); + Vec::from_iter(ids.into_iter().flat_map(|id| { + (!self.playlists.contains_key(&id)).then(|| { + self.playlists.insert(id, Default::default()); + id + }) + })) } -} -impl Store { - /// Get the iterator to query all the levels of the queue. - pub fn iter_queue(&self) -> queue::QueueIter { - queue::QueueIter::new(self) + /// Set the metadata informations about a kar in the [Store]. Any previous information is + /// overwritten. + pub fn set(&mut self, kara: Kara) { + let _ = self.karas.insert(kara.id, kara); } +} +impl Store { /// Get the iterator to query a specific level of the queue. pub fn iter_queue_level(&self, level: Priority) -> queue_level::QueueLevelIter { queue_level::QueueLevelIter::new(self, level) @@ -84,27 +92,22 @@ impl Store { } /// Get the iterator to list the playlist's content. - pub fn iter_playlist_content<S>(&self, name: S) -> playlist_content::PlaylistContentIter - where - S: TryInto<PlaylistName>, - <S as TryInto<PlaylistName>>::Error: fmt::Display, - { - match name.try_into() { - Ok(name) => playlist_content::PlaylistContentIter::new(self, name), - Err(err) => { - log::error!("{err}"); - playlist_content::PlaylistContentIter::empty(self) - } - } + pub fn iter_playlist_content(&self, id: KId) -> playlist_content::PlaylistContentIter { + playlist_content::PlaylistContentIter::new(self, id) } /// Get a kara from the store. If the [Kara] was not already cached in the store, returns its /// [KId]... Note that if the passed [KId] is not valid, then we return a [KId] that doesn't /// really exists as we can't know if it's valid or not… - pub fn get(&self, kid: &KId) -> KaraOrId { + pub fn get(&self, kid: KId) -> KaraOrId { self.karas - .get(kid) + .get(&kid) .map(KaraOrId::Kara) - .unwrap_or_else(|| KaraOrId::Id(kid.clone())) + .unwrap_or_else(|| KaraOrId::Id(kid)) + } + + /// Get the informations about a playlist. + pub fn playlist(&self, id: KId) -> Option<&Playlist> { + self.playlists.get(&id) } } diff --git a/amadeus/src/store/history.rs b/amadeus/src/store/history.rs index b7e18576bdb3dc70e0ad296dc75f31671f2479dd..eae05c48d85f23b13b9ffaa18de11a1c329d19ed 100644 --- a/amadeus/src/store/history.rs +++ b/amadeus/src/store/history.rs @@ -1,3 +1,5 @@ +//! Implements the iterator for the history. + use super::Store; use lektor_payloads::KId; @@ -10,19 +12,25 @@ impl<'a> HistoryIter<'a> { pub(super) fn new(store: &'a Store) -> Self { Self(store, 0) } + + /// Tells if the iterator is empty without consuming it. + pub fn is_empty(&self) -> bool { + self.count() == 0 + } } impl<'a> Iterator for HistoryIter<'a> { - type Item = &'a KId; + type Item = KId; fn next(&mut self) -> Option<Self::Item> { (self.0.history) .get(self.1) .inspect(|_| self.1 += 1) + .copied() } fn last(self) -> Option<Self::Item> { - self.0.history.last() + self.0.history.last().copied() } fn size_hint(&self) -> (usize, Option<usize>) { diff --git a/amadeus/src/store/playlist_content.rs b/amadeus/src/store/playlist_content.rs index 8f389bf1366ee0d9b6d60119fa36a013db02d039..1b6c7a20e03aa4e5f47e6dd572e01c95a556268d 100644 --- a/amadeus/src/store/playlist_content.rs +++ b/amadeus/src/store/playlist_content.rs @@ -1,42 +1,42 @@ +//! Implements the iterator for the content of a playlist. + use super::Store; -use lektor_payloads::{KId, PlaylistName}; +use lektor_payloads::KId; /// Wrapper struct to get the content of a playlist. #[derive(Debug, Clone)] -pub struct PlaylistContentIter<'a>(&'a Store, Option<PlaylistName>, usize); +pub struct PlaylistContentIter<'a>(&'a Store, KId, usize); impl<'a> PlaylistContentIter<'a> { - pub(super) fn empty(store: &'a Store) -> Self { - Self(store, None, 0) + pub(super) fn new(store: &'a Store, id: KId) -> Self { + Self(store, id, 0) } - pub(super) fn new(store: &'a Store, name: PlaylistName) -> Self { - Self(store, Some(name), 0) + /// Tells if the iterator is empty without consuming it. + pub fn is_empty(&self) -> bool { + self.size_hint().1.unwrap() == 0 } } impl<'a> Iterator for PlaylistContentIter<'a> { - type Item = &'a KId; + type Item = KId; fn next(&mut self) -> Option<Self::Item> { (self.0.playlists) - .get(self.1.as_ref()?)? + .get(&self.1)? .content() - .get(self.2) + .nth(self.2) .inspect(|_| self.2 += 1) } fn last(self) -> Option<Self::Item> { - (self.0.playlists).get(self.1.as_ref()?)?.content().last() + (self.0.playlists).get(&self.1)?.content().last() } fn size_hint(&self) -> (usize, Option<usize>) { - let Some(name) = self.1.as_ref() else { - return (0, Some(0)); - }; let size = (self.0.playlists) - .get(name) - .map(|plt| plt.content().len() - self.2) + .get(&self.1) + .map(|plt| plt.content().count() - self.2) .unwrap_or_default(); (size, Some(size)) } diff --git a/amadeus/src/store/playlists.rs b/amadeus/src/store/playlists.rs index 8734b713fd80781d5fb96e1f41026b669748d566..2d51c8c28475afc1b286b91fb7333c5578147c98 100644 --- a/amadeus/src/store/playlists.rs +++ b/amadeus/src/store/playlists.rs @@ -1,10 +1,12 @@ -use super::Store; +//! Implements the iterator for all the playlists and their meta-informations. + +use crate::{playlist::Playlist, store::Store}; use hashbrown::hash_map; -use lektor_payloads::{Playlist, PlaylistName}; +use lektor_payloads::KId; /// Wrapper struct to get the playlists. #[derive(Debug, Clone)] -pub struct PlaylistsIter<'a>(&'a Store, hash_map::Keys<'a, PlaylistName, Playlist>); +pub struct PlaylistsIter<'a>(&'a Store, hash_map::Keys<'a, KId, Playlist>); impl<'a> PlaylistsIter<'a> { pub(super) fn new(store: &'a Store) -> Self { @@ -13,16 +15,14 @@ impl<'a> PlaylistsIter<'a> { } impl<'a> Iterator for PlaylistsIter<'a> { - type Item = (PlaylistName, &'a Playlist); + type Item = &'a Playlist; fn next(&mut self) -> Option<Self::Item> { - let key = self.1.next()?; - self.0.playlists.get(key).map(|plt| (key.clone(), plt)) + self.0.playlists.get(self.1.next()?) } fn last(self) -> Option<Self::Item> { - let key = self.1.last()?; - self.0.playlists.get(key).map(|plt| (key.clone(), plt)) + self.0.playlists.get(self.1.last()?) } fn size_hint(&self) -> (usize, Option<usize>) { diff --git a/amadeus/src/store/queue.rs b/amadeus/src/store/queue.rs deleted file mode 100644 index 9805720eac5b66d49d7a46d4810250efdc1d9ef2..0000000000000000000000000000000000000000 --- a/amadeus/src/store/queue.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::Store; -use lektor_payloads::{KId, Priority}; - -/// Wrapper struct to get the content of the queue, any level. Implements [Iterator], use the -/// [Iterator::next] functions to query the state. Returns kara from the one closest to being -/// played to the farest. -#[derive(Debug, Clone, Copy)] -pub struct QueueIter<'a>(&'a Store, usize); - -impl<'a> QueueIter<'a> { - pub(super) fn new(store: &'a Store) -> Self { - Self(store, 0) - } -} - -impl<'a> Iterator for QueueIter<'a> { - type Item = &'a KId; - - fn next(&mut self) -> Option<Self::Item> { - (self.0.queue.iter().rev().flatten()) - .nth(self.1) - .inspect(|_| self.1 += 1) - } - - fn last(self) -> Option<Self::Item> { - self.0.queue[Priority::min().index()].last() - } - - fn size_hint(&self) -> (usize, Option<usize>) { - let size = self.0.queue.iter().map(|lvl| lvl.len()).sum::<usize>() - self.1; - (size, Some(size)) - } - - fn count(self) -> usize { - self.size_hint().1.unwrap() - } -} diff --git a/amadeus/src/store/queue_level.rs b/amadeus/src/store/queue_level.rs index 2cabf0cb57762f0e80ee9663f11ef2ac4275f1ea..c90f995284b3ca53c632fbf4980196510a76c8a0 100644 --- a/amadeus/src/store/queue_level.rs +++ b/amadeus/src/store/queue_level.rs @@ -1,3 +1,5 @@ +//! Implements the iterator to iterate only over a single level of the queue at once. + use super::Store; use lektor_payloads::{KId, Priority}; @@ -11,19 +13,24 @@ impl<'a> QueueLevelIter<'a> { pub(super) fn new(store: &'a Store, level: Priority) -> Self { Self(store, level, 0) } + + pub fn is_empty(&self) -> bool { + self.count() == 0 + } } impl<'a> Iterator for QueueLevelIter<'a> { - type Item = &'a KId; + type Item = KId; fn next(&mut self) -> Option<Self::Item> { (self.0.queue[self.1.index()]) .get(self.2) .inspect(|_| self.2 += 1) + .copied() } fn last(self) -> Option<Self::Item> { - (self.0.queue[self.1.index()]).last() + (self.0.queue[self.1.index()]).last().copied() } fn size_hint(&self) -> (usize, Option<usize>) { diff --git a/kurisu_api/Cargo.toml b/kurisu_api/Cargo.toml index b2d25cc338deef8e870c6bd83e7c6680d2fbc2e6..5c6996e5213885eeb14acdf1041e64f7af8b63de 100644 --- a/kurisu_api/Cargo.toml +++ b/kurisu_api/Cargo.toml @@ -1,19 +1,22 @@ [package] name = "kurisu_api" +description = "Crate used to deserialize what Kurisu returns" +rust-version.workspace = true + version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -rust-version.workspace = true -description = "Crate used to deserialize what Kurisu returns" [lib] doctest = false [dependencies] +log.workspace = true serde.workspace = true sha256.workspace = true hashbrown.workspace = true +derive_more.workspace = true lektor_utils = { path = "../lektor_utils" } [dev-dependencies] diff --git a/kurisu_api/src/error.rs b/kurisu_api/src/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..dad7ece09c64eb66f71e532d148e73f1adbbfb51 --- /dev/null +++ b/kurisu_api/src/error.rs @@ -0,0 +1,20 @@ +use derive_more::Display; +use std::borrow::Cow; + +#[derive(Debug, Clone, Display)] +#[display("{_0}")] +pub struct Error(pub(crate) Cow<'static, str>); + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } + + fn description(&self) -> &str { + self.0.as_ref() + } + + fn cause(&self) -> Option<&dyn std::error::Error> { + self.source() + } +} diff --git a/kurisu_api/src/lib.rs b/kurisu_api/src/lib.rs index 44ce6a88c9099670fcc8d3451e3ce6e0abbc9960..6f2839a76d52f4946936419dafc20572fb841f52 100644 --- a/kurisu_api/src/lib.rs +++ b/kurisu_api/src/lib.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] +pub mod error; mod hash; pub mod v2; pub use hash::*; diff --git a/kurisu_api/src/v2.rs b/kurisu_api/src/v2.rs index ca9f93e77fc35d6d6bbde5bb95baa94a1e3c14c3..35c9cae826034fac453bf272975f104feb17bcba 100644 --- a/kurisu_api/src/v2.rs +++ b/kurisu_api/src/v2.rs @@ -1,10 +1,10 @@ //! Object rules for the Kurisu's V2 API -use std::str::FromStr; - -use crate::SHA256; -use hashbrown::HashSet; +use crate::{error::Error, SHA256}; +use derive_more::Display; +use hashbrown::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; +use std::{borrow, cmp, collections::BTreeSet, str::FromStr}; /// We can have multiple kurisu repos. #[derive(Debug, PartialEq, Eq)] @@ -14,24 +14,150 @@ pub struct Repo { pub infos: Infos, } +/// Some infos about criteras. This is just an API specific thing, won't be accessible in other +/// ways than getters. +#[derive(Debug, Deserialize, PartialEq, Eq, Default)] +struct Criterias { + /// Authors present in the database. + #[serde(default)] + author: HashSet<String>, + + /// People that have favorites, will be imported as playlists. + #[serde(default)] + favorite_haver: HashSet<String>, + + /// List of languages possible. The first item is the code, the second the name. + #[serde(default)] + language: Vec<(String, String)>, + + /// Available song origins. + #[serde(default)] + song_origin: HashSet<SongOrigin>, + + /// Available song types. + #[serde(default)] + song_type: HashSet<SongType>, +} + +impl Criterias { + /// Merges two database [Criterias], can show warnings if some bizare things happens… + pub fn merge(mut self, mut other: Criterias) -> Self { + (self.language.iter()) + .flat_map(|(c, name)| { + let idx = (other.language.iter().enumerate()) + .find_map(|(idx, (code, _))| (c.as_str() == code.as_str()).then_some(idx))?; + let (_, other) = other.language.remove(idx); + Some((c, name, other)) + }) + .for_each(|(c, name, other)| { + log::warn!("language with code '{c}' have multiple names: '{name}', '{other}'") + }); + + self.language.extend(other.language); + self.author.extend(other.author); + self.favorite_haver.extend(other.favorite_haver); + self.song_origin.extend(other.song_origin); + self.song_type.extend(other.song_type); + self + } +} + /// Some infos about the repo and its tags. #[derive(Debug, Deserialize, PartialEq, Eq, Default)] pub struct Infos { /// The current epoch of the database. - pub dbepoch: u64, + #[serde(default)] + dbepoch: u64, /// The tags with keys. - #[serde(rename = "TagKeys")] - pub tag_keys: HashSet<String>, + #[serde(rename = "valuefullTagKeys")] + #[serde(default)] + valuefull_tag_keys: HashSet<String>, /// The tags without keys. - #[serde(rename = "ValuelessTagKeys")] - pub valueless_tag_keys: HashSet<String>, + #[serde(rename = "valuelessTagKeys")] + #[serde(default)] + valueless_tag_keys: HashSet<String>, + + /// The criterias. + #[serde(default)] + criterias: Criterias, +} + +impl Infos { + /// Merges two database [Infos], can show warnings if some bizare things happens… + pub fn merge(mut self, other: Infos) -> Self { + if !(self.valueless_tag_keys).is_disjoint(&other.valuefull_tag_keys) + || !(self.valuefull_tag_keys).is_disjoint(&other.valueless_tag_keys) + { + log::warn!("valuefull and valueless tags share some keys…") + } + + self.valueless_tag_keys.extend(other.valueless_tag_keys); + self.valuefull_tag_keys.extend(other.valuefull_tag_keys); + Self { + dbepoch: cmp::max(self.dbepoch, other.dbepoch), + valuefull_tag_keys: self.valuefull_tag_keys, + valueless_tag_keys: self.valueless_tag_keys, + criterias: self.criterias.merge(other.criterias), + } + } + + /// Warn when there is intersection between valuefull and valueless tags. + pub fn warn_on_conflicting_tags(&self) { + if !(self.valuefull_tag_keys).is_disjoint(&self.valueless_tag_keys) { + log::warn!("valueless and valuefull tags have some shared keys…") + } + } + + pub fn epoch(&self) -> u64 { + self.dbepoch + } + + pub fn song_types(&self) -> impl Iterator<Item = SongType> + '_ { + self.criterias.song_type.iter().copied() + } + + pub fn song_origins(&self) -> impl Iterator<Item = SongOrigin> + '_ { + self.criterias.song_origin.iter().copied() + } + + pub fn languages(&self) -> impl Iterator<Item = &str> { + (self.criterias.language.iter()).map(|(code, _)| code.as_str()) + } + + pub fn language_code(&self, code: &str) -> Option<&str> { + (self.criterias.language.iter()) + .find_map(|(c, n)| (code == c.as_str()).then_some(n.as_str())) + } + + pub fn authors(&self) -> impl Iterator<Item = &str> { + self.criterias.author.iter().map(String::as_str) + } + + pub fn favorites(&self) -> impl Iterator<Item = &str> { + self.criterias.favorite_haver.iter().map(String::as_str) + } + + pub fn tags(&self) -> impl Iterator<Item = &str> { + self.valueless_tag_keys + .union(&self.valuefull_tag_keys) + .map(String::as_str) + } + + pub fn valuefull_tags(&self) -> impl Iterator<Item = &str> { + self.valuefull_tag_keys.iter().map(String::as_str) + } + + pub fn valueless_tags(&self) -> impl Iterator<Item = &str> { + self.valueless_tag_keys.iter().map(String::as_str) + } } /// The type of a song. One the the following, one per kara. -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash, Display)] #[serde(rename_all = "UPPERCASE")] +#[display("{}", self.as_str())] pub enum SongType { OP, ED, @@ -41,8 +167,9 @@ pub enum SongType { } /// The origin of a song's source. One the the following, one per kara. -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash, Display)] #[serde(rename_all = "lowercase")] +#[display("{}", self.as_str())] pub enum SongOrigin { Anime, VN, @@ -76,7 +203,7 @@ impl SongOrigin { } impl FromStr for SongType { - type Err = String; + type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "OP" => Ok(Self::OP), @@ -84,13 +211,13 @@ impl FromStr for SongType { "IS" => Ok(Self::IS), "MV" => Ok(Self::MV), "OT" => Ok(Self::OT), - _ => Err(format!("unknown song type: {s}")), + _ => Err(Error(format!("unknown song type: {s}").into())), } } } impl FromStr for SongOrigin { - type Err = String; + type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "anime" => Ok(Self::Anime), @@ -98,35 +225,52 @@ impl FromStr for SongOrigin { "game" => Ok(Self::Game), "music" => Ok(Self::Music), "other" => Ok(Self::Other), - _ => Err(format!("unknown song origin: {s}")), + _ => Err(Error(format!("unknown song origin: {s}").into())), } } } -impl AsRef<str> for SongType { - fn as_ref(&self) -> &str { +impl borrow::Borrow<str> for SongType { + fn borrow(&self) -> &str { self.as_str() } } -impl AsRef<str> for SongOrigin { +impl AsRef<str> for SongType { fn as_ref(&self) -> &str { self.as_str() } } -impl std::fmt::Display for SongType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) +impl borrow::Borrow<str> for SongOrigin { + fn borrow(&self) -> &str { + self.as_str() } } -impl std::fmt::Display for SongOrigin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) +impl AsRef<str> for SongOrigin { + fn as_ref(&self) -> &str { + self.as_str() } } +/// The struct returned by kurisu when downloading a group of karas. +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct Karas { + /// The epoch. + #[serde(default)] + dbepoch: u64, + + /// The validity (in seconds) of this epoch. + #[serde(rename = "dbepochValidity")] + #[serde(default)] + dbepoch_validity: u64, + + /// All of the karas returned. + #[serde(default)] + karas: BTreeSet<Kara>, +} + /// The struct returned by kurisu. Intended to be deserialized from json. #[derive(Debug, Deserialize, PartialEq, Eq)] pub struct Kara { @@ -142,13 +286,14 @@ pub struct Kara { /// The list of authors that contributed to the kara. #[serde(rename = "author")] - pub kara_makers: Vec<String>, + pub kara_makers: HashSet<String>, /// Is the kara a new one? pub is_new: bool, /// Is the kara a virtual one? #[serde(rename = "virtual")] + #[serde(default)] pub is_virtual: bool, /// The type of the song. Is it an ending, opening, etc. @@ -170,11 +315,75 @@ pub struct Kara { pub epoch: u64, /// The size of the file. + #[serde(default)] pub filesize: u64, /// The sha256 hash of the file. - pub file_hash: SHA256, + #[serde(default)] + pub file_hash: Option<SHA256>, /// The tags ised in the kara. + #[serde(default)] pub tags: Vec<[String; 2]>, } + +impl Ord for Kara { + fn cmp(&self, other: &Self) -> cmp::Ordering { + PartialOrd::partial_cmp(&self, &other).unwrap() + } +} +impl PartialOrd for Kara { + #[allow(clippy::non_canonical_partial_ord_impl)] + fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> { + PartialOrd::partial_cmp(&self.id, &other.id) + } +} + +#[derive(Debug, Deserialize)] +pub struct Favorites(HashMap<String, HashSet<u64>>); + +impl Favorites { + /// Names of favorite lists. + pub fn lists(&self) -> impl Iterator<Item = &str> { + self.0.keys().map(String::as_str) + } + + /// The content of a favorite list. + pub fn list_content(&self, user: &str) -> impl Iterator<Item = u64> + '_ { + (self.0.get(user)) + .map(|list| list.iter().copied()) + .into_iter() + .flatten() + } +} + +impl IntoIterator for Favorites { + type Item = (String, HashSet<u64>); + type IntoIter = <HashMap<String, HashSet<u64>> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct Playlist { + /// The ID of the playlist. + id: u64, + + /// The title of the playlist. + title: String, + + /// The optional description of the playlist, can be empty. + #[serde(default)] + description: String, + + /// The owners of this playlist, can be empty. + #[serde(default)] + owners: Vec<String>, + + /// The content of this playlist, can be empty. The order is important and there can be + /// repetitions of karas. + #[serde(default)] + karas: Vec<u64>, +} diff --git a/kurisu_api/tests/dbinfo.json b/kurisu_api/tests/dbinfo.json index cd9a54919be7d3cfe93b95db95e59de0905a977d..8bea121236097ae32279f05ea0d30b681502789b 100644 --- a/kurisu_api/tests/dbinfo.json +++ b/kurisu_api/tests/dbinfo.json @@ -1,23 +1,199 @@ { - "dbepoch": 1, - "TagKeys": [ - "Version", - "Composer", - "VTuber", - "Version", - "Composer", - "VTuber", - "Version", - "Composer", - "VTuber", - "Version", - "Composer", - "VTuber" + "dbepoch": 95, + "criterias": { + "author": [ "Elliu", "fooPseudo", "TheOneAuthor", "TheOneAuthor2", "TTheOneAuthor2" ], + "language": [ + [ "aa", "Afar" ], + [ "ab", "Abkhazian" ], + [ "af", "Afrikaans" ], + [ "ak", "Akan" ], + [ "sq", "Albanian" ], + [ "am", "Amharic" ], + [ "ar", "Arabic" ], + [ "an", "Aragonese" ], + [ "hy", "Armenian" ], + [ "as", "Assamese" ], + [ "av", "Avaric" ], + [ "ae", "Avestan" ], + [ "ay", "Aymara" ], + [ "az", "Azerbaijani" ], + [ "ba", "Bashkir" ], + [ "bm", "Bambara" ], + [ "eu", "Basque" ], + [ "be", "Belarusian" ], + [ "bn", "Bengali" ], + [ "bh", "Bihari languages" ], + [ "bi", "Bislama" ], + [ "bs", "Bosnian" ], + [ "br", "Breton" ], + [ "bg", "Bulgarian" ], + [ "my", "Burmese" ], + [ "ca", "Catalan" ], + [ "ch", "Chamorro" ], + [ "ce", "Chechen" ], + [ "zh", "Chinese" ], + [ "cu", "Old Slavonic" ], + [ "cv", "Chuvash" ], + [ "kw", "Cornish" ], + [ "co", "Corsican" ], + [ "cr", "Cree" ], + [ "cs", "Czech" ], + [ "da", "Danish" ], + [ "dv", "Maldivian" ], + [ "nl", "Dutch" ], + [ "dz", "Dzongkha" ], + [ "en", "English" ], + [ "eo", "Esperanto" ], + [ "et", "Estonian" ], + [ "ee", "Ewe" ], + [ "fo", "Faroese" ], + [ "fj", "Fijian" ], + [ "fi", "Finnish" ], + [ "fr", "French" ], + [ "fy", "Western Frisian" ], + [ "ff", "Fulah" ], + [ "ka", "Georgian" ], + [ "de", "German" ], + [ "gd", "Gaelic" ], + [ "ga", "Irish" ], + [ "gl", "Galician" ], + [ "gv", "Manx" ], + [ "el", "Greek" ], + [ "gn", "Guarani" ], + [ "gu", "Gujarati" ], + [ "ht", "Haitian" ], + [ "ha", "Hausa" ], + [ "he", "Hebrew" ], + [ "hz", "Herero" ], + [ "hi", "Hindi" ], + [ "ho", "Hiri Motu" ], + [ "hr", "Croatian" ], + [ "hu", "Hungarian" ], + [ "ig", "Igbo" ], + [ "is", "Icelandic" ], + [ "io", "Ido" ], + [ "ii", "Sichuan Yi" ], + [ "iu", "Inuktitut" ], + [ "ie", "Interlingue" ], + [ "ia", "Interlingua" ], + [ "id", "Indonesian" ], + [ "ik", "Inupiaq" ], + [ "it", "Italian" ], + [ "jv", "Javanese" ], + [ "ja", "Japanese" ], + [ "kl", "Kalaallisut" ], + [ "kn", "Kannada" ], + [ "ks", "Kashmiri" ], + [ "kr", "Kanuri" ], + [ "kk", "Kazakh" ], + [ "km", "Central Khmer" ], + [ "ki", "Kikuyu" ], + [ "rw", "Kinyarwanda" ], + [ "ky", "Kirghiz" ], + [ "kv", "Komi" ], + [ "kg", "Kongo" ], + [ "ko", "Korean" ], + [ "kj", "Kuanyama" ], + [ "ku", "Kurdish" ], + [ "lo", "Lao" ], + [ "la", "Latin" ], + [ "lv", "Latvian" ], + [ "li", "Limburgan" ], + [ "ln", "Lingala" ], + [ "lt", "Lithuanian" ], + [ "lb", "Luxembourgish" ], + [ "lu", "Luba-Katanga" ], + [ "lg", "Ganda" ], + [ "mk", "Macedonian" ], + [ "mh", "Marshallese" ], + [ "ml", "Malayalam" ], + [ "mi", "Maori" ], + [ "mr", "Marathi" ], + [ "ms", "Malay" ], + [ "mg", "Malagasy" ], + [ "mt", "Maltese" ], + [ "mn", "Mongolian" ], + [ "na", "Nauru" ], + [ "nv", "Navajo" ], + [ "nr", "South Ndebele" ], + [ "nd", "North Ndebele" ], + [ "ng", "Ndonga" ], + [ "ne", "Nepali" ], + [ "nn", "Norwegian Nynorsk" ], + [ "nb", "Norwegian BokmÃ¥l" ], + [ "no", "Norwegian" ], + [ "ny", "Chichewa" ], + [ "oc", "Occitan" ], + [ "oj", "Ojibwa" ], + [ "or", "Oriya" ], + [ "om", "Oromo" ], + [ "os", "Ossetian" ], + [ "pa", "Punjabi" ], + [ "fa", "Persian" ], + [ "pi", "Pali" ], + [ "pl", "Polish" ], + [ "pt", "Portuguese" ], + [ "ps", "Pashto" ], + [ "qu", "Quechua" ], + [ "rm", "Romansh" ], + [ "ro", "Romanian / Moldavian" ], + [ "rn", "Rundi" ], + [ "ru", "Russian" ], + [ "sg", "Sango" ], + [ "sa", "Sanskrit" ], + [ "si", "Sinhala" ], + [ "sk", "Slovak" ], + [ "sl", "Slovenian" ], + [ "se", "Northern Sami" ], + [ "sm", "Samoan" ], + [ "sn", "Shona" ], + [ "sd", "Sindhi" ], + [ "so", "Somali" ], + [ "st", "Southern Sotho" ], + [ "es", "Spanish" ], + [ "sc", "Sardinian" ], + [ "sr", "Serbian" ], + [ "ss", "Swati" ], + [ "su", "Sundanese" ], + [ "sw", "Swahili" ], + [ "sv", "Swedish" ], + [ "ty", "Tahitian" ], + [ "ta", "Tamil" ], + [ "tt", "Tatar" ], + [ "te", "Telugu" ], + [ "tg", "Tajik" ], + [ "tl", "Tagalog" ], + [ "th", "Thai" ], + [ "bo", "Tibetan" ], + [ "ti", "Tigrinya" ], + [ "to", "Tonga (Tonga Islands)" ], + [ "tn", "Tswana" ], + [ "ts", "Tsonga" ], + [ "tk", "Turkmen" ], + [ "tr", "Turkish" ], + [ "tw", "Twi" ], + [ "ug", "Uighur" ], + [ "uk", "Ukrainian" ], + [ "ur", "Urdu" ], + [ "uz", "Uzbek" ], + [ "ve", "Venda" ], + [ "vi", "Vietnamese" ], + [ "vo", "Volapük" ], + [ "cy", "Welsh" ], + [ "wa", "Walloon" ], + [ "wo", "Wolof" ], + [ "xh", "Xhosa" ], + [ "yi", "Yiddish" ], + [ "yo", "Yoruba" ], + [ "za", "Zhuang" ], + [ "zu", "Zulu" ], + [ "fx", "Fictional" ], + [ "ot", "Joker" ] ], - "ValuelessTagKeys": [ - "VTuber", - "VTuber", - "VTuber", - "VTuber" - ] + "song_origin": [ "anime", "vn", "game", "music", "other" ], + "song_type": [ "OP", "ED", "IS", "MV", "OT" ], + "favorites_haver": [ "fooPseudo" ] + }, + "valuefullTagKeys": [ "Number", "Composer", "ComposerButSuperLongToFaireChier" ], + "valuelessTagKeys": [ "VTuber" ] } diff --git a/kurisu_api/tests/favorites.json b/kurisu_api/tests/favorites.json new file mode 100644 index 0000000000000000000000000000000000000000..d755994c68638388fb45903680aaf2ad11bd028f --- /dev/null +++ b/kurisu_api/tests/favorites.json @@ -0,0 +1,5 @@ +{ + "fooPseudo": [ 6, 9544 ], + "zooBar": [ 5 ] +} + diff --git a/kurisu_api/tests/karas.json b/kurisu_api/tests/karas.json new file mode 100644 index 0000000000000000000000000000000000000000..4636710d2caf1b66ee20b3afd15b9d6fae51da75 --- /dev/null +++ b/kurisu_api/tests/karas.json @@ -0,0 +1,150 @@ +{ + "dbepoch": 95, + "dbepochValidity": 2024072806, + "karas": [ + { + "id": 9544, + "song_title": "Hyper", + "source_name": "Under Ninja", + "song_type": "OP", + "song_origin": "anime", + "is_new": false, + "created_at": 65974410587, + "updated_at": 65974410587, + "epoch": 0, + "file_hash": "01567e864bf255dab39245b5ab0c521ac960080f45b69406bba434dc5ba7985b", + "nextFilepath": "", + "filesize": 24693413, + "virtual": false, + "language": [ "ru" ], + "author": [ "Elliu" ], + "tags": [ + [ "Number", "1" ], + [ "Composer", "xcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklak" ], + [ "Composer", "ejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzakl" ] + ] + }, + { + "id": 5, + "song_title": "Shion", + "source_name": "niKu", + "song_type": "MV", + "song_origin": "vn", + "is_new": false, + "created_at": 1724741313, + "updated_at": 1724741320, + "epoch": 0, + "file_hash": "054c85b4e39b62897e24922af5c21593325100a5d75a57f5fa56c194e83eee67", + "nextFilepath": "", + "filesize": 44060958, + "virtual": false, + "language": [ "ab" ], + "author": [ "fooPseudo", "Elliu", "TheOneAuthor" ], + "tags": [ + [ "VTuber", "" ], + [ "Composer", "xcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklaxcklak" ], + [ "Composer", "ejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzaklejzakl" ], + [ "ComposerButSuperLongToFaireChier", "aze" ] + ] + }, + { + "id": 4, + "song_title": "Gif ni Ted", + "source_name": "Henjin no Salad Bowl", + "song_type": "OP", + "song_origin": "vn", + "is_new": false, + "created_at": 1724740583, + "updated_at": 1724740596, + "epoch": 0, + "file_hash": "91b3afcfba5d9022f9b72e0b94127d9f1a11cc047f8540e2393c702429de80ce", + "nextFilepath": "", + "filesize": 24201908, + "virtual": false, + "language": [ "af" ], + "author": [ "TheOneAuthor", "Elliu", "fooPseudo" ], + "tags": [ + [ "Number", "1" ], + [ "VTuber", "" ], + [ "Composer", "WaouhSuperJ'adore" ] + ] + }, + { + "id": 2, + "song_title": "Pre-Romance", + "source_name": "Natsu e no Tunnel, Sayonara no Deguchi", + "song_type": "MV", + "song_origin": "game", + "is_new": true, + "created_at": 1723986494, + "updated_at": 1723986500, + "epoch": 0, + "file_hash": "6f3df02ee1f000295deaab0fe89e6908ae1ca3a25a533bc99b3d59fa60caf497", + "nextFilepath": "", + "filesize": 9784315, + "virtual": false, + "language": [ "aa" ], + "author": [ "fooPseudo" ], + "tags": [] + }, + { + "id": 1, + "song_title": "Rap God ft. Momoi (46 Brian)", + "source_name": "Eminem", + "song_type": "MV", + "song_origin": "game", + "is_new": false, + "created_at": 1723986487, + "updated_at": 1723986496, + "epoch": 0, + "file_hash": "6b512e13388623a1aed0967a449320818d4eb4c91ffec20014cc0ee02f0fbd4f", + "nextFilepath": "", + "filesize": 5488980, + "virtual": false, + "language": [ "af" ], + "author": [ "fooPseudo" ], + "tags": [] + }, + { + "id": 3, + "song_title": "Every Second (Japanese Version)", + "source_name": "Hananoi-kun to Koi no Yamai", + "song_type": "ED", + "song_origin": "game", + "is_new": false, + "created_at": 1724143207, + "updated_at": 1724740456, + "epoch": 0, + "file_hash": "5c78cc58610da5ac5212e8d48a61eb2f8f5ff89c97e47372e0142d5f59de8039", + "nextFilepath": "", + "filesize": 8197649, + "virtual": false, + "language": [ "ak" ], + "author": [ "fooPseudo" ], + "tags": [ + [ "Number", "1" ], + [ "VTuber", "" ] + ] + }, + { + "id": 6, + "song_title": "Reversal", + "source_name": "Tsuki ga Michibiku Isekai Douchuu 2nd Season", + "song_type": "OP", + "song_origin": "game", + "is_new": false, + "created_at": 1725547250, + "updated_at": 1726757878, + "epoch": 0, + "file_hash": "19f0f6ab891c8a565f6483d6148782efd2d0e935b1e092951529cb59bcc35f34", + "nextFilepath": "", + "filesize": 13453030, + "virtual": false, + "language": [ "af" ], + "author": [ "fooPseudo" ], + "tags": [ + [ "Number", "2" ] + ] + } + ] +} diff --git a/kurisu_api/tests/playlists.json b/kurisu_api/tests/playlists.json new file mode 100644 index 0000000000000000000000000000000000000000..46af1f8bd71275f93d5d66cd3ff5d20a5c82f239 --- /dev/null +++ b/kurisu_api/tests/playlists.json @@ -0,0 +1,37 @@ +[ + { + "id": 5, + "title": "Playlist 1", + "description": "This is an awesome playlist", + "owners": [ "fooPseudo" ], + "karas": [ 6, 9544, 5 ] + }, + { + "id": 6, + "title": "Playlist 2", + "description": "This playlist is not yours", + "owners": [ "fooPseudo<wow>" ], + "karas": [ 9544, 5 ] + }, + { + "id": 8, + "title": "Playlist with quite long name", + "description": "Short description", + "owners": [], + "karas": [ 6 ] + }, + { + "id": 9, + "title": "Kinda The longest The longest The longest The longest The longest The longest The longest The longest The longest The longest", + "description": "aze", + "owners": [ "fooPseudo", "fooPseudo<wow>" ], + "karas": [] + }, + { + "id": 10, + "title": "aze", + "description": "aze", + "owners": [ "fooPseudo" ], + "karas": [] + } +] diff --git a/kurisu_api/tests/sample.json b/kurisu_api/tests/sample.json deleted file mode 100644 index 609d1bfb1e70ad648c4302342fb88ebfe7749cf6..0000000000000000000000000000000000000000 --- a/kurisu_api/tests/sample.json +++ /dev/null @@ -1,323 +0,0 @@ -[ - { - "id": 9022, - "song_title": "hA HA honk2", - "source_name": "senzawa", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "br", - "de" - ], - "author": [ - "Elliu" - ], - "tags": [ - [ - "Version", - "Short" - ], - [ - "Composer", - "aze" - ] - ], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-27 20:12:55", - "file_hash": "d1d2033d760e93158fc64426e8b7ec3535fbf92d41e48bc16fca2f5fd8b2a926", - "filesize": 1465654, - "epoch": 1, - "updated_at": 1677967139, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9026, - "song_title": "Chikatto Chika Chika", - "source_name": "Kaguya-sama wa Kokurasetai", - "song_type": "ED", - "song_origin": "vn", - "language": [ - "ca", - "ja" - ], - "author": [ - "Kubat" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-27 20:16:20", - "file_hash": "def21dad50c0d4d9c477591cd9de039a7ab3c0d65d52b390417edbbb0776052f", - "filesize": 17122564, - "epoch": 1, - "updated_at": 1683208523, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9027, - "song_title": "last sparkle", - "source_name": "Pop Team Epic Special", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "zh", - "br" - ], - "author": [ - "Salixor" - ], - "tags": [ - [ - "Composer", - "aze" - ] - ], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:11:48", - "file_hash": "3da80457a071047896c833a0ddde64a82b524e78cf33e2de0a312160ad0e40c2", - "filesize": 16883894, - "epoch": 8, - "updated_at": 1683208648, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9028, - "song_title": "Piyo", - "source_name": "Higepiyo", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "en" - ], - "author": [ - "Elliu" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:07:20", - "file_hash": "c285aa8b1bd3c21bf158b6bbb2c8798febc43ed9d707fb6fd79a074d5f359b41", - "filesize": 463172, - "epoch": 0, - "updated_at": 1683209107, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9029, - "song_title": "Maka went gao", - "source_name": "Soul Eater", - "song_type": "IS", - "song_origin": "game", - "language": [ - "la" - ], - "author": [ - "Deurstann" - ], - "tags": [], - "upload_comment": "", - "is_new": false, - "author_year": "2023-09-10 13:08:02", - "file_hash": "0a0baa8de3696955a695fdf816abb42539bceadbfb4c79cde7d43c081ff6d9c0", - "filesize": 196032, - "epoch": 0, - "updated_at": 1683209132, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9030, - "song_title": "Chijou no Senshi", - "source_name": "Sakura Taisen New York, New York", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "ja" - ], - "author": [ - "Bisquette" - ], - "tags": [ - [ - "VTuber", - "" - ] - ], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-24 14:48:30", - "file_hash": "974583d04d8fe2485b64ec8de97785cc34536cd8a69166d3be2cd907148154d2", - "filesize": 11905649, - "epoch": 0, - "updated_at": 1683209134, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9031, - "song_title": "Hare Hare Yukai", - "source_name": "Suzumiya Haruhi no Yuuutsu", - "song_type": "ED", - "song_origin": "vn", - "language": [ - "ja" - ], - "author": [ - "Sting" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:08:05", - "file_hash": "99a280ae6fde376dc6f933e7ff86e377863bc18a2c775265eba387b217c660fb", - "filesize": 10130414, - "epoch": 0, - "updated_at": 1683209135, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9032, - "song_title": "All Alone With You", - "source_name": "Psycho-Pass", - "song_type": "ED", - "song_origin": "vn", - "language": [ - "fr" - ], - "author": [ - "Colgate" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:07:50", - "file_hash": "47a5585c5fe4d1e5ea85c80f3a01d465aa393a1dbbd1bd38f2021df90dd44fec", - "filesize": 10044203, - "epoch": 0, - "updated_at": 1683209136, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9033, - "song_title": "Mes rêves", - "source_name": "Pokémon", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "fr" - ], - "author": [ - "Deurstann" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:07:24", - "file_hash": "4036f7f60c08bf70741f118eca95dff61facfb66199351e9e40900071cd5322d", - "filesize": 10247536, - "epoch": 0, - "updated_at": 1683209137, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9035, - "song_title": "Shiny tale", - "source_name": "Danshi Koukousei no Nichijou", - "song_type": "OP", - "song_origin": "anime", - "language": [ - "fr" - ], - "author": [ - "Sting" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-10 13:07:17", - "file_hash": "c6d19655f65777e8bcae8394289bd1ba5e986d31cd53725ea590a22e1c285419", - "filesize": 22148477, - "epoch": 0, - "updated_at": 1683209138, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9036, - "song_title": "Wind", - "source_name": "Naruto", - "song_type": "ED", - "song_origin": "vn", - "language": [ - "ru" - ], - "author": [ - "Deurstann" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-09 19:35:48", - "file_hash": "be4f6b70ef0c3fce3744d397e7c8a4292f1e029a2b82512832ece0f632641976", - "filesize": 6533611, - "epoch": 0, - "updated_at": 1683209138, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9037, - "song_title": "Get Wild", - "source_name": "City Hunter", - "song_type": "ED", - "song_origin": "vn", - "language": [ - "ja" - ], - "author": [ - "Deurstann" - ], - "tags": [], - "upload_comment": "", - "is_new": true, - "author_year": "2023-09-09 19:22:00", - "file_hash": "d6bad8ec540853d71669912e2731c197bdb150cf05f00c3d7fb2aaecd55b9a6e", - "filesize": 6857963, - "epoch": 0, - "updated_at": 1683209227, - "created_at": 1677967139, - "virtual": false - }, - { - "id": 9041, - "song_title": "hA HA honk", - "source_name": "senzawa", - "song_type": "IS", - "song_origin": "game", - "language": [ - "ja" - ], - "author": [ - "Elliu" - ], - "tags": [], - "upload_comment": "oops reupload cause <>", - "is_new": true, - "author_year": "2023-09-10 12:38:27", - "file_hash": "86c2eb4fe6a78afb0a3c6d5e769154fdf4e00cf4a6e0e0ff5bb2ee74d5f3a750", - "filesize": 1465654, - "epoch": 0, - "updated_at": 1694290141, - "created_at": 1677967139, - "virtual": false - } -] diff --git a/kurisu_api/tests/v2.rs b/kurisu_api/tests/v2.rs index 88fcbffa6d072a9d89ccf56cb806fb986cfca547..1fc0c1412c16e0123414ccc505084545f89fc67b 100644 --- a/kurisu_api/tests/v2.rs +++ b/kurisu_api/tests/v2.rs @@ -3,8 +3,7 @@ use lektor_utils::{assert_err, assert_ok}; #[test] fn test_kurisu_v2_dbinfos() { - const SAMPLE: &str = include_str!("dbinfo.json"); - assert_ok!(serde_json::from_str::<Infos>(SAMPLE)); + assert_ok!(serde_json::from_str::<Infos>(include_str!("dbinfo.json"))); } #[test] @@ -43,6 +42,19 @@ fn test_kurisu_v2_simple_enums() { #[test] fn test_kurisu_v2_get_karas() { - const SAMPLE: &str = include_str!("sample.json"); - assert_ok!(serde_json::from_str::<Vec<Kara>>(SAMPLE)); + assert_ok!(serde_json::from_str::<Karas>(include_str!("karas.json"))); +} + +#[test] +fn test_kurisu_v2_get_favorites() { + assert_ok!(serde_json::from_str::<Favorites>(include_str!( + "favorites.json" + ))); +} + +#[test] +fn test_kurisu_v2_get_playlists() { + assert_ok!(serde_json::from_str::<Vec<Playlist>>(include_str!( + "playlists.json" + ))); } diff --git a/lektor_lib/Cargo.toml b/lektor_lib/Cargo.toml index c6f37da0a86fcb327b5519cec5f4388446ae47a9..e882fd53e5c990b9dfb2d375dbc3e19ec23aba32 100644 --- a/lektor_lib/Cargo.toml +++ b/lektor_lib/Cargo.toml @@ -8,17 +8,14 @@ rust-version.workspace = true description = "Client library for lektord, used to factorize the code between lkt and amadeus" [dependencies] -serde.workspace = true +serde.workspace = true serde_json.workspace = true -log.workspace = true -url.workspace = true -anyhow.workspace = true - +log.workspace = true +url.workspace = true +anyhow.workspace = true reqwest.workspace = true - futures.workspace = true -async-trait.workspace = true -lektor_utils = { path = "../lektor_utils" } +lektor_utils = { path = "../lektor_utils" } lektor_payloads = { path = "../lektor_payloads" } diff --git a/lektor_lib/src/requests.rs b/lektor_lib/src/requests.rs index 719f92626f02f22b43771893bcc524d94d0c04fd..fc66474a4417cea0a8ec2933c958a60a52ee3f29 100644 --- a/lektor_lib/src/requests.rs +++ b/lektor_lib/src/requests.rs @@ -1,5 +1,9 @@ use crate::ConnectConfig; use anyhow::{bail, Context, Result}; +use futures::{ + stream::{self, FuturesUnordered}, + StreamExt, +}; use lektor_payloads::*; use lektor_utils::{encode_base64, encode_base64_value}; use reqwest::{ @@ -66,14 +70,23 @@ pub async fn get_status(config: impl AsRef<ConnectConfig>) -> Result<PlayStateWi request!(config; GET @ "/playback/state" => PlayStateWithCurrent) } -pub async fn get_kara_by_kid(config: impl AsRef<ConnectConfig>, kid: KId) -> Result<Kara> { - let (len, kid) = encode_base64(kid.as_str())?; - let kid = std::str::from_utf8(&kid[..len])?; - request!(config; GET @ "/get/kid/{kid}" => Kara) +pub async fn get_karas_by_kid( + config: impl AsRef<ConnectConfig>, + kids: Vec<KId>, +) -> Result<Vec<Kara>> { + let config = config.as_ref(); + Ok(stream::iter(kids) + .then(|kid| async move { get_kara_by_kid(config, kid).await }) + .collect::<FuturesUnordered<_>>() + .await + .into_iter() + .flatten() + .collect()) } -pub async fn get_kara_by_id(config: impl AsRef<ConnectConfig>, id: u64) -> Result<Kara> { - request!(config; GET @ "/get/id/{id}" => Kara) +pub async fn get_kara_by_kid(config: impl AsRef<ConnectConfig>, kid: KId) -> Result<Kara> { + log::info!("try to get kara {kid}"); + request!(config; GET @ "/get/kid/{}", encode_base64(kid.to_string())? => Kara) } pub async fn toggle_playback_state(config: impl AsRef<ConnectConfig>) -> Result<()> { @@ -132,33 +145,25 @@ pub async fn remove_level_from_queue( config: impl AsRef<ConnectConfig>, level: Priority, ) -> Result<()> { - request!(config; DELETE @ "/queue/{level}") + request!(config; DELETE @ "/queue/level/{level}") } pub async fn remove_range_from_queue( config: impl AsRef<ConnectConfig>, range: impl Into<Range>, ) -> Result<()> { - let (len, range) = encode_base64(format!("{}", range.into()))?; - let range = std::str::from_utf8(&range[..len])?; - request!(config; DELETE @ "/queue?range={range}") + request!(config; DELETE @ "/queue?range={}", encode_base64(format!("{}", range.into()))?) } pub async fn shuffle_level_queue(config: impl AsRef<ConnectConfig>, prio: Priority) -> Result<()> { - request!(config; PUT @ "/queue/{prio}") -} - -pub async fn shuffle_queue(config: impl AsRef<ConnectConfig>) -> Result<()> { - shuffle_queue_range(config, ..).await + request!(config; PUT @ "/queue/level/{prio}") } pub async fn shuffle_queue_range( config: impl AsRef<ConnectConfig>, range: impl Into<Range>, ) -> Result<()> { - let (len, range) = encode_base64(format!("{}", range.into()))?; - let range = std::str::from_utf8(&range[..len])?; - request!(config; POST(QueueUpdateAction::Shuffle) @ "/queue?range={range}") + request!(config; POST(QueueUpdateAction::Shuffle) @ "/queue?range={}", encode_base64(format!("{}", range.into()))?) } // ================================================== // @@ -169,9 +174,7 @@ pub async fn remove_range_from_history( config: impl AsRef<ConnectConfig>, range: impl Into<Range>, ) -> Result<()> { - let (len, range) = encode_base64(format!("{}", range.into()))?; - let range = std::str::from_utf8(&range[..len])?; - request!(config; DELETE @ "/history?range={range}") + request!(config; DELETE @ "/history?range={}", encode_base64(format!("{}", range.into()))?) } // ================================================== // @@ -183,10 +186,7 @@ pub async fn remove_kid_from_playlist( name: Arc<str>, id: KId, ) -> Result<()> { - let action = PlaylistUpdateAction::Remove(KaraFilter::KId(id)); - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; PATCH(action) @ "/playlist/{name}") + request!(config; PATCH(PlaylistUpdateAction::Remove(KaraFilter::KId(id))) @ "/playlist/{}", encode_base64(name)?) } pub async fn add_to_playlist( @@ -194,9 +194,7 @@ pub async fn add_to_playlist( name: Arc<str>, what: KaraFilter, ) -> Result<()> { - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; PATCH(PlaylistUpdateAction::Add(what)) @ "/playlist/{name}") + request!(config; PATCH(PlaylistUpdateAction::Add(what)) @ "/playlist/{}", encode_base64(name)?) } pub async fn remove_from_playlist( @@ -204,41 +202,34 @@ pub async fn remove_from_playlist( name: Arc<str>, what: KaraFilter, ) -> Result<()> { - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; PATCH(PlaylistUpdateAction::Remove(what)) @ "/playlist/{name}") + request!(config; PATCH(PlaylistUpdateAction::Remove(what)) @ "/playlist/{}", encode_base64(name)?) } pub async fn create_playlist(config: impl AsRef<ConnectConfig>, name: Arc<str>) -> Result<()> { - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; PUT @ "/playlist/{name}") + request!(config; PUT @ "/playlist/{}", encode_base64(name)?) } pub async fn delete_playlist(config: impl AsRef<ConnectConfig>, name: Arc<str>) -> Result<()> { - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; DELETE @ "/playlist/{name}") + request!(config; DELETE @ "/playlist/{}", encode_base64(name)?) } // ================================================== // // Search functions // // ================================================== // -pub async fn get_queue(config: impl AsRef<ConnectConfig>) -> Result<Vec<(Priority, KId)>> { - get_queue_range(config, ..).await -} - -pub async fn get_history(config: impl AsRef<ConnectConfig>) -> Result<Vec<KId>> { - get_history_range(config, ..).await -} - pub async fn get_queue_count( config: impl AsRef<ConnectConfig>, ) -> Result<[usize; PRIORITY_LENGTH]> { request!(config; GET @ "/queue/count" => [usize; PRIORITY_LENGTH]) } +pub async fn get_queue_level( + config: impl AsRef<ConnectConfig>, + prio: Priority, +) -> Result<Vec<KId>> { + request!(config; GET @ "/queue/level/{prio}" => Vec<KId>) +} + pub async fn get_history_count(config: impl AsRef<ConnectConfig>) -> Result<usize> { request!(config; GET @ "/history/count" => usize) } @@ -247,44 +238,41 @@ pub async fn get_queue_range( config: impl AsRef<ConnectConfig>, range: impl Into<Range>, ) -> Result<Vec<(Priority, KId)>> { - let (len, range) = encode_base64(format!("{}", range.into()))?; - let range = std::str::from_utf8(&range[..len])?; - request!(config; GET @ "/queue?range={range}" => Vec<(Priority, KId)>) + request!(config; GET @ "/queue?range={}", encode_base64(range.into().to_string())? => Vec<(Priority, KId)>) } pub async fn get_history_range( config: impl AsRef<ConnectConfig>, range: impl Into<Range>, ) -> Result<Vec<KId>> { - let (len, range) = encode_base64(format!("{}", range.into()))?; - let range = std::str::from_utf8(&range[..len])?; - request!(config; GET @ "/history?range={range}" => Vec<KId>) + request!(config; GET @ "/history?range={}", encode_base64(range.into().to_string())? => Vec<KId>) } -pub async fn get_playlists( - config: impl AsRef<ConnectConfig>, -) -> Result<Vec<(PlaylistName, PlaylistInfo)>> { - request!(config; GET @ "/playlist" => Vec<(PlaylistName, PlaylistInfo)>) +pub async fn get_playlists(config: impl AsRef<ConnectConfig>) -> Result<Vec<(KId, String)>> { + request!(config; GET @ "/playlists" => Vec<(KId, String)>) } -pub async fn get_playlist_content( - config: impl AsRef<ConnectConfig>, - name: Arc<str>, -) -> Result<Vec<KId>> { - let (len, name) = encode_base64(name)?; - let name = std::str::from_utf8(&name[..len])?; - request!(config; GET @ "/playlist/{name}" => Vec<KId>) +pub async fn get_playlist(config: impl AsRef<ConnectConfig>, id: KId) -> Result<Playlist> { + request!(config; GET @ "/playlist/{id}" => Playlist) +} + +pub async fn get_playlist_info(config: impl AsRef<ConnectConfig>, id: KId) -> Result<PlaylistInfo> { + request!(config; GET @ "/playlist/{id}/info" => PlaylistInfo) +} + +pub async fn get_playlist_content(config: impl AsRef<ConnectConfig>, id: KId) -> Result<Vec<KId>> { + request!(config; GET @ "/playlist/{id}/content" => Vec<KId>) } pub async fn search_karas( config: impl AsRef<ConnectConfig>, from: SearchFrom, - by: KaraBy, + by: impl IntoIterator<Item = KaraBy>, ) -> Result<Vec<KId>> { - let regex = vec![by]; - let (len, obj) = encode_base64_value(SearchData { from, regex })?; - let get = std::str::from_utf8(&obj[..len])?; - request!(config; GET @ "/search/{get}" => Vec<KId>) + request!(config; + GET @ "/search/{}", encode_base64_value(SearchData { from, regex: by.into_iter().collect() })? + => Vec<KId> + ) } pub async fn count_karas( @@ -292,8 +280,8 @@ pub async fn count_karas( from: SearchFrom, by: KaraBy, ) -> Result<usize> { - let regex = vec![by]; - let (len, obj) = encode_base64_value(SearchData { from, regex })?; - let get = std::str::from_utf8(&obj[..len])?; - request!(config; GET @ "/count/{get}" => usize) + request!(config; + GET @ "/count/{}", encode_base64_value(SearchData { from, regex: vec![by] })? + => usize + ) } diff --git a/lektor_nkdb/Cargo.toml b/lektor_nkdb/Cargo.toml index 4e18c45e4bb79e62eb5f760230db063ead8155d0..e7eaef27edcae9ac72e80b8705ea277a0fdab5b7 100644 --- a/lektor_nkdb/Cargo.toml +++ b/lektor_nkdb/Cargo.toml @@ -1,30 +1,28 @@ [package] name = "lektor_nkdb" +description = "New database implementation for lektord (New Kara DataBase)" +rust-version.workspace = true + version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -rust-version.workspace = true -description = "New database implementation for lektord (New Kara DataBase)" [dependencies] -serde.workspace = true -serde_json.workspace = true - -log.workspace = true -url.workspace = true -rand.workspace = true -regex.workspace = true -chrono.workspace = true -sha256.workspace = true -anyhow.workspace = true -hashbrown.workspace = true - -tokio.workspace = true -futures.workspace = true -async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +log.workspace = true +url.workspace = true +rand.workspace = true +regex.workspace = true +chrono.workspace = true +sha256.workspace = true +anyhow.workspace = true +hashbrown.workspace = true +derive_more.workspace = true +tokio.workspace = true +futures.workspace = true tokio-stream.workspace = true - -kurisu_api = { path = "../kurisu_api" } -lektor_utils = { path = "../lektor_utils" } -lektor_procmacros = { path = "../lektor_procmacros" } +kurisu_api = { path = "../kurisu_api" } +lektor_utils = { path = "../lektor_utils" } +lektor_procmacros = { path = "../lektor_procmacros" } diff --git a/lektor_nkdb/src/database/epoch.rs b/lektor_nkdb/src/database/epoch.rs index 36c63d2b3f4e7216ca02da0509d0cd341e0b9237..059a569ef38daf2097e89c4133c6a97eeffea665 100644 --- a/lektor_nkdb/src/database/epoch.rs +++ b/lektor_nkdb/src/database/epoch.rs @@ -6,7 +6,7 @@ use crate::*; /// The epoch contains all available karas at a certain point in time. It can be submitted and /// available to all readers of the database or unsubmited and only one writter can edit it. #[derive(Debug, Default)] -pub(crate) struct Epoch(EpochData, u64); +pub struct Epoch(EpochData, u64); /// Represent the data contained in an epoch. pub type EpochData = HashMap<KId, Kara>; @@ -19,15 +19,8 @@ impl From<(EpochData, u64)> for Epoch { impl Epoch { /// Get all the tuples (KId, RemoteKId) for the kara present in the epoch. - pub fn ids(&self) -> impl Iterator<Item = (&KId, &RemoteKId)> + '_ { - self.0.values().map(|kara| (&kara.id, &kara.remote)) - } - - /// Get a [Kara] by its local id [u64]. The [KId] is the unique id [u64] for this epoch plus - /// the path from the data folder. A single [u64] can have two [KId] representations if a kara - /// was changed in an epoch and that epoch is loaded in memory. - pub fn get_kara_by_u64(&self, id: u64) -> Option<&Kara> { - self.0.values().find(|kara| id.eq(&kara.id.local_id())) + pub fn ids(&self) -> impl Iterator<Item = (KId, RemoteKId)> + '_ { + self.0.values().map(|kara| (kara.id, kara.remote.clone())) } /// Get the list of [Kara] corresponding to the [KId]. If we failed to find one kara we log the @@ -45,11 +38,6 @@ impl Epoch { self.0.get(&id) } - /// Returns whever the [Epoch] contains the [KId] or not. - pub fn contains(&self, id: &KId) -> bool { - self.0.contains_key(id) - } - /// Get access to the karas in the epoch. pub fn data(&self) -> &EpochData { &self.0 @@ -61,7 +49,7 @@ impl Epoch { } /// Get the number of the epoch - pub fn epoch_num(&self) -> u64 { + pub fn num(&self) -> u64 { self.1 } } diff --git a/lektor_nkdb/src/database/mod.rs b/lektor_nkdb/src/database/mod.rs index aa7598d855980c465d9bd1fda50e4e2474b17c88..357442d3173d9733a06698169984b7d68e3dcc7d 100644 --- a/lektor_nkdb/src/database/mod.rs +++ b/lektor_nkdb/src/database/mod.rs @@ -1,15 +1,7 @@ //! Base definition of how we store kara in different epochs. Even if it's called "database", it's //! only a small, but fundational, part of the database system. -mod epoch; -mod kara; -mod pool; -mod update; - -pub use kara::*; -pub use kurisu_api::v2::{SongOrigin, SongType}; -pub use pool::{KId, RemoteKId}; -pub use update::UpdateHandler; - -pub(crate) use epoch::*; -pub(crate) use pool::Pool; +pub(crate) mod epoch; +pub(crate) mod kara; +pub(crate) mod pool; +pub(crate) mod update; diff --git a/lektor_nkdb/src/database/pool.rs b/lektor_nkdb/src/database/pool.rs index 332db3f225b1fd8b93af22befa9ffc1d1fe0e81a..5af0585da9191f85acbbd22541b52a4ac8add26d 100644 --- a/lektor_nkdb/src/database/pool.rs +++ b/lektor_nkdb/src/database/pool.rs @@ -4,109 +4,65 @@ //! representation for searching plus some mapping. A pool is common for all the epochs. For epoch //! specific things like the [u64] to [Kid] / [Kara] mappings, do that in the epoch struct. -use crate::{EpochData, Playlist}; -use hashbrown::HashMap; -use serde::{Deserialize, Serialize}; -use std::{collections::hash_map::DefaultHasher, hash::Hasher, sync::Arc}; +use crate::{EpochData, KId, RemoteKId}; +use derive_more::Into; +use hashbrown::{HashMap, HashSet}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; use tokio::sync::RwLock; -/// A pool of all the available kara for every epochs +/// A pool of all the available kara for every epochs. Contains the mapping for remote id to the +/// local id representations. #[derive(Debug, Default)] pub(crate) struct Pool { - /// Cache for strings, to reduce memory load. - string_cache: RwLock<HashMap<u64, Arc<str>>>, + /// ID mapping from local to remote. Note that there is a one-to-one relation between + /// [RemoteKId] and [KId], even with metadata updates or file update. + id_mapping: RwLock<HashMap<RemoteKId, KId>>, - /// The mapping for remote id <-> local id - id_mapping: RwLock<Vec<(KId, RemoteKId)>>, + /// The next local ID. + next_id: AtomicU64, + + /// Cache strings for authors and languages, and tags. + strings: RwLock<HashSet<Arc<str>>>, } impl Pool { /// Create the pool storage from an iterator of id/remote_id. pub(crate) async fn from_iter<T: IntoIterator<Item = (KId, RemoteKId)>>(iter: T) -> Self { - // Compress things if needed! - let this = Self::default(); - let vec = futures::future::join_all(iter.into_iter().map(|(kid, rkid)| async { - let kid = this.get_str::<KId>(kid.0).await; - let rkid = this.get_str::<RemoteKId>(rkid.0).await; - (kid, rkid) - })) - .await; - let _ = std::mem::replace(&mut (*this.id_mapping.write().await), vec); - this + let id_mapping = iter + .into_iter() + .map(|(kid, rkid)| (rkid, kid)) + .collect::<HashMap<_, _>>(); + Self { + next_id: AtomicU64::new( + (id_mapping.values()) + .map(|KId(id)| *id + 1) + .max() + .unwrap_or(1), + ), + id_mapping: RwLock::new(id_mapping), + ..Default::default() + } } - /// Get other possible ids from an id, we may want to do that to get all the local ids for a - /// remote id, to get a more up-to-date version of a kara. - pub(crate) async fn get_other_locals(&self, id: KId) -> Vec<KId> { - let mapping = self.id_mapping.read().await; - - let find_remote = |(kid, rkid): &_| id.eq(kid).then_some(rkid).cloned(); - let remote = mapping.iter().find_map(find_remote).expect("no remote id"); - - let find_locals = |(kid, rkid): &(KId, _)| remote.eq(rkid).then_some(kid.clone()); - mapping.iter().filter_map(find_locals).collect() + /// Get a new and unique [KId] + pub(crate) fn next_kid(&self) -> KId { + KId(self.next_id.fetch_add(1, Ordering::AcqRel)) } /// Get the tuple (local, remote) from the string representation of the remote id. Id the local /// id is not present, returns none, else some(local_id). pub(crate) async fn get_from_remote(&self, rkid: impl AsRef<str>) -> (Option<KId>, RemoteKId) { - let rkid = self.get_str::<RemoteKId>(rkid).await; - let id = (self.id_mapping.read().await.iter()) - .find_map(|(id, remote)| remote.eq(&rkid).then_some(id)) - .cloned(); - (id, rkid) - } - - /// Get a pointer to the string by its value. Used to reduce the amount of memory allocated for - /// string representation. The heuristic is that we can share the song's origins and the tag - /// keys and values. This is the sync version where we don't need async because we have a mut - /// reference to the pool (thus exclusive). - fn get_str_sync<I: sealed::Id>(&mut self, id: Arc<str>) -> I { - let mut hash = DefaultHasher::new(); - hash.write(id.as_ref().as_bytes()); - (self.string_cache.get_mut()) - .entry(hash.finish()) - .or_insert(id) - .clone() - .into() - } - - /// Get a pointer to the string by its value. Used to reduce the amount of memory allocated for - /// string representation. The heuristic is that we can share the song's origins and the tag - /// keys and values. - pub(crate) async fn get_str<I: sealed::Id>(&self, id: impl AsRef<str>) -> I { - let mut hash = DefaultHasher::new(); - hash.write(id.as_ref().as_bytes()); - let mut this = self.string_cache.write().await; - this.entry(hash.finish()) - .or_insert_with(|| Arc::<str>::from(id.as_ref())) - .clone() - .into() - } - - /// Same as [Pool::get_str], but don't create the id if not present to avoid being DOSed... - pub(crate) async fn try_get_str<I: sealed::Id>(&self, id: impl AsRef<str>) -> Option<I> { - let mut hash = DefaultHasher::new(); - hash.write(id.as_ref().as_bytes()); - let this = self.string_cache.write().await; - this.get(&hash.finish()).cloned().map(Into::into) - } - - /// Get the maximal id present in the pool. - pub(crate) async fn maximal_id(&self) -> u64 { - let list = self.id_mapping.read().await; - list.iter() - .map(|(id, _)| id.local_id()) - .max() - .unwrap_or_default() + let id = self.id_mapping.read().await.get(rkid.as_ref()).copied(); + (id, RemoteKId(rkid.as_ref().into())) } /// Fatcorize the [Arc<str>] for an [EpochData] to have pointer equality. We do that at the /// loading time, thus we can have a mut referenceto the pool and skip some locks. pub(crate) fn factorize_epoch_data(&mut self, data: &mut EpochData) { let content = std::mem::take(data).into_iter().map(|(kid, mut kara)| { - kara.id = self.get_str_sync(kara.id.0); - kara.remote = self.get_str_sync(kara.remote.0); kara.language = kara .language .into_iter() @@ -122,188 +78,20 @@ impl Pool { let values = values.into_iter().map(|v| self.get_str_sync(v)); (key, values.collect()) })); - (self.get_str_sync(kid.0), kara) + (kid, kara) }); let _ = std::mem::replace(data, EpochData::from_iter(content)); } - /// Fatcorize the [Arc<str>] for a [Playlist] to have pointer equality. We do that at the - /// loading time, thus we can have a mut referenceto the pool and skip some locks. - pub(crate) fn factorize_playlist(&mut self, plt: &mut Playlist) { - let content = std::mem::take(&mut plt.content) - .into_iter() - .map(|kid| self.get_str_sync(kid.0)); - let _ = std::mem::replace(&mut plt.content, content.collect()); - } -} - -/// The string must respect the following format, you can assert this: -/// "local_id:path" -/// Note that the path can't contains slashes! -#[derive(Serialize, Deserialize, Clone)] -pub struct KId(Arc<str>); - -impl KId { - /// Create a raw string representation of the [KId], unsafe operation. - /// - /// ### Safety - /// Don't use this function appart from reusing it in the pool to get the pointer from the - /// string cache. - pub(super) unsafe fn new_raw(id: u64, path: impl std::fmt::Display) -> String { - format!("{id}:{path}") - } - - /// Split the id in its different parts - fn split(&self) -> (&str, &str) { - self.0.split_once(':').expect("invalid kid found") - } - - /// Get the local path of a kara. The path is relative to the $prefix from the database - /// structure. - pub fn path(&self) -> &str { - self.split().1 - } - - /// Get the numeric local id of a kara. - pub fn local_id(&self) -> u64 { - self.split() - .0 - .parse::<u64>() - .expect("invalid id part in kid found") - } - - /// Get the string representaiton of the [KId]. - pub fn as_str(&self) -> &str { - self.0.as_ref() - } -} - -impl From<Arc<str>> for KId { - fn from(value: Arc<str>) -> Self { - Self(value) - } -} - -/// The string must respect the following format, you can assert this: -/// "remote_id@remote_name" -/// Note that the path can't contains slashes! -#[derive(Serialize, Deserialize, Clone)] -pub struct RemoteKId(Arc<str>); - -impl RemoteKId { - /// Create a raw string representation of the [RemoteKId], unsafe operation. - /// - /// ### Safety - /// Don't use this function appart from reusing it in the pool to get the pointer from the - /// string cache. - pub(super) unsafe fn new_raw(remote_id: u64, remote_name: impl std::fmt::Display) -> String { - format!("{remote_id}@{remote_name}") - } - - /// Split the id in its different parts - fn split(&self) -> (&str, &str) { - self.0.split_once('@').expect("invalid kid found") - } - - /// Get the name of the remote server - pub fn remote_name(&self) -> &str { - self.split().1 - } - - /// Get the id of the kara in the remote server - pub fn remote_id(&self) -> u64 { - self.split() - .0 - .parse::<u64>() - .expect("invalid id part in kid found") - } - - /// Get the string representaiton of the [RemoteKId]. - pub fn as_str(&self) -> &str { - self.0.as_ref() - } -} - -impl From<Arc<str>> for RemoteKId { - fn from(value: Arc<str>) -> Self { - Self(value) - } -} - -/// Protect the types that we cache to do pointer comparaison instead of string comparaisons. -mod sealed { - /// We can create the said id ([super::KId] or [super::RemoteKId] only) from the pointer to the - /// character slice. - pub trait Id: From<std::sync::Arc<str>> {} - impl Id for std::sync::Arc<str> {} - impl Id for super::KId {} - impl Id for super::RemoteKId {} -} - -// We implement equality and partial equality with pointer equals, we take into account that the -// strings where created correctly. With this trick we can implement hash just by hashing the -// address of the pointer, not the underlying string. - -impl Eq for KId {} -impl PartialEq for KId { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.0, &other.0) - } -} - -impl Eq for RemoteKId {} -impl PartialEq for RemoteKId { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.0, &other.0) - } -} - -impl std::hash::Hash for KId { - fn hash<H: Hasher>(&self, state: &mut H) { - std::ptr::hash(Arc::as_ptr(&self.0), state) - } -} - -impl std::hash::Hash for RemoteKId { - fn hash<H: Hasher>(&self, state: &mut H) { - std::ptr::hash(Arc::as_ptr(&self.0), state) - } -} - -// We need to display the ids for urls... - -impl std::fmt::Display for KId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.0.as_ref()) - } -} - -impl std::fmt::Display for RemoteKId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.0.as_ref()) - } -} - -impl std::fmt::Debug for KId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::fmt::Debug for RemoteKId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef<str> for KId { - fn as_ref(&self) -> &str { - self.as_str() + pub(crate) async fn get_str(&self, str: impl AsRef<str>) -> Arc<str> { + (self.strings.write().await) + .get_or_insert_with(str.as_ref(), |str| Arc::from(str)) + .clone() } -} -impl AsRef<str> for RemoteKId { - fn as_ref(&self) -> &str { - self.as_str() + fn get_str_sync(&mut self, str: impl AsRef<str>) -> Arc<str> { + (self.strings.get_mut()) + .get_or_insert_with(str.as_ref(), |str| Arc::from(str)) + .clone() } } diff --git a/lektor_nkdb/src/database/update.rs b/lektor_nkdb/src/database/update.rs index d5bdcb642669d5bcdbab005f9ad053bdc2f46203..80eafe081529c31583f77d8e83cdc3434e5f105c 100644 --- a/lektor_nkdb/src/database/update.rs +++ b/lektor_nkdb/src/database/update.rs @@ -1,16 +1,11 @@ //! The update handle is the only way to write into the database at runtime. -use crate::{database::*, storage::*}; +use crate::*; use anyhow::Result; use futures::future::join_all; use hashbrown::HashMap; use kurisu_api::SHA256; -use lektor_utils::pushvec::*; -use std::cell::RefCell; -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, -}; +use std::{cell::RefCell, sync::Arc}; /// A pool handle. Used to add new karas to the pool. The update logic follows the following: /// - if a kara was not present, we add it and we download the file. @@ -34,7 +29,6 @@ pub struct UpdateHandler<'a, Storage: DatabaseStorage> { storage: &'a Storage, new_epoch: RefCell<PushVecMutNode<Epoch>>, last_epoch: Option<&'a Epoch>, - last_kid: AtomicU64, } impl<'a, Storage: DatabaseStorage> UpdateHandler<'a, Storage> { @@ -50,7 +44,6 @@ impl<'a, Storage: DatabaseStorage> UpdateHandler<'a, Storage> { storage, new_epoch: RefCell::new(node), last_epoch: last, - last_kid: AtomicU64::new(pool.maximal_id().await + 1), } } @@ -78,12 +71,9 @@ impl<'a, Storage: DatabaseStorage> UpdateHandler<'a, Storage> { self.storage.submit_kara(file, id, hash).await } - /// Add a kara. Can error. On success, if the kara needs to be downloaded returns the couple of - /// IDs with the hash of the file. - /// - /// NOTE: if the file changed, then it's hash will be different, then its local [KId] will also - /// will be different, thus we will download automatically and not re-add the old thing. - async fn add_kara<'b>(&'b self, kara: Kara) -> Result<Option<(KId, RemoteKId, SHA256)>> + /// Add a kara. On success, if the kara needs to be downloaded returns the couple of IDs with + /// the hash of the file. + async fn add_kara<'b>(&'b self, kara: Kara) -> Option<(KId, RemoteKId, SHA256)> where 'b: 'a, { @@ -94,93 +84,93 @@ impl<'a, Storage: DatabaseStorage> UpdateHandler<'a, Storage> { .borrow_mut() .content() .data_mut() - .insert(kara.id.clone(), kara.clone()); - Ok(None) + .insert(kara.id, kara.clone()); + None }; let doit = |kara: Kara, new_epoch: &'a RefCell<PushVecMutNode<Epoch>>| async { let KaraStatus::Physical { hash, .. } = kara.kara_status else { log::warn!("tried to download a virtual kara: {kara}"); return reuse(kara, new_epoch); }; - let (kid, rkid) = (kara.id.clone(), kara.remote.clone()); + let (kid, rkid) = (kara.id, kara.remote.clone()); log::debug!("need to download kid {kid} / remote_kid {rkid}"); new_epoch .borrow_mut() .content() .data_mut() - .insert(kara.id.clone(), kara); - Ok(Some((kid, rkid, hash))) + .insert(kara.id, kara); + Some((kid, rkid, hash)) }; match self.last_epoch { None => doit(kara, &self.new_epoch).await, // No way the kara was here Some(last_epoch) => match last_epoch.data().get(&kara.id) { - Some(old_kara) if !kara.same_file_as(old_kara) => { - doit(kara, &self.new_epoch).await // The file has changed - } - // Not present last time None => doit(kara, &self.new_epoch).await, + // Present last time, but the file has changed. The KId is reused. + Some(old_kara) if !kara.same_file_as(old_kara) => doit(kara, &self.new_epoch).await, + // Present last time, but we use the new built kara because some informations might - // have been updated even if the epoch was not incremented. + // have been updated even if the epoch was not incremented. Note that the KId is + // reused here. Some(_) => reuse(kara, &self.new_epoch), }, } } - /// Add a kara from the V2 API. Can error. On success, if the kara needs to be downloaded - /// returns the couple of IDs with the sha256 hash of the file to download. + /// Add a kara from the V2 API. On success, if the kara needs to be downloaded returns the + /// couple of IDs with the sha256 hash of the file to download. pub async fn add_kara_v2<'b>( &'b self, repo: &str, kara: kurisu_api::v2::Kara, - ) -> Result<Option<(KId, RemoteKId, SHA256)>> + ) -> Option<(KId, RemoteKId, SHA256)> where 'b: 'a, { // Convert data from V2 API. Create a local ID if needed. - let (id, rkid) = (self.pool) - .get_from_remote(unsafe { RemoteKId::new_raw(kara.id, repo) }) + let (id, remote) = (self.pool) + .get_from_remote(RemoteKId::new(kara.id, repo)) .await; - let id = match id { - Some(id) => id, - None => unsafe { - self.pool - .get_str::<KId>(KId::new_raw( - self.last_kid.fetch_add(1, Ordering::SeqCst), - kara.file_hash, - )) - .await - }, - }; - let get_str = |str| self.pool.get_str::<Arc<str>>(str); let (language, kara_makers) = tokio::join!( - join_all(kara.language.into_iter().map(get_str)), - join_all(kara.kara_makers.into_iter().map(get_str)) + join_all((kara.language.into_iter()).map(|str| self.pool.get_str(str))), + join_all((kara.kara_makers.into_iter()).map(|str| self.pool.get_str(str))) ); let mut tags = HashMap::<Arc<str>, Vec<Arc<str>>>::default(); for [key, value] in kara.tags { if value.is_empty() { - tags.entry(get_str(key).await).or_insert_with(Vec::new); + tags.entry(self.pool.get_str(key).await) + .or_insert_with(Vec::new); } else { - let (key, value) = tokio::join!(get_str(key), get_str(value)); + let (key, value) = tokio::join!(self.pool.get_str(key), self.pool.get_str(value)); tags.entry(key).or_insert_with(Vec::new).push(value); } } - let kurisu_api::v2::Kara { - file_hash: hash, - filesize, - .. - } = kara; + let kara_status = match (kara.is_virtual, kara.file_hash) { + (true, None) => KaraStatus::Virtual, + (true, Some(hash)) => { + log::warn!("kara {remote} has virtual flag but a file hash: {hash}"); + KaraStatus::Virtual + } + (false, None) => { + log::error!("the kara {remote} is not virtual but don't have a file hash"); + KaraStatus::Virtual + } + (false, Some(hash)) => KaraStatus::Physical { + filesize: kara.filesize, + hash, + }, + }; self.add_kara(Kara { - id, + id: id.unwrap_or_else(|| self.pool.next_kid()), tags, - remote: rkid, + remote, + kara_status, song_type: kara.song_type, song_title: kara.song_title, song_source: kara.song_source, @@ -192,10 +182,6 @@ impl<'a, Storage: DatabaseStorage> UpdateHandler<'a, Storage> { updated_at: kara.updated_at, epoch: kara.epoch, }, - kara_status: match kara.is_virtual { - true => KaraStatus::Virtual, - false => KaraStatus::Physical { filesize, hash }, - }, }) .await } diff --git a/lektor_nkdb/src/id.rs b/lektor_nkdb/src/id.rs new file mode 100644 index 0000000000000000000000000000000000000000..0dea9e690ffd916fa0ca0d437dbb6b8af4171c37 --- /dev/null +++ b/lektor_nkdb/src/id.rs @@ -0,0 +1,97 @@ +//! Contains ID definitions used for kara and playlists. + +use derive_more::{Display, Into}; +use serde::{Deserialize, Serialize}; +use std::{borrow, num, str::FromStr, sync::Arc}; + +/// The string must respect the following format, you can assert this: +/// "local_id:path" +/// Note that the path can't contains slashes! +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, Hash, Debug, Into)] +#[repr(transparent)] +#[serde(transparent)] +#[display("{_0}")] +pub struct KId(pub(crate) u64); + +impl PartialEq<KId> for u64 { + fn eq(&self, other: &KId) -> bool { + other.0 == *self + } +} + +impl PartialEq<u64> for KId { + fn eq(&self, other: &u64) -> bool { + self.0 == *other + } +} + +impl FromStr for KId { + type Err = num::ParseIntError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + s.parse().map(Self) + } +} + +impl From<u64> for KId { + fn from(value: u64) -> Self { + Self(value) + } +} + +/// The string must respect the following format, you can assert this: +/// "remote_id@remote_name" +/// Note that the path can't contains slashes! +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Display, Debug)] +#[repr(transparent)] +#[serde(transparent)] +#[display("{_0}")] +pub struct RemoteKId(pub(crate) Arc<str>); + +impl RemoteKId { + /// Create a raw string representation of the [RemoteKId], unsafe operation. + pub(super) fn new(remote_id: u64, remote_name: impl std::fmt::Display) -> Self { + Self(format!("{remote_id}@{remote_name}").into()) + } + + /// Split the id in its different parts + fn split(&self) -> (&str, &str) { + self.0.split_once('@').expect("invalid kid found") + } + + /// Get the name of the remote server + pub fn remote_name(&self) -> &str { + self.split().1 + } + + /// Get the id of the kara in the remote server + pub fn remote_id(&self) -> u64 { + self.split() + .0 + .parse::<u64>() + .expect("invalid id part in kid found") + } + + /// Get the string representaiton of the [RemoteKId]. + pub fn as_str(&self) -> &str { + self.0.as_ref() + } +} + +impl borrow::Borrow<str> for RemoteKId { + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl From<Arc<str>> for RemoteKId { + fn from(value: Arc<str>) -> Self { + Self(value) + } +} + +impl AsRef<str> for RemoteKId { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/lektor_nkdb/src/lib.rs b/lektor_nkdb/src/lib.rs index e0d164a2d1058e3df7c84d76d11a9a335d2f6515..f9e9434155e5d178271d5902fadf59033f6e7c99 100644 --- a/lektor_nkdb/src/lib.rs +++ b/lektor_nkdb/src/lib.rs @@ -1,65 +1,43 @@ -//! A database to store informations about kara, by conserving old versions. +//! A new implementation of a database to store informations about karas, playlists and such. pub use crate::{ database::{ - KId, Kara, KaraStatus, KaraTimeStamps, RemoteKId, SongOrigin, SongType, UpdateHandler, + epoch::Epoch, + kara::{Kara, KaraStatus, KaraTimeStamps}, + update::UpdateHandler, }, - playlist::{Playlist, PlaylistInfo, PlaylistName}, - queue::{Priority, PRIORITY_LENGTH, PRIORITY_VALUES}, + id::{KId, RemoteKId}, + playlists::playlist::{Playlist, PlaylistInfo}, search::{KaraBy, SearchFrom}, - storage::*, + storage::{DatabaseDiskStorage, DatabaseStorage}, }; +pub use kurisu_api::v2::{SongOrigin, SongType}; -use crate::{database::*, queue::*, search::*}; -use anyhow::{anyhow, bail, Context, Result}; -use futures::{ - stream::{self, FuturesUnordered}, - StreamExt, +use crate::{ + database::{epoch::EpochData, pool::Pool}, + search::*, }; +use anyhow::{anyhow, Context as _, Result}; use hashbrown::HashMap; use lektor_utils::pushvec::*; -use playlist::Playlists; -use serde::{Deserialize, Serialize}; -use std::ops::{RangeBounds, RangeFull}; -use tokio::sync::RwLock; +use playlists::{Playlists, PlaylistsHandle}; mod database; -mod playlist; -mod queue; +mod id; +mod playlists; mod search; mod storage; +mod strings; /// The database type, we use interior mutability to handle the MT things. #[derive(Debug)] pub struct Database<Storage: DatabaseStorage = DatabaseDiskStorage> { pool: Pool, - queue: Queue, playlists: Playlists, - playstate: RwLock<PlayState>, epochs: PushVec<Epoch>, storage: Storage, } -/// Play state, stored in the database. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default)] -#[serde(rename_all = "lowercase")] -pub enum PlayState { - #[default] - Stop, - Play, - Pause, -} - -impl AsRef<str> for PlayState { - fn as_ref(&self) -> &str { - match self { - PlayState::Stop => "stopped", - PlayState::Play => "play", - PlayState::Pause => "paused", - } - } -} - impl<Storage: DatabaseStorage> Database<Storage> { /// Create a new database with the correspondig prefix. pub async fn new(prefix: impl Into<Storage::Prefix>) -> Result<Self> { @@ -72,37 +50,34 @@ impl<Storage: DatabaseStorage> Database<Storage> { let mut last_epoch = epochs .push(storage.read_last_epoch().await?.unwrap_or_default().into()) .await; - let mut pool = Pool::from_iter( - last_epoch - .content() - .ids() - .map(|(a, b)| (a.clone(), b.clone())), - ) - .await; + let mut pool = Pool::from_iter(last_epoch.content().ids()).await; log::info!("fatcorize content in the last_epoch and in the playlists"); - let mut playlists = storage - .read_playlists() - .await - .with_context(|| "failed to read playlists")?; pool.factorize_epoch_data(last_epoch.content().data_mut()); - playlists - .iter_mut() - .for_each(|(_, plt)| pool.factorize_playlist(plt)); log::info!("database loaded from disk"); last_epoch.finished(); + Ok(Self { + playlists: Playlists::new( + storage + .read_playlists() + .await + .with_context(|| "failed to read playlists")?, + ), pool, - queue: Default::default(), - playstate: Default::default(), - playlists: FromIterator::from_iter(playlists), epochs, storage, }) } + /// Get the playlist handle of the database. + pub fn playlists(&self) -> PlaylistsHandle<Storage> { + PlaylistsHandle::new(&self.playlists, &self.storage) + } + /// Get the update handler. + #[must_use] pub async fn update(&self) -> UpdateHandler<Storage> { UpdateHandler::new( &self.pool, @@ -113,429 +88,25 @@ impl<Storage: DatabaseStorage> Database<Storage> { .await } - /// Refresh the [KId]s of the playlists to use a most up-to-date version of a kara if - /// available. Should be called after a successfull update process. In case of failure we log - /// the error and do nothing else... - pub async fn refresh_playlist_contents(&self) { - let Some(epoch) = self.last_epoch().await else { - log::error!("no epoch in database, can't refresh playlists"); - return; - }; - for name in self.playlists.list_names().await { - log::info!("refresh ids for playlist {name}"); - let ids = stream::iter(self.playlists.get_content(&name).await.unwrap_or_default()) - .then(|id| async { - let locals = self.pool.get_other_locals(id).await; - locals.into_iter().filter(|id| epoch.contains(id)) - }) - .collect::<FuturesUnordered<_>>(); - self.playlists - .refresh(&name, ids.await.into_iter().flatten().collect::<Vec<KId>>()) - .await - .map_err(|err| log::error!("failed to refresh ids of playlist {name}: {err}")) - .unwrap_or_default(); - } - } - - /// Get the [KId] out of its string representation. We query the pool to guaranty the pointer - /// unicity for faster checks. - pub async fn get_kid_from_str(&self, kid: impl AsRef<str>) -> Option<KId> { - self.pool.try_get_str(kid).await - } - - /// Get a [Kara] by its [u64] representation in the last epoch. - pub async fn get_kara_by_id(&self, id: u64) -> Result<&Kara> { - self.last_epoch() - .await - .ok_or(anyhow!("empty epoch"))? - .get_kara_by_u64(id) - .ok_or(anyhow!("no kara {id}")) - } - /// Get a [Kara] by its [KId] representation in the last epoch. Should be more efficient than /// the [Self::get_kara_by_id] because we dirrectly use the hash thing and we don't iterate /// over all the items in the [HashMap]. pub async fn get_kara_by_kid(&self, id: KId) -> Result<&Kara> { - let local_id = id.local_id(); self.last_epoch() .await - .ok_or(anyhow!("empty epoch"))? + .context("empty epoch")? .get_kara_by_kid(id) - .ok_or(anyhow!("no kara {local_id}")) - } - - /// Search the database with a specific regex. - pub async fn search(&self, from: SearchFrom, regex: Vec<KaraBy>) -> Result<Vec<KId>> { - let regex = SearchBy::from_iter( - regex - .into_iter() - .map(SearchBy::new) - .collect::<Result<Vec<_>, _>>()?, - ); - let kids = match from { - SearchFrom::Playlist(plt) => self.playlists.get_content(plt).await.unwrap_or_default(), - SearchFrom::History => self.history(RangeFull).await, - SearchFrom::Queue => { - let queue = self.queue(RangeFull).await; - queue.into_iter().map(|(_, kid)| kid).collect() - } - SearchFrom::Database => match self.last_epoch().await { - Some(epoch) => epoch.ids().map(|(kid, _)| kid.clone()).collect(), - None => vec![], - }, - }; - let mut karas: Vec<KId> = match self.last_epoch().await { - Some(epoch) => epoch - .get_karas_by_kid(kids) - .into_iter() - .filter_map(|kara| regex.matches(kara).then_some(kara.id.clone())) - .collect(), - None => bail!("no epoch to search kara from"), - }; - let plts = regex.into_needed_playlists(); - if !plts.is_empty() { - let filter: Vec<_> = stream::iter(plts.into_iter()) - .then(|name| self.playlists.get_content(name)) - .collect::<FuturesUnordered<_>>() - .await - .into_iter() - .flatten() - .flatten() - .collect(); - karas.retain(|kara_id| filter.contains(kara_id)); - } - Ok(karas) - } - - /// Returns the kara count from the search set. - pub async fn count(&self, from: SearchFrom, regex: Vec<KaraBy>) -> usize { - log::error!("find a way to not allocate the search result buffer..."); - self.search(from, regex) - .await - .map(|vec| vec.len()) - .unwrap_or_default() + .with_context(|| format!("no kara {id}")) } /// Get the last epoch from the database. - pub(crate) async fn last_epoch(&self) -> Option<&Epoch> { + pub async fn last_epoch(&self) -> Option<&Epoch> { self.epochs.last().await } - /// Get the last epoch from the database. - pub async fn last_epoch_num(&self) -> Option<u64> { - self.epochs.last().await.map(|epoch| epoch.epoch_num()) - } - - /// Get the current kara with the play state. - /// - /// We have to do shenanigans. Because between karas we have an idle peridod and the playback - /// is stopped, we can't clear the current kara on each idle period because the next one could - /// have already be set in the current member. - pub async fn current(&self) -> (PlayState, Option<KId>) { - use PlayState::*; - let (current, playstate) = tokio::join!(self.queue.current(), self.playstate.read()); - match *playstate { - Stop => (Stop, None), - sta @ Play | sta @ Pause => (sta, current), - } - } - - /// Get the number of karas in each level of the queue. - pub async fn queue_count(&self) -> [usize; PRIORITY_LENGTH] { - self.queue.queue_count_per_level().await - } - - /// Get the number of karas in the history. - pub async fn history_count(&self) -> usize { - self.queue.history_count().await - } - - /// Get the play history from the most recent one to the most old one. - pub async fn history(&self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { - self.queue.history(range).await - } - - /// Remove elements from the history, from the most recent ones to the most old ones. - pub async fn history_delete(&self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { - self.queue.history_delete(range).await - } - - /// Get the play queue in the correct order. - pub async fn queue(&self, range: impl RangeBounds<usize> + Copy) -> Vec<(Priority, KId)> { - self.queue.queue(range).await - } - - /// Get the play queue in the correct order, only the specified priority level. - pub async fn queue_get_level(&self, prio: Priority) -> Vec<KId> { - self.queue.queue_get_level(prio).await - } - - /// Remove elements from the queue. - pub async fn queue_delete(&self, range: impl RangeBounds<usize> + Copy) { - self.queue.queue_delete(range).await; - } - - /// Remove an entire level from the queue. - pub async fn queue_delete_level(&self, prio: Priority) { - self.queue.queue_delete_level(prio).await; - } - - /// Shuffle the queue. - pub async fn queue_shuffle(&self, range: impl RangeBounds<usize> + Copy) { - self.queue.queue_shuffle(range).await - } - - /// Shuffle the queue. - pub async fn queue_shuffle_level(&self, level: Priority) { - self.queue.queue_shuffle_level(level).await - } - - /// Move a part of the queue after a kara. - pub async fn queue_move_after(&self, range: impl RangeBounds<usize> + Copy, after: usize) { - self.queue.queue_move_after(range, after).await - } - - /// Get the next kara to play. Update the queue and history. It is up to the player to play the - /// next file. - #[must_use] - pub async fn next(&self) -> Option<KId> { - self.queue.next().await - } - - /// Get the previous kara to play. Update the queue and history. It is up to the player to play - /// the previous file. - #[must_use] - pub async fn previous(&self) -> Option<KId> { - self.queue.previous().await - } - - /// Play from a position in the queue, remove all previous karas. If the position doesn't - /// exists then we returns [None]. - #[must_use] - pub async fn play_from_position(&self, position: usize) -> Option<KId> { - self.queue.play_from_position(position).await - } - - /// Set the play state. - pub async fn set_playstate(&self, new_state: PlayState) { - let mut state = self.playstate.write().await; - let _ = std::mem::replace(&mut *state, new_state); - } - - /// Toggle the play state. - pub async fn toggle_playstate(&self) { - let mut state = self.playstate.write().await; - let new_state = match *state { - PlayState::Stop | PlayState::Pause => PlayState::Play, - PlayState::Play => PlayState::Pause, - }; - let _ = std::mem::replace(&mut *state, new_state); - } - /// Get the path to a kara, try to get the absolute path, so here we append the relative path /// to the prefix of the database if possible. pub fn get_kara_uri(&self, id: KId) -> Result<url::Url> { self.storage.get_kara_uri(id) } - - /// Insert a kara in the queue. - pub async fn queue_insert(&self, prio: Priority, id: KId) { - self.queue.insert(prio, id).await - } - - /// Insert all karas in the queue in this order. - pub async fn queue_insert_all(&self, prio: Priority, ids: Vec<KId>) { - self.queue.insert_all(prio, ids).await - } - - /// Insert all karas in the queue in this order. - pub async fn queue_insert_slice(&self, prio: Priority, ids: &[KId]) { - self.queue.insert_slice(prio, ids).await - } - - /// Delete all karas from the queue with this id - pub async fn queue_delete_all(&self, id: KId) { - self.queue.queue_delete_all(id).await - } - - /// Delete all karas from the queue with this id - pub async fn queue_delete_all_from_level(&self, prio: Priority, id: KId) { - self.queue.queue_delete_all_from_level(prio, id).await - } - - /// Delete all karas from the queue with this id. The id was not already parsed, we try to - /// parse it here. - pub async fn queue_delete_all_maybe_id(&self, id: String) { - self.queue - .queue_delete_all(self.pool.get_str::<KId>(id).await) - .await - } - - /// Delete all karas from the history with this id - pub async fn history_delete_all(&self, id: KId) { - self.queue.history_delete_all(id).await - } - - /// Get a playlist by its name. - pub async fn playlist_get_content(&self, name: impl AsRef<str>) -> Option<Vec<KId>> { - self.playlists.get_content(name.as_ref()).await - } - - /// Get a list of all the playlists - pub async fn playlists(&self) -> Vec<(PlaylistName, PlaylistInfo)> { - self.playlists.list().await - } - - /// Rename a playlist. - pub async fn playlist_rename( - &self, - name: PlaylistName, - new_name: PlaylistName, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .rename(name.clone(), new_name.clone(), user, admin) - .await?; - self.playlists - .write_playlist_with(new_name, &self.storage) - .await?; - self.storage.delete_playlist(name).await?; - Ok(()) - } - - /// Give a playlist to another user. - pub async fn playlist_give_to( - &self, - name: PlaylistName, - new_user: impl ToString, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .give_to(name.clone(), new_user.to_string(), user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Create a new playlist. Returns whever the creation operation was successfull or not. - pub async fn playlist_new( - &self, - name: impl Into<PlaylistName>, - plt: impl Into<PlaylistInfo>, - ) -> Result<()> { - self.playlists.create(name, plt, &self.storage).await - } - - /// Delete a playlist. Returns whever the operation was successfull or not. - pub async fn playlist_delete( - &self, - name: impl Into<PlaylistName>, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - let name = name.into(); - self.storage.delete_playlist(name.as_ref()).await?; - self.playlists.delete(name, user, admin).await?; - Ok(()) - } - - /// Delete a kara from a playlist. - pub async fn playlist_delete_id( - &self, - name: impl AsRef<str>, - kid: KId, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .remove_id(name.as_ref(), kid, user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Delete a multiple karas from a playlist. - pub async fn playlist_delete_ids( - &self, - name: impl AsRef<str>, - kids: Vec<KId>, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .remove_ids(name.as_ref(), &kids, user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Add a kara to a playlist. - pub async fn playlist_add_id( - &self, - name: impl AsRef<str>, - kid: KId, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .add_id(name.as_ref(), kid, user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Add multiple karas to a playlist. - pub async fn playlist_add_ids( - &self, - name: impl AsRef<str>, - kids: Vec<KId>, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .add_ids(name.as_ref(), &kids, user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Add karas from a playlist to another playlist. - pub async fn playlist_add_playlist( - &self, - name: impl AsRef<str>, - from: impl AsRef<str>, - rand: bool, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .add_plt(name.as_ref(), from.as_ref(), rand, user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } - - /// Remove karas from a playlist from another playlist. - pub async fn playlist_delete_playlist( - &self, - name: impl AsRef<str>, - from: impl AsRef<str>, - user: impl AsRef<str>, - admin: bool, - ) -> Result<()> { - self.playlists - .remove_plt(name.as_ref(), from.as_ref(), user, admin) - .await?; - self.playlists - .write_playlist_with(name, &self.storage) - .await - } } diff --git a/lektor_nkdb/src/playlist/infos.rs b/lektor_nkdb/src/playlist/infos.rs deleted file mode 100644 index e08b0fc731dcef76f871e956bcf9b6d692a8573a..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/playlist/infos.rs +++ /dev/null @@ -1,64 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Informations about a playlist. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct PlaylistInfo { - /// The name of the user who created the playlist. If the user who created the playlist wasn't - /// identified, then it's an anonymous playlist. - pub user: Option<String>, - - /// Creation time of the playlist. - pub created_at: i64, - - /// Last update time of the playlist. - pub updated_at: i64, -} - -impl PlaylistInfo { - pub fn new() -> Self { - Self { - user: None, - created_at: chrono::Utc::now().timestamp(), - updated_at: chrono::Utc::now().timestamp(), - } - } - - pub fn user(self, user: impl ToString) -> Self { - Self { - user: Some(user.to_string()), - ..self - } - } - - pub fn created_at(self, created_at: i64) -> Self { - Self { created_at, ..self } - } - - pub fn updated_at(self, updated_at: i64) -> Self { - Self { updated_at, ..self } - } - - /// Should a user be authorized to modify a playlist? - pub fn authorize_write(&self, name: impl AsRef<str>, admin: bool) -> bool { - let name = name.as_ref(); - if admin { - log::warn!("bypass author on playlist request by admin {name}"); - } - let ret = admin - || (self.user.as_ref()) - .map(|user| user.eq(name)) - .unwrap_or_default(); - if ret { - log::info!("authorize {name} to edit playlist"); - } else { - log::info!("authorizen't {name} to edit playlist"); - } - ret - } -} - -impl Default for PlaylistInfo { - fn default() -> Self { - Self::new() - } -} diff --git a/lektor_nkdb/src/playlist/mod.rs b/lektor_nkdb/src/playlist/mod.rs deleted file mode 100644 index 1c8cd458ced67a3fafb87cb2abcfb38a1728df9b..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/playlist/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! How to store playlists. Because playlists and epochs don't have the same lifespan we must do -//! some shenanigans. - -mod infos; -mod name; -mod register; -mod values; - -pub use self::{infos::*, name::*, values::*}; -pub(crate) use register::*; diff --git a/lektor_nkdb/src/playlist/name.rs b/lektor_nkdb/src/playlist/name.rs deleted file mode 100644 index 640379927c26e555dcf1db2018246d36a1d57cff..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/playlist/name.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! We have some restrictions on playlists' name to have a usable thing... - -use anyhow::bail; -use serde::{Deserialize, Serialize}; -use std::{borrow::Borrow, str::FromStr, sync::Arc}; - -/// A playlist name, we excract it from the path. -/// -/// ### Safety -/// We check before builder the playlist name that the [`u8`] is a valide UTF-8 string. -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct PlaylistName(Arc<str>); - -impl FromStr for PlaylistName { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - if s.is_empty() || s.len() > Self::MAX_LEN { - bail!("invalid playlist name lengh of {}", s.len()) - } else if s.chars().filter(|c| !c.is_ascii_alphanumeric()).count() > 0 { - bail!("invalid playlist name: not ascii alphanumeric") - } else { - Ok(Self(s.into())) - } - } -} - -impl PlaylistName { - /// The maximal length of the playlist's name. - pub const MAX_LEN: usize = 128; - - pub fn as_str(&self) -> &str { - self.0.as_ref() - } -} - -impl AsRef<str> for PlaylistName { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl std::fmt::Display for PlaylistName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_ref()) - } -} - -impl std::fmt::Debug for PlaylistName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let name = self.as_str(); - f.debug_tuple("PlaylistName").field(&name).finish() - } -} - -impl Borrow<str> for PlaylistName { - fn borrow(&self) -> &str { - self.as_ref() - } -} - -impl Serialize for PlaylistName { - fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(self.as_ref()) - } -} - -impl<'de> Deserialize<'de> for PlaylistName { - fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { - use serde::de::Visitor; - struct PltNameVisitor; - impl<'de> Visitor<'de> for PltNameVisitor { - type Value = PlaylistName; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("string slice") - } - - fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> { - PlaylistName::from_str(&v).map_err(serde::de::Error::custom) - } - - fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> { - PlaylistName::from_str(v).map_err(serde::de::Error::custom) - } - - fn visit_borrowed_str<E: serde::de::Error>( - self, - v: &'de str, - ) -> Result<Self::Value, E> { - PlaylistName::from_str(v).map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_any(PltNameVisitor) - } -} - -#[test] -fn create_playlist_name() { - use lektor_utils::assert_ok; - assert_eq!( - assert_ok!(PlaylistName::from_str("foo42bar69")).as_ref(), - "foo42bar69" - ); -} - -#[test] -fn invalid_playlist_name() { - use lektor_utils::assert_err; - assert_err!(PlaylistName::from_str("")); - assert_err!(PlaylistName::from_str("Zażółć gęślÄ… jaźń")); - assert_err!(PlaylistName::from_str("@")); - assert_err!(PlaylistName::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); -} - -#[test] -fn playlist_name_eq() { - use lektor_utils::assert_ok; - use std::{ - hash::{DefaultHasher, Hash, Hasher}, - ptr, - }; - let a = assert_ok!(PlaylistName::from_str("abc")); - let b = assert_ok!(PlaylistName::from_str("abc")); - assert!(!ptr::addr_eq(a.0.as_ptr(), b.0.as_ptr())); - assert_eq!(a, b); - assert_eq!( - { - let mut s = DefaultHasher::new(); - a.hash(&mut s); - s.finish() - }, - { - let mut s = DefaultHasher::new(); - b.hash(&mut s); - s.finish() - }, - ); -} diff --git a/lektor_nkdb/src/playlist/register.rs b/lektor_nkdb/src/playlist/register.rs deleted file mode 100644 index 85664188391c33cd4acff05339c8844e94aa7dec..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/playlist/register.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! A list of playlists. It's different from an epoch because inside an epoch the playlists can -//! change. - -use crate::{DatabaseStorage, KId, Playlist, PlaylistInfo, PlaylistName}; -use anyhow::{anyhow, bail, Result}; -use hashbrown::{hash_map::OccupiedEntry, HashMap}; -use rand::{seq::SliceRandom, thread_rng}; -use std::ops::Deref; -use tokio::sync::RwLock; - -/// This type is just a wrapper around the [PlaylistsContent] with a [RwLock]. For function -/// documentation see [PlaylistsContent]. -#[derive(Debug)] -pub(crate) struct Playlists(RwLock<PlaylistsContent>); - -/// All the data from the playlists. -#[derive(Debug)] -struct PlaylistsContent(HashMap<PlaylistName, Playlist>); - -impl Playlists { - /// Write the playlist to a storage. - pub async fn write_playlist_with( - &self, - name: impl AsRef<str>, - storage: &impl DatabaseStorage, - ) -> Result<()> { - match self.0.read().await.0.get(name.as_ref()) { - Some(plt) => storage.write_playlist(name.as_ref(), plt).await?, - None => bail!("failed to get playlist {}", name.as_ref()), - } - Ok(()) - } - - /// Add a new playlist if it didn't already exists. Returns whever the creation operation was - /// successfull or not. - pub async fn create( - &self, - name: impl Into<PlaylistName>, - plt: impl Into<PlaylistInfo>, - storage: &impl DatabaseStorage, - ) -> Result<()> { - let name = name.into(); - let mut this = self.0.write().await; - if !this.0.contains_key(&name) { - let plt = Into::<PlaylistInfo>::into(plt).into(); - storage.write_playlist(&name, &plt).await?; - log::info!("create playlist {name}: {plt:#?}"); - this.0.insert(name, plt); - Ok(()) - } else { - bail!("can't create playlist {name} as it already exits") - } - } -} - -impl FromIterator<(PlaylistName, Playlist)> for PlaylistsContent { - fn from_iter<T: IntoIterator<Item = (PlaylistName, Playlist)>>(iter: T) -> Self { - Self(HashMap::from_iter(iter)) - } -} - -impl FromIterator<(PlaylistName, Playlist)> for Playlists { - fn from_iter<T: IntoIterator<Item = (PlaylistName, Playlist)>>(iter: T) -> Self { - Self(RwLock::new(FromIterator::from_iter(iter))) - } -} - -impl PlaylistsContent { - /// Get the playlist in write access if allowed. - fn write_plt_if_authorizes<T>( - &mut self, - name: impl AsRef<str>, - user: impl AsRef<str>, - admin: bool, - cb: impl FnOnce(&mut Playlist) -> Result<T>, - ) -> Result<T> { - let (name, user) = (name.as_ref(), user.as_ref()); - let Some(playlist) = self.0.get_mut(name) else { - bail!("playlist {name} asked by user {user} doesn't exist") - }; - if playlist.authorize_write(user, admin) { - cb(playlist) - } else { - bail!("user {user} is not authorized to write playlist {name}") - } - } - - /// Get the playlist in write access if allowed. Here we get the whole entry, not just the - /// value out of the hashmap. - fn write_plt_entry_if_authorizes<T>( - &mut self, - name: impl Into<PlaylistName>, - user: impl AsRef<str>, - admin: bool, - cb: impl FnOnce(OccupiedEntry<'_, PlaylistName, Playlist>) -> Result<T>, - ) -> Result<T> { - use hashbrown::hash_map::Entry; - let user = user.as_ref(); - match self.0.entry(name.into()) { - Entry::Occupied(entry) if entry.get().authorize_write(user, admin) => cb(entry), - Entry::Occupied(entry) => { - bail!( - "user {user} is not authorized to write playlist {}", - entry.key() - ) - } - Entry::Vacant(entry) => { - bail!("failed to get playlist {}", entry.key()) - } - } - } -} - -macro_rules! impl_playlists { - ($( - $(#[$doc:meta])* - $operation: ident fn $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? $body: block - )+) => { - impl Playlists { - $(impl_playlists! { - $(#[$doc])* - bridge: $operation $name (&$this $(, $arg: $ty)*) $(-> $ret)? - })+ - } - - impl PlaylistsContent { - $(impl_playlists! { - $(#[$doc])* - original: $operation $name (&$this $(, $arg: $ty)*) $(-> $ret)? { $body } - })+ - } - }; - - ( - $(#[$doc:meta])* - bridge: $operation: ident $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? - ) => { - #[allow(dead_code)] - $(#[$doc])* - pub async fn $name(&$this $(,$arg: $ty)*) $(-> $ret)? { - $this.0.$operation().await.$name($($arg),*) - } - }; - - ( - $(#[$doc:meta])* - original: read $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? { $body: block } - ) => { - $(#[$doc])* - pub fn $name(&$this $(,$arg: $ty)*) $(-> $ret)? { $body } - }; - - ( - $(#[$doc:meta])* - original: write $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? { $body: block } - ) => { - $(#[$doc])* - pub fn $name(&mut $this $(,$arg: $ty)*) $(-> $ret)? { $body } - }; -} - -impl_playlists! { - /// Get the total count of playlists - read fn count(&self) -> usize { - self.0.len() - } - - /// Get the list of all the playlists. - read fn list(&self) -> Vec<(PlaylistName, PlaylistInfo)> { - self.0.iter().map(|(name, plt)| (name.clone(), plt.deref().clone())).collect() - } - - /// Get the list of all the playlists. - read fn list_names(&self) -> Vec<PlaylistName> { - self.0.iter().map(|(name, _)| name.clone()).collect() - } - - /// Refresh some ids of a playlist with the new ones. Here we skip the user check thing as this - /// function should not be called by a user but as part of the update process. - write fn refresh(&self, name: impl AsRef<str>, ids: Vec<KId>) -> Result<()> { - let (mut ids, name) = (ids, name.as_ref()); - let plt = self.0.get_mut(name).ok_or_else(|| anyhow!("no playlist {name} to refresh"))?; - ids.retain(|id| !plt.content.contains(id)); - if ids.is_empty() { - log::warn!("no ids to refresh the playlist {name} with..."); - } else { - plt.content.append(&mut ids); - } - Ok(()) - } - - /// Rename a playlist. - write fn rename(&self, name: PlaylistName, new_name: PlaylistName, user: impl AsRef<str>, admin: bool) -> Result<()> { - if self.0.contains_key(&new_name) { - bail!("can't rename {name} to {new_name} because the playlist already exists") - } - let entry = match self.0.entry(name) { - hashbrown::hash_map::Entry::Occupied(entry) if entry.get().authorize_write(user.as_ref(), admin) => entry.remove(), - hashbrown::hash_map::Entry::Occupied(entry) => bail!("user {} not authorized to write {}", user.as_ref(), entry.key().as_str()), - hashbrown::hash_map::Entry::Vacant(entry) => bail!("failed to get playlist {}", entry.into_key()) - }; - match self.0.insert(new_name, entry) { - Some(_) => bail!("failed to rename the playlist"), - None => Ok(()), - } - } - - /// Give a playlist to another user. - write fn give_to(&self, name: PlaylistName, new_user: impl ToString, user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_entry_if_authorizes(name, user, admin, |mut entry| { - entry.get_mut().user = Some(new_user.to_string()); - Ok(()) - }) - } - - /// Deletes a playlist, returns whever the operation was successfull or not. - write fn delete(&self, name: PlaylistName, user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_entry_if_authorizes(name, user, admin, |entry| { - log::info!("remove playlist {}", entry.key().as_str()); - entry.remove(); - Ok(()) - }) - } - - /// Add a kara to a playlist. Returns true if it was successfull, false otherwise. - write fn add(&self, name: impl AsRef<str>, id: KId, user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_if_authorizes(name, user, admin, |playlist| { - playlist.content.push(id); - Ok(()) - }) - } - - /// Remove a kara from a playlist. Returns true if it was successfull, false otherwise. - write fn remove_id(&self, name: impl AsRef<str>, id: KId, user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_if_authorizes(name, user, admin, |playlist| { - playlist.content.retain(|kid| id.ne(kid)); - Ok(()) - }) - } - - /// Remove karas from a playlist. Returns true if it was successfull, false otherwise. - write fn remove_ids(&self, name: impl AsRef<str>, ids: &[KId], user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_if_authorizes(name, user, admin, |playlist| { - playlist.content.retain(|kid| !ids.contains(kid)); - Ok(()) - }) - } - - /// Remove a kara from a playlist. Returns true if it was successfull, false otherwise. - write fn add_id(&self, name: impl AsRef<str>, id: KId, user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_if_authorizes(name, user, admin, |playlist| { - playlist.extend(Some(id)); - Ok(()) - }) - } - - /// Remove karas from a playlist. Returns true if it was successfull, false otherwise. - write fn add_ids(&self, name: impl AsRef<str>, ids: &[KId], user: impl AsRef<str>, admin: bool) -> Result<()> { - self.write_plt_if_authorizes(name, user, admin, |playlist| { - playlist.extend(ids.iter().cloned()); - Ok(()) - }) - } - - /// Add the content of a playlist into another one. - write fn add_plt(&self, name: impl AsRef<str>, from: impl AsRef<str>, rand: bool, user: impl AsRef<str>, admin: bool) -> Result<()> { - let Some(mut ids) = self.get_content(&from) else { bail!("playlist {} doesn't exists", from.as_ref()) }; - if rand { ids[..].shuffle(&mut thread_rng()) } - self.add_ids(name, &ids, user, admin) - } - - /// Remove from a playlist the content of another one. - write fn remove_plt(&self, name: impl AsRef<str>, from: impl AsRef<str>, user: impl AsRef<str>, admin: bool) -> Result<()> { - let Some(ids) = self.get_content(&from) else { bail!("playlist {} doesn't exists", from.as_ref()) }; - self.remove_ids(name, &ids, user, admin) - } - - /// Get the content of a playlist, we need to clone because of the lock... - read fn get_content(&self, name: impl AsRef<str>) -> Option<Vec<KId>> { - let res = self.0.get(name.as_ref()).map(|plt| plt.content().to_vec()); - if res.is_none() { log::error!("playlist {} doesn't exist", name.as_ref()); } - res - } - - /// Count the number of elements in the playlist. If the playlist didn't exist returns [None], - /// otherwise returns [Some] of the count. - read fn count_playlist(&self, name: impl AsRef<str>) -> Option<usize> { - let res = self.0.get(name.as_ref()).map(|plt| plt.content().len()); - if res.is_none() { log::error!("playlist {} doesn't exist", name.as_ref()); } - res - } -} diff --git a/lektor_nkdb/src/playlist/values.rs b/lektor_nkdb/src/playlist/values.rs deleted file mode 100644 index 67611570f61e366e0a3937b607fc44708ddd1f11..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/playlist/values.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! A single playlist. - -use crate::{KId, PlaylistInfo}; -use serde::{Deserialize, Serialize}; - -/// The playlist, with the informations and its content. -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct Playlist { - /// Metadata about the playlist. - pub(crate) infos: PlaylistInfo, - - /// The content of a playlist. Here the [KId] are about all the epochs. When loading a new - /// epoch, if a new [KId] replaces an old one, then we also add it to the playlist. At loading - /// time we filter the [KId]s. - pub(crate) content: Vec<KId>, -} - -impl From<PlaylistInfo> for Playlist { - fn from(infos: PlaylistInfo) -> Self { - Self { - infos, - ..Default::default() - } - } -} - -impl std::ops::Deref for Playlist { - type Target = PlaylistInfo; - fn deref(&self) -> &Self::Target { - &self.infos - } -} - -impl std::ops::DerefMut for Playlist { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.infos - } -} - -impl Playlist { - pub fn content(&self) -> &[KId] { - &self.content - } -} - -impl Extend<KId> for Playlist { - fn extend<T: IntoIterator<Item = KId>>(&mut self, iter: T) { - for kid in iter.into_iter() { - if !self.content.contains(&kid) { - self.content.push(kid); - } - } - } -} diff --git a/lektor_nkdb/src/playlists/mod.rs b/lektor_nkdb/src/playlists/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..290c47d1bca7025daf0ad1e19d3c0c1ae37b45ba --- /dev/null +++ b/lektor_nkdb/src/playlists/mod.rs @@ -0,0 +1,112 @@ +//! Contains implementation about a collection of playlists. + +pub(crate) mod playlist; + +use crate::{playlists::playlist::Playlist, DatabaseStorage, KId}; +use anyhow::{bail, Context as _, Result}; +use hashbrown::{hash_map, HashMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::RwLock; + +/// This type is just a wrapper around the [PlaylistsContent] with a [RwLock]. For function +/// documentation see [PlaylistsContent]. +#[derive(Debug)] +pub(crate) struct Playlists { + content: RwLock<HashMap<KId, Playlist>>, + epoch: AtomicU64, +} + +pub struct PlaylistsHandle<'a, Storage: DatabaseStorage + Sized> { + playlists: &'a Playlists, + storage: &'a Storage, +} + +impl<'a, Storage: DatabaseStorage + Sized> PlaylistsHandle<'a, Storage> { + pub(crate) fn new(playlists: &'a Playlists, storage: &'a Storage) -> Self { + Self { playlists, storage } + } + + pub async fn epoch(&self) -> u64 { + self.playlists.epoch.load(Ordering::Acquire) + } + + pub async fn is_empty(&self) -> bool { + self.playlists.content.read().await.is_empty() + } + + pub async fn len(&self) -> usize { + self.playlists.content.read().await.len() + } + + pub async fn read<T>(&self, plt: KId, cb: impl FnOnce(&Playlist) -> T) -> Result<T> { + Ok(cb((self.playlists.content.read().await) + .get(&plt) + .context("playlist not found")?)) + } + + pub async fn list(&self) -> Vec<(KId, String)> { + (self.playlists.content.read().await.iter()) + .map(|(&id, playlist)| (id, playlist.name().to_string())) + .collect() + } + + pub async fn write<T>( + &self, + plt: KId, + user: impl AsRef<str>, + admin: bool, + cb: impl FnOnce(&mut Playlist) -> T, + ) -> Result<T> { + let mut this = self.playlists.content.write().await; + let plt = this + .get_mut(&plt) + .context("playlist not found")? + .authorize_writes(user, admin) + .context("user not allowed to modify playlist")?; + let res = cb(plt); + self.storage.write_playlist(plt).await?; + self.playlists.epoch.fetch_add(1, Ordering::AcqRel); + Ok(res) + } + + /// Add a new playlist if it didn't already exists. Returns whever the creation operation was + /// successfull or not. + pub async fn create( + &self, + name: impl ToString, + settings: impl FnOnce(Playlist) -> Playlist, + ) -> Result<()> { + let mut this = self.playlists.content.write().await; + let next_id = KId(this.keys().map(|KId(id)| id + 1).max().unwrap_or(1)); + let plt = settings(Playlist::new(next_id, name)).updated_now(); + self.playlists.epoch.fetch_add(1, Ordering::AcqRel); + self.storage.write_playlist(&plt).await?; + this.insert(next_id, plt); + Ok(()) + } + + pub async fn delete(&self, plt: KId, user: impl AsRef<str>, admin: bool) -> Result<()> { + match self.playlists.content.write().await.entry(plt) { + hash_map::Entry::Vacant(_) => bail!("playlist not found"), + hash_map::Entry::Occupied(mut entry) => { + (entry.get_mut()) + .authorize_writes(user, admin) + .context("user not allowed to modify the playlist")?; + entry.remove(); + if let Err(err) = self.storage.delete_playlist(plt).await { + log::error!("failed to delete playlist '{plt}': {err}") + } + } + } + Ok(()) + } +} + +impl Playlists { + pub(crate) fn new(iter: impl IntoIterator<Item = Playlist>) -> Self { + Self { + content: RwLock::new(iter.into_iter().map(|plt| (plt.local_id(), plt)).collect()), + epoch: AtomicU64::new(0), + } + } +} diff --git a/lektor_nkdb/src/playlists/playlist.rs b/lektor_nkdb/src/playlists/playlist.rs new file mode 100644 index 0000000000000000000000000000000000000000..784377506583fcf4f5e677a54435d89d15dc24e0 --- /dev/null +++ b/lektor_nkdb/src/playlists/playlist.rs @@ -0,0 +1,221 @@ +//! Contains implementation about a single playlist. + +use crate::{strings, KId, RemoteKId}; +use hashbrown::HashSet; +use rand::seq::SliceRandom as _; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Informations about a playlist. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Playlist { + /// The local ID. + local_id: KId, + + /// The remote ID. Is [None] if the playlist was not imported. + remote_id: Option<RemoteKId>, + + /// The name of the owners of the playlist. If the user who created the playlist wasn't + /// identified, then it's an anonymous playlist. The users can't be empty strings. + owners: HashSet<Arc<str>>, + + /// The name of the playlist. Note that this is not a primary key, multiple playlists can have + /// the same name... Must be only in ascii characters. + name: String, + + /// The description of the playlist. Must be only in ascii characters. If it is [Some], then + /// the [String] is not empty. + description: Option<String>, + + /// Creation time of the playlist. This is a local time, Kurisu doesn't have this notion. + created_at: i64, + + /// Last update time of the playlist. This is a local time, Kurisu doesn't have this notion. + updated_at: i64, + + /// The content of the playlist. + content: Vec<KId>, +} + +// Builder-lite interface +impl Playlist { + pub(crate) fn new(id: KId, name: impl ToString) -> Self { + Self { + name: name.to_string(), + local_id: id, + remote_id: None, + description: None, + owners: Default::default(), + content: Default::default(), + + // Be mindfull of the order… + created_at: chrono::Utc::now().timestamp(), + updated_at: chrono::Utc::now().timestamp(), + } + } + + pub fn with_owner_sync(mut self, user: impl AsRef<str>) -> Self { + self.owners.insert(strings::CACHE.get_sync(user.as_ref())); + self.updated_now() + } + + pub async fn with_owner(mut self, user: impl AsRef<str>) -> Self { + self.owners.insert(strings::CACHE.get(user.as_ref()).await); + self.updated_now() + } + + pub fn with_description(mut self, desc: impl ToString) -> Self { + let description = desc.to_string(); + self.description = (!description.is_empty()).then_some(description); + self.updated_now() + } + + pub(super) fn updated_now(mut self) -> Self { + self.updated_at = chrono::Utc::now().timestamp(); + self + } +} + +// Setters +impl Playlist { + pub fn set_name(&mut self, name: impl ToString) -> &mut Self { + self.name = name.to_string(); + self + } + + pub fn add_owner(&mut self, name: impl AsRef<str>) -> &mut Self { + self.owners.insert(Arc::from(name.as_ref())); + self + } + + pub fn remove_owner(&mut self, name: impl AsRef<str>) -> &mut Self { + self.owners.remove(name.as_ref()); + self + } + + pub(crate) fn authorize_writes( + &mut self, + user: impl AsRef<str>, + admin: bool, + ) -> Option<&mut Self> { + (admin || self.owners.contains(user.as_ref())).then_some(self) + } + + pub fn push(&mut self, id: KId) { + self.content.push(id) + } + + pub fn remove(&mut self, id: KId) { + self.retain(|other| *other != id) + } + + pub fn retain(&mut self, cb: impl FnMut(&KId) -> bool) { + self.content.retain(cb) + } + + pub fn append(&mut self, ids: &mut Vec<KId>) { + self.content.append(ids) + } + + pub fn shuffle(&mut self) { + self.content.shuffle(&mut rand::thread_rng()) + } +} + +// Getters +impl Playlist { + pub fn local_id(&self) -> KId { + self.local_id + } + + pub fn contains(&self, id: KId) -> bool { + self.content.contains(&id) + } + + pub fn remote_id(&self) -> Option<RemoteKId> { + self.remote_id.clone() + } + + pub fn owners(&self) -> impl Iterator<Item = &str> { + self.owners.iter().map(Arc::as_ref) + } + + pub fn name(&self) -> &str { + self.name.as_ref() + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn created_at(&self) -> chrono::DateTime<chrono::Utc> { + chrono::DateTime::from_timestamp(self.created_at, 0).unwrap_or_default() + } + + pub fn updated_at(&self) -> chrono::DateTime<chrono::Utc> { + chrono::DateTime::from_timestamp(self.updated_at, 0).unwrap_or_default() + } + + pub fn iter_seq_content(&self) -> impl Iterator<Item = KId> + '_ { + self.content.iter().copied() + } + + pub fn iter_uniq_content(&self) -> impl Iterator<Item = KId> + '_ { + HashSet::<KId>::from_iter(self.content.iter().copied()).into_iter() + } + + pub fn get_infos(&self) -> PlaylistInfo { + PlaylistInfo { + local_id: self.local_id, + owners: self.owners.clone(), + name: self.name.clone(), + description: self.description.clone(), + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +impl Extend<KId> for Playlist { + fn extend<T: IntoIterator<Item = KId>>(&mut self, iter: T) { + self.content.extend(iter) + } +} + +/// Informations relative to a playlist. We don't show everything here. For fields signification, +/// see the ones in [Playlist]. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct PlaylistInfo { + local_id: KId, + owners: HashSet<Arc<str>>, + name: String, + description: Option<String>, + created_at: i64, + updated_at: i64, +} + +impl PlaylistInfo { + pub fn local_id(&self) -> KId { + self.local_id + } + + pub fn owners(&self) -> impl Iterator<Item = &str> { + self.owners.iter().map(Arc::as_ref) + } + + pub fn name(&self) -> &str { + self.name.as_ref() + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn created_at(&self) -> chrono::DateTime<chrono::Utc> { + chrono::DateTime::from_timestamp(self.created_at, 0).unwrap_or_default() + } + + pub fn updated_at(&self) -> chrono::DateTime<chrono::Utc> { + chrono::DateTime::from_timestamp(self.updated_at, 0).unwrap_or_default() + } +} diff --git a/lektor_nkdb/src/queue/mod.rs b/lektor_nkdb/src/queue/mod.rs deleted file mode 100644 index 4ae27535b78c7f8fd4a7293e92cab7c8f9bc86ad..0000000000000000000000000000000000000000 --- a/lektor_nkdb/src/queue/mod.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! Store the state of the queue in memory. For now we don't store it on disk, see latter how we do -//! that thing. - -mod priority; - -pub use priority::*; - -use crate::*; -use lektor_utils::{filter_range, BoundedBoundRange}; -use rand::{seq::SliceRandom, thread_rng}; -use std::ops::RangeInclusive; -use tokio::sync::RwLock; - -/// The queue contains the playing kara and the following ones. This type is just a wrapper around -/// the [QueueContent] with a [RwLock]. For function documentation see [QueueContent]. -#[derive(Debug, Default)] -pub(crate) struct Queue(RwLock<QueueContent>); - -/// The content of the queue. Is protected by a RwLock from tokio. -#[derive(Debug, Default)] -struct QueueContent { - /// The current kara - current: Option<KId>, - - /// The queue, stored as a priority list, stored in reverse order from the priority. - queue: [Vec<KId>; PRIORITY_LENGTH], - - /// The history, we push back into it, so the most recent one is the one at the end of the - /// vector. - history: Vec<KId>, -} - -macro_rules! impl_queue { - ($( - $(#[$doc:meta])* - $operation: ident fn $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? $body: block - )+) => { - impl Queue { - $(impl_queue! { - $(#[$doc])* - bridge: $operation $name (&$this $(, $arg: $ty)*) $(-> $ret)? - })+ - } - - impl QueueContent { - $(impl_queue! { - $(#[$doc])* - original: $operation $name (&$this $(, $arg: $ty)*) $(-> $ret)? { $body } - })+ - } - }; - - ( - $(#[$doc:meta])* - bridge: $operation: ident $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? - ) => { - #[allow(dead_code)] - $(#[$doc])* - pub async fn $name(&$this $(,$arg: $ty)*) $(-> $ret)? { - $this.0.$operation().await.$name($($arg),*) - } - }; - - ( - $(#[$doc:meta])* - original: read $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? { $body: block } - ) => { - $(#[$doc])* - pub fn $name(&$this $(,$arg: $ty)*) $(-> $ret)? { $body } - }; - - ( - $(#[$doc:meta])* - original: write $name: ident (&$this: ident $(, $arg: ident: $ty: ty)*) $(-> $ret: ty)? { $body: block } - ) => { - $(#[$doc])* - pub fn $name(&mut $this $(,$arg: $ty)*) $(-> $ret)? { $body } - }; -} - -impl_queue! { - /// Get the current kara if it exists. - read fn current(&self) -> Option<KId> { - self.current.clone() - } - - read fn split_range_per_queue_level(&self, range: impl RangeBounds<usize>) -> Vec<(Priority, RangeInclusive<usize>)> { - let (mut start, mut end) = (match range.start_bound() { - std::ops::Bound::Included(pos) => *pos, - std::ops::Bound::Excluded(pos) => pos + 1, - std::ops::Bound::Unbounded => 0, - }, match range.end_bound() { - std::ops::Bound::Included(pos) => *pos, - std::ops::Bound::Excluded(pos) => pos - 1, - std::ops::Bound::Unbounded => self.queue_count(), - }); - self.queue.iter().enumerate().rev().flat_map(|(idx, lvl)| { - let ret = (start <= lvl.len()).then(|| ( - Priority::from(idx + 1), - start..=(lvl.len() - 1).min(end) - )); - (start, end) = (start - lvl.len(), end - lvl.len()); - ret - }).collect() - } - - /// Clear the queue up to the position specified, returning it into the current kara. - write fn play_from_position(&self, position: usize) -> Option<KId> { - (position < self.queue_count()).then(|| { - self.split_range_per_queue_level(0..=position).into_iter().fold(None, |_, (prio, range)| { - self.queue[prio.index()].drain(range).last() - }) - }) - .flatten() - } - - /// Shuffle the elemenst in the queue, the kara won't change priorities. - write fn queue_shuffle(&self, range: impl RangeBounds<usize> + Copy) { - self.split_range_per_queue_level(range).into_iter().for_each(|(prio, range)| { - self.queue[prio.index()][range] - .shuffle(&mut thread_rng()); - }); - } - - /// Shuffle the elemenst in the level of the queue. - write fn queue_shuffle_level(&self, level: Priority) { - self.queue[level.index()] - .shuffle(&mut thread_rng()); - } - - /// Get the number of element in the range. Handles the out of bound stuff. - read fn queue_range_count(&self, range: impl RangeBounds<usize> + Copy) -> usize { - let (start, end) = (match range.start_bound() { - std::ops::Bound::Included(pos) => *pos, - std::ops::Bound::Excluded(pos) => pos + 1, - std::ops::Bound::Unbounded => 0, - }, match range.end_bound() { - std::ops::Bound::Included(pos) => *pos, - std::ops::Bound::Excluded(pos) => pos - 1, - std::ops::Bound::Unbounded => self.queue_count(), - }); - end - start - } - - /// Move a kara after a position. - write fn queue_move_after(&self, range: impl RangeBounds<usize> + Copy, after: usize) { - if !range.contains(&after) { - let start = match range.start_bound() { - std::ops::Bound::Included(start) => *start, - std::ops::Bound::Excluded(start) => start - 1, - std::ops::Bound::Unbounded => 0, - }; - let mut after = if after < start { after } else { after - start }; - let count = self.queue_range_count(range); - let ids = self.queue_delete_get(range).into_iter().map(|(_, id)| id); - for lvl in self.queue.iter_mut().rev() { - if after > lvl.len() { - after -= lvl.len(); - continue; - } else { - lvl.reserve(count); - ids.rev().for_each(|id| lvl.insert(after, id)); - break; - } - } - } else { - log::error!("can't move a range after a position included in it"); - } - } - - /// Remove elements from a level of the queue. - write fn queue_delete_level(&self, level: Priority) { - self.queue[level.index()].clear() - } - - /// Remove elements from the queue by their position. - write fn queue_delete(&self, range: impl RangeBounds<usize> + Copy) { - self.split_range_per_queue_level(range).into_iter().for_each(|(prio, range)| { - self.queue[prio.index()].drain(range); - }) - } - - /// Remove elements from the queue by their position and returns what was deleted. - write fn queue_delete_get(&self, range: impl RangeBounds<usize> + Copy) -> Vec<(Priority, KId)> { - self.split_range_per_queue_level(range).into_iter().flat_map(|(prio, range)| { - self.queue[prio.index()] - .drain(range) - .map(move |id| (prio, id)) - .collect::<Vec<_>>() - }).collect() - } - - /// Get the content of the level of the queue. - read fn queue_get_level(&self, prio: Priority) -> Vec<KId> { - self.queue[prio.index()].clone() - } - - /// Iterate over the queue. - read fn queue(&self, range: impl RangeBounds<usize> + Copy) -> Vec<(Priority, KId)> { - let iter = self.queue.iter().enumerate().map(|(idx, list)| { - list.iter().map(move |id| (Priority::from(idx + 1), id.clone())) - }).rev().flatten(); - filter_range(iter, range).collect::<Vec<_>>() - } - - /// Returns the number of karas present in the queue. - read fn queue_count(&self) -> usize { - self.queue.iter().map(|lvl| lvl.len()).sum() - } - - /// Returns the number of elements in each level of the queue - read fn queue_count_per_level(&self) -> [usize; PRIORITY_LENGTH] { - [ self.queue[Priority::Add .index()].len() - , self.queue[Priority::Suggest.index()].len() - , self.queue[Priority::Insert .index()].len() - , self.queue[Priority::Enforce.index()].len() - ] - } - - /// Insert something into one priority of the queue. - write fn insert(&self, prio: Priority, kara: KId) { - self.queue[prio.index()].insert(0, kara); - } - - /// Insert something into one priority of the queue. - write fn insert_slice(&self, prio: Priority, kara: &[KId]) { - let level = &mut self.queue[prio.index()]; - level.reserve(kara.len()); - kara.iter().rev().for_each(|id| level.insert(0, id.clone())); - } - - /// Insert something into one priority of the queue. - write fn insert_all(&self, prio: Priority, kara: Vec<KId>) { - let level = &mut self.queue[prio.index()]; - level.reserve(kara.len()); - kara.into_iter().rev().for_each(|id| level.insert(0, id.clone())); - } - - /// Returns the number of karas present in the history. - read fn history_count(&self) -> usize { - self.history.len() - } - - /// Remove elements from the history by their position. - write fn history_delete(&self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { - use std::ops::Bound::{*, self}; - let count = self.history_count(); - let inverse = |bound: Bound<&usize>| match bound { - Included(idx) => Included(count - idx - 1), - Excluded(idx) => Excluded(count - idx - 1), - Unbounded => Unbounded, - }; - let range = BoundedBoundRange::from(( - inverse(range.start_bound()), - inverse(range.end_bound()) - )); - self.history.drain(range).collect() - } - - /// Iterate over the history. We iterate from the most recent one to the most old one. - read fn history(&self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { - filter_range(self.history.iter().rev(), range).cloned().collect() - } - - /// Get the next kara to play from the queue and insert the old-playing one into the history. - /// The returned kara is the new current one. - write fn next(&self) -> Option<KId> { - let mut iter = self.queue.iter_mut().rev(); - let current = iter.find_map(|lvl| (!lvl.is_empty()).then(|| lvl.remove(0)))?; - if let Some(current) = self.current.replace(current.clone()) { - self.history.push(current); - } - Some(current) - } - - /// Get the previous kara to play from the history and insert the old-playing one into the - /// queue at the same priority as the next one. The returned kara is the new current one. If - /// the queue is empty we add as the default priority. - write fn previous(&self) -> Option<KId> { - let current = self.history.pop()?; - if let Some(current) = self.current.replace(current.clone()) { - match self.queue.iter_mut().rev().find(|lvl| !lvl.is_empty()) { - Some(lvl) => lvl.insert(0, current), - None => self.insert(Default::default(), current), - } - } - Some(current) - } - - /// Delete all karas by and their id in the queue - write fn queue_delete_all(&self, id: KId) { - self.queue.iter_mut().for_each(|lvl| lvl.retain(|kid| id.ne(kid))) - } - - /// Delete all karas by and their id from a level in the queue - write fn queue_delete_all_from_level(&self, prio: Priority, id: KId) { - self.queue[prio.index()].retain(|kid| id.ne(kid)) - } - - /// Delete all karas by and their id in the history - write fn history_delete_all(&self, id: KId) { - self.history.retain(|kid| id.ne(kid)) - } -} diff --git a/lektor_nkdb/src/search/mod.rs b/lektor_nkdb/src/search/mod.rs index d93ecd576c0ea0b18ca61d767b188e76ef5114e1..6c7b416bc9477c4c5d9e35cef8603e139bab7797 100644 --- a/lektor_nkdb/src/search/mod.rs +++ b/lektor_nkdb/src/search/mod.rs @@ -5,7 +5,7 @@ mod kara_by; pub use kara_by::*; -use crate::{playlist::PlaylistName, Kara}; +use crate::{KId, Kara}; use anyhow::Result; use kurisu_api::v2::{SongOrigin, SongType}; use regex::Regex; @@ -17,7 +17,7 @@ pub enum SearchFrom { Queue, Database, History, - Playlist(PlaylistName), + Playlist(KId), } /// Structure used to tell how to do the search, either by a regex, or by applying another way @@ -62,7 +62,7 @@ impl SearchBy { pub(crate) fn matches(&self, kara: &Kara) -> bool { match &self { SearchBy::Query(regex) => regex.is_match(&kara.to_title_string()), - SearchBy::Id(id) => kara.id.local_id().eq(id), + SearchBy::Id(id) => kara.id == *id, SearchBy::SongType(ty) => kara.song_type.eq(ty), SearchBy::SongOrigin(ori) => kara.song_origin.eq(ori), SearchBy::Author(author) => kara.kara_makers.contains(author.as_str()), diff --git a/lektor_nkdb/src/storage/disk_storage.rs b/lektor_nkdb/src/storage/disk_storage.rs index 85543be1ed98ee0d520645803f5a066338f0c041..7917e517cdb23ad14248eb5297bf3add6e2e3792 100644 --- a/lektor_nkdb/src/storage/disk_storage.rs +++ b/lektor_nkdb/src/storage/disk_storage.rs @@ -1,7 +1,7 @@ //! We store things in the disk. use crate::*; -use anyhow::{bail, Context, Result}; +use anyhow::{bail, ensure, Result}; use futures::{ stream::{self, FuturesUnordered}, StreamExt, @@ -11,7 +11,8 @@ use kurisu_api::SHA256; use regex::Regex; use std::{ path::{Path, PathBuf}, - sync::{atomic::AtomicU64, Arc, LazyLock}, + str::FromStr, + sync::{atomic::AtomicU64, LazyLock}, }; use tokio::{ fs::{create_dir_all, read, read_dir, write, OpenOptions}, @@ -42,8 +43,8 @@ pub struct DatabaseDiskStorage { } enum PlaylistWriteEvent { - Write(Arc<str>, Playlist), - Delete(Arc<str>), + Write(KId, Playlist), + Delete(KId), } macro_rules! regex { @@ -52,26 +53,16 @@ macro_rules! regex { }; } -fn get_regex_epoch_ok() -> &'static Regex { +fn get_regex_ok() -> &'static Regex { static REGEX: LazyLock<Regex> = regex!(r"^([0123456789]+).ok$"); ®EX } -fn get_regex_epoch_json() -> &'static Regex { +fn get_regex_json() -> &'static Regex { static REGEX: LazyLock<Regex> = regex!(r"^([0123456789]+).json$"); ®EX } -fn get_regex_playlist_ok() -> &'static Regex { - static REGEX: LazyLock<Regex> = regex!(r"^([a-zA-Z0123456789]+).ok$"); - ®EX -} - -fn get_regex_playlist_json() -> &'static Regex { - static REGEX: LazyLock<Regex> = regex!(r"^([a-zA-Z0123456789]+).json$"); - ®EX -} - impl DatabaseDiskStorage { // Get a path from the root. Note that it's not really safe as something can be crafter to add // `/../` strings to escape the root... @@ -88,7 +79,7 @@ impl DatabaseDiskStorage { /// Create a new kara file with a specific extension. If a file with the same path exists it /// will be overwritten. async fn new_kara_file(&self, id: KId) -> Result<(PathBuf, tokio::fs::File)> { - let path = self.path_from_root(format!("data/{}.mkv", id.path())); + let path = self.path_from_root(format!("data/{id}.mkv")); if path.exists() { log::warn!("overwriting file {}", path.to_string_lossy()) } @@ -189,29 +180,25 @@ impl DatabaseDiskStorage { async fn pool_playlist_write_events(prefix: PathBuf, mut recv: Receiver<PlaylistWriteEvent>) { let prefix = prefix.as_path(); let write = |name, plt| async move { - let data = match serde_json::to_string(&plt) { - Ok(data) => data, - Err(err) => { - log::error!("failed to serialize playlist {name} to write it to disk: {err:?}"); - return; + match serde_json::to_string(&plt) { + Ok(data) => { + if let Err(err) = Self::write_json_from_root(prefix, &name, data).await { + log::error!("can't write playlist {name}: {err:?}") + } } + Err(e) => log::error!("failed to write playlist {name} to disk: {e:?}"), }; - if let Err(err) = Self::write_json_from_root(prefix, &name, data).await { - log::error!("can't write playlist {name}: {err:?}") - } }; - let delete = |name: Arc<str>| async move { - let mut path = prefix.join(name.as_ref()); - path.set_extension("json"); - if let Err(err) = tokio::fs::remove_file(path).await { - log::error!("failed to delete playlist {name}: {err:?}") + let delete = |id: KId| async move { + if let Err(err) = tokio::fs::remove_file(prefix.join(format!("{id}.json"))).await { + log::error!("failed to delete playlist {id}: {err:?}") } }; while let Some(event) = recv.recv().await { match event { - PlaylistWriteEvent::Write(name, plt) => write(name, plt).await, - PlaylistWriteEvent::Delete(name) => delete(name).await, + PlaylistWriteEvent::Write(id, plt) => write(id, plt).await, + PlaylistWriteEvent::Delete(id) => delete(id).await, } } } @@ -241,10 +228,8 @@ macro_rules! read_json_folder { continue; }; match what.as_str().parse() { - Err(err) => anyhow::bail!("invalid regex, problem with file: {name}: {err}"), - Ok(name) => { - sets[i].insert(name); - } + Err(err) => bail!("invalid regex, problem with file: {name}: {err}"), + Ok(name) => _ = sets[i].insert(name), } } } @@ -256,27 +241,24 @@ macro_rules! read_json_folder { }; } -#[async_trait::async_trait] impl DatabaseStorage for DatabaseDiskStorage { type Prefix = PathBuf; type File = (PathBuf, tokio::fs::File); async fn load_from_prefix(prefix: PathBuf) -> Result<Self> { - let regex = get_regex_epoch_ok(); + let regex = get_regex_ok(); // Prepare folders. for folder in ["data", "epoch", "playlists"] { let folder = prefix.join(folder); match create_dir_all(&folder).await { Err(err) if err.kind() != std::io::ErrorKind::AlreadyExists => { - return Err(err).with_context(|| { - format!("failed to create folder {}", folder.to_string_lossy()) - }) + return Err(err) + .with_context(|| format!("failed to create folder {}", folder.display())) } _ => {} } - let file = folder.join(".touch"); - write(&file, chrono::Local::now().to_string()).await?; + write(folder.join(".touch"), chrono::Local::now().to_string()).await?; } // We find the maximal epoch number for this computer, to be sure to not override any @@ -319,26 +301,29 @@ impl DatabaseStorage for DatabaseDiskStorage { async fn read_last_epoch(&self) -> Result<Option<(EpochData, u64)>> { read_json_folder! { - (self) "epoch" [get_regex_epoch_ok(), get_regex_epoch_json()] => u64; + (self) "epoch" [get_regex_ok(), get_regex_json()] => u64; valid_epochs => { match valid_epochs.into_iter().max() { - None => { - log::warn!("no epoch to load, new install?"); - Ok(None) - } + None => Ok(None), Some(id) => { let path = self.path_from_root(format!("epoch/{id}.json")); let mut data = serde_json::from_slice::<EpochData>(&read(&path).await?)?; - stream::iter(data.keys()).then(|id| async move { - let hash = id.path(); - let path = self.path_from_root(format!("data/{hash}.mkv")); - Ok::<_, anyhow::Error>(sha256::try_async_digest(&path).await?.ne(hash).then_some(id.clone())) + stream::iter(data.iter()).then(|(id, kara): (&KId, &Kara)| async move { + let KaraStatus::Physical { hash, .. } = &kara.kara_status else { + return None; + }; + match sha256::try_async_digest(self.path_from_root(format!("data/{id}.mkv"))).await + .context("failed to digest file") + .map(|str| SHA256::from_str(&str).map_err(|err| anyhow!("{err}"))) + { + Ok(Ok(file_hash)) => (file_hash != *hash).then_some(*id), + Ok(Err(err)) | Err(err) => { + log::error!("kara with id {id} is corrupted: {err}"); + Some(*id) + } + } }) - .collect::<FuturesUnordered<_>>().await.into_iter() - .filter_map(|res| res.map_err(|err| log::error!("{err}")).ok()).flatten() - .for_each(|kid| { - log::warn!("kara {kid} from epoch {id} is corrupted, remove it from epoch"); - data.remove(&kid); - }); + .collect::<FuturesUnordered<_>>().await + .into_iter().flatten().for_each(|kid: KId| { data.remove(&kid); }); log::info!("load epoch {id} with {} karas from path: {}", data.len(), path.to_string_lossy()); Ok(Some((data, *id))) } @@ -346,15 +331,16 @@ impl DatabaseStorage for DatabaseDiskStorage { } } - async fn read_playlists(&self) -> Result<Vec<(PlaylistName, Playlist)>> { + async fn read_playlists(&self) -> Result<Vec<Playlist>> { read_json_folder! { - (self) "playlists" [get_regex_playlist_ok(), get_regex_playlist_json()] => PlaylistName; + (self) "playlists" [get_regex_ok(), get_regex_json()] => KId; valid_playlists => { - stream::iter(valid_playlists).then(|name: &PlaylistName| async move { - let path = self.path_from_root(format!("playlists/{name}.json")); + stream::iter(valid_playlists).then(|id: &KId| async move { + let path = self.path_from_root(format!("playlists/{id}.json")); log::info!("load playlist from path: {}", path.to_string_lossy()); let json: Playlist = serde_json::from_slice(&read(path).await?)?; - Ok((name.clone(), json)) + ensure!(json.local_id() == *id, ""); + Ok(json) }) .collect::<FuturesUnordered<_>>().await .into_iter().collect::<Result<Vec<_>, _>>() @@ -370,16 +356,21 @@ impl DatabaseStorage for DatabaseDiskStorage { Ok(()) } - async fn write_playlist(&self, name: impl AsRef<str> + Send, data: &Playlist) -> Result<()> { - let event = PlaylistWriteEvent::Write(name.as_ref().into(), data.clone()); - self.playlist_pipeline.send(event).await?; - Ok(()) + async fn write_playlist(&self, playlist: &Playlist) -> Result<()> { + self.playlist_pipeline + .send(PlaylistWriteEvent::Write( + playlist.local_id(), + playlist.clone(), + )) + .await + .context("failed to send event") } - async fn delete_playlist(&self, name: impl AsRef<str> + Send) -> Result<()> { - let event = PlaylistWriteEvent::Delete(name.as_ref().into()); - self.playlist_pipeline.send(event).await?; - Ok(()) + async fn delete_playlist(&self, id: KId) -> Result<()> { + self.playlist_pipeline + .send(PlaylistWriteEvent::Delete(id)) + .await + .context("failed to send event") } async fn prepare_kara(&self, id: KId) -> Result<Self::File> { @@ -392,17 +383,19 @@ impl DatabaseStorage for DatabaseDiskStorage { } async fn submit_kara(&self, (path, _): Self::File, id: KId, hash: SHA256) -> Result<()> { - let digest = SHA256::try_from(sha256::try_async_digest(&path).await?).unwrap(); - let path = path.display(); - if digest != hash { - bail!("invalid digest for {path}, expected {hash}, got {digest}",) - } - log::info!("finished writing file for kara {id:?}: {path}"); + let digest = SHA256::try_from(sha256::try_async_digest(&path).await?) + .map_err(|err| anyhow!("invalid sha256 computed: {err}"))?; + ensure!( + digest == hash, + "invalid digest for {}, expected {hash}, got {digest}", + path.display() + ); + log::info!("finished writing file for kara {id:?}: {}", path.display()); Ok(()) } fn get_kara_uri(&self, id: KId) -> Result<url::Url> { - let path = self.path_from_root(format!("data/{}.mkv", id.path())); + let path = self.path_from_root(format!("data/{id}.mkv")); Ok(url::Url::parse(&format!("file://{}", path.display()))?) } } diff --git a/lektor_nkdb/src/storage/mod.rs b/lektor_nkdb/src/storage/mod.rs index e43480929b375eef8dcfbba98c61ae73603f5244..ad841439f587b293cb7d4435ce0887e5bd4cf0f1 100644 --- a/lektor_nkdb/src/storage/mod.rs +++ b/lektor_nkdb/src/storage/mod.rs @@ -12,12 +12,13 @@ pub use disk_storage::DatabaseDiskStorage; #[cfg(test)] mod test_storage; #[cfg(test)] +#[allow(unused_imports)] pub use test_storage::DatabaseTestStorage; /// Describes needed interactions with the database storage. All interactions should only be done /// throu this trait. The [url::Url] returned by the [Self::get_kara_uri] function should be usable by mpv to /// read the file (file://, http://, etc...) -#[async_trait::async_trait] +#[allow(async_fn_in_trait)] pub trait DatabaseStorage: Sized + std::fmt::Debug { /// The type of prefix to use for this loaded. type Prefix; @@ -44,13 +45,13 @@ pub trait DatabaseStorage: Sized + std::fmt::Debug { // =============== // /// Read the playlists from the storage. - async fn read_playlists(&self) -> Result<Vec<(PlaylistName, Playlist)>>; + async fn read_playlists(&self) -> Result<Vec<Playlist>>; /// Write a playlist to the storage. - async fn write_playlist(&self, name: impl AsRef<str> + Send, data: &Playlist) -> Result<()>; + async fn write_playlist(&self, playlist: &Playlist) -> Result<()>; /// Delete a playlist. - async fn delete_playlist(&self, name: impl AsRef<str> + Send) -> Result<()>; + async fn delete_playlist(&self, id: KId) -> Result<()>; // ========== // // Kara stuff // diff --git a/lektor_nkdb/src/storage/test_storage.rs b/lektor_nkdb/src/storage/test_storage.rs index 49b418476a9e92abf2d3d8d96aac749e1e263683..b9e30c60f8e2b0853ab2f5a63b5b1bacbc96d0a3 100644 --- a/lektor_nkdb/src/storage/test_storage.rs +++ b/lektor_nkdb/src/storage/test_storage.rs @@ -2,9 +2,14 @@ //! tests and we don't depend on the fs to do things... Note that the [Uri] returned by this //! storage are in `void://` which is not something usable! -use crate::{database::*, storage::DatabaseStorage, Playlist, PlaylistName}; +use crate::{ + database::{epoch::*, kara::*}, + id::*, + storage::DatabaseStorage, + Playlist, +}; use anyhow::{Context, Result}; -use futures::future::join_all; +use futures::future; use hashbrown::HashMap; use kurisu_api::SHA256; use tokio::sync::RwLock; @@ -14,7 +19,6 @@ use tokio::sync::RwLock; #[derive(Debug)] pub struct DatabaseTestStorage(RwLock<HashMap<KId, Kara>>); -#[async_trait::async_trait] impl DatabaseStorage for DatabaseTestStorage { type Prefix = (); type File = (); @@ -28,22 +32,22 @@ impl DatabaseStorage for DatabaseTestStorage { } async fn write_epoch(&self, data: &EpochData) -> Result<()> { - join_all(data.iter().map(|(id, kara)| async { - self.0.write().await.insert(id.clone(), (*kara).clone()); + future::join_all(data.iter().map(|(id, kara)| async { + self.0.write().await.insert(*id, kara.clone()); })) .await; Ok(()) } - async fn read_playlists(&self) -> Result<Vec<(PlaylistName, Playlist)>> { + async fn read_playlists(&self) -> Result<Vec<Playlist>> { unimplemented!() } - async fn write_playlist(&self, _: impl AsRef<str> + Send, _: &Playlist) -> Result<()> { + async fn write_playlist(&self, _: &Playlist) -> Result<()> { Ok(()) } - async fn delete_playlist(&self, _: impl AsRef<str> + Send) -> Result<()> { + async fn delete_playlist(&self, _: KId) -> Result<()> { Ok(()) } @@ -60,7 +64,6 @@ impl DatabaseStorage for DatabaseTestStorage { } fn get_kara_uri(&self, id: KId) -> Result<url::Url> { - url::Url::parse(&format!("void://{}", id.path())) - .with_context(|| format!("invalid url void://{}", id.path())) + url::Url::parse(&format!("void://{id}")).with_context(|| format!("invalid url void://{id}")) } } diff --git a/lektor_nkdb/src/strings.rs b/lektor_nkdb/src/strings.rs new file mode 100644 index 0000000000000000000000000000000000000000..d5892eebbb3270f836f5ea4c2ec0195b94bda0be --- /dev/null +++ b/lektor_nkdb/src/strings.rs @@ -0,0 +1,34 @@ +//! Contains things to store and cache strings to reduce memory footprint and have simpler +//! comparaisons: string comparaisons will become pointer —e.g. usize— comparaisons. + +use hashbrown::HashSet; +use std::sync::{Arc, LazyLock}; +use tokio::sync::RwLock; + +pub(crate) static CACHE: LazyLock<StringCache> = LazyLock::new(StringCache::default); + +/// The string cache. Used to cache strings as [Arc<str>] to reduce memory footprint. +#[derive(Default)] +pub(crate) struct StringCache(RwLock<HashSet<Arc<str>>>); + +impl StringCache { + /// Get a string, in an async way. + pub async fn get(&self, str: impl AsRef<str>) -> Arc<str> { + match self.0.read().await.get(str.as_ref()).cloned() { + Some(str) => str, + None => (self.0.write().await) + .get_or_insert_with(str.as_ref(), |str| Arc::from(str)) + .clone(), + } + } + + /// Get a string, in a sync way. + pub fn get_sync(&self, str: impl AsRef<str>) -> Arc<str> { + match self.0.blocking_read().get(str.as_ref()).cloned() { + Some(str) => str, + None => (self.0.blocking_write()) + .get_or_insert_with(str.as_ref(), |str| Arc::from(str)) + .clone(), + } + } +} diff --git a/lektor_payloads/Cargo.toml b/lektor_payloads/Cargo.toml index 315a1e2e1011ec3d96eb7e53d13dbdc6e63a5942..bfebc83593c5a934c7f9129c03d260b0ad92add7 100644 --- a/lektor_payloads/Cargo.toml +++ b/lektor_payloads/Cargo.toml @@ -1,21 +1,24 @@ [package] name = "lektor_payloads" +description = "The payloads of the requests used to exchange messages between lektord and its client, this is a utility crate, you can build your own thing because they are all serialized to json under the hood" + +rust-version.workspace = true + version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -rust-version.workspace = true -description = "The payloads of the requests used to exchange messages between lektord and its client, this is a utility crate, you can build your own thing because they are all serialized to json under the hood" [dependencies] -serde.workspace = true +serde.workspace = true serde_json.workspace = true anyhow.workspace = true -futures.workspace = true -axum.workspace = true +futures.workspace = true +axum.workspace = true async-trait.workspace = true -lektor_nkdb = { path = "../lektor_nkdb" } -lektor_utils = { path = "../lektor_utils" } +lektor_nkdb.workspace = true +lektor_utils.workspace = true +lektor_procmacros.workspace = true diff --git a/lektor_payloads/src/lib.rs b/lektor_payloads/src/lib.rs index 3ad79de1c00ba2d103273eaa0a281e319c52b150..8825f98a2cec6cce89c5217a5cf6a8460b40e14f 100644 --- a/lektor_payloads/src/lib.rs +++ b/lektor_payloads/src/lib.rs @@ -1,15 +1,22 @@ //! Crate containing structs/enums that are used as payloads to communicate with the lektord //! daemon. Some things are re-exports. -mod playlist_name; +mod play_state; +mod priority; mod range; mod search; mod userid; -pub use crate::{playlist_name::*, range::*, search::*, userid::LektorUser}; +pub use crate::{ + priority::{Priority, PRIORITY_LENGTH, PRIORITY_VALUES}, + play_state::PlayState, + range::*, + search::*, + userid::LektorUser, +}; pub use lektor_nkdb::{ - KId, Kara, KaraBy, KaraStatus, KaraTimeStamps, PlayState, Playlist, PlaylistInfo, Priority, - RemoteKId, SearchFrom, SongOrigin, SongType, PRIORITY_LENGTH, PRIORITY_VALUES, + KId, Kara, KaraBy, KaraStatus, KaraTimeStamps, Playlist, PlaylistInfo, RemoteKId, SearchFrom, + SongOrigin, SongType, }; use anyhow::{anyhow, ensure}; @@ -73,7 +80,7 @@ pub enum PlaylistUpdateAction { GiveTo(Box<str>), /// Rename the playlist. - Rename(PlaylistName), + Rename(String), /// Add a kara to the playlist. Add(KaraFilter), diff --git a/lektor_payloads/src/play_state.rs b/lektor_payloads/src/play_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b2b2571690c9ad09a78cc0aef55e47c03ff570a --- /dev/null +++ b/lektor_payloads/src/play_state.rs @@ -0,0 +1,23 @@ +//! The playstate of the player. + +use serde::{Deserialize, Serialize}; + +/// Play state of the player. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default)] +#[serde(rename_all = "lowercase")] +pub enum PlayState { + #[default] + Stop = 0, + Play = 1, + Pause = 2, +} + +impl AsRef<str> for PlayState { + fn as_ref(&self) -> &str { + match self { + PlayState::Stop => "stopped", + PlayState::Play => "play", + PlayState::Pause => "paused", + } + } +} diff --git a/lektor_payloads/src/playlist_name.rs b/lektor_payloads/src/playlist_name.rs deleted file mode 100644 index 68c71ca4bdff309758b23e934e805aad130a71f7..0000000000000000000000000000000000000000 --- a/lektor_payloads/src/playlist_name.rs +++ /dev/null @@ -1,78 +0,0 @@ -use anyhow::anyhow; -use axum::{ - extract::FromRequestParts, - http::{request::Parts, StatusCode}, -}; -use lektor_nkdb::PlaylistName as NKDBPlaylistName; -use lektor_utils::decode_base64; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -/// A playlist name, we excract it from the path. We wrap the thing from the database. -/// -/// ### Safety -/// We check before builder the playlist name that the [`u8`] is a valide UTF-8 string. -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Hash)] -#[repr(transparent)] -pub struct PlaylistName(NKDBPlaylistName); - -/// The prefix of the playlists requests. -const PLAYLIST_PREFIX: &str = "/playlist/"; - -impl AsRef<str> for PlaylistName { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -impl FromStr for PlaylistName { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - NKDBPlaylistName::from_str(s).map(Self) - } -} - -impl TryFrom<&str> for PlaylistName { - type Error = anyhow::Error; - - fn try_from(value: &str) -> Result<Self, Self::Error> { - value.parse() - } -} - -impl From<PlaylistName> for NKDBPlaylistName { - fn from(value: PlaylistName) -> Self { - value.0 - } -} - -impl From<NKDBPlaylistName> for PlaylistName { - fn from(value: NKDBPlaylistName) -> Self { - Self(value) - } -} - -impl std::fmt::Display for PlaylistName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_ref()) - } -} - -#[async_trait::async_trait] -impl<S> FromRequestParts<S> for PlaylistName { - type Rejection = (StatusCode, String); - - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { - let err = |err: anyhow::Error| (StatusCode::NOT_ACCEPTABLE, err.to_string()); - let path = parts.uri.path(); - if !path.starts_with(PLAYLIST_PREFIX) { - Err(err(anyhow!("invalid playlist path: {path}"))) - } else { - decode_base64(&path[PLAYLIST_PREFIX.len()..]) - .map_err(err)? - .parse() - .map_err(err) - } - } -} diff --git a/lektor_nkdb/src/queue/priority.rs b/lektor_payloads/src/priority.rs similarity index 98% rename from lektor_nkdb/src/queue/priority.rs rename to lektor_payloads/src/priority.rs index f8cd69ba0d5b4a5bddff6280c7f23145316da07b..1fd1894dc896520f474e8d51927019a011cb54ae 100644 --- a/lektor_nkdb/src/queue/priority.rs +++ b/lektor_payloads/src/priority.rs @@ -1,5 +1,5 @@ -use crate::*; use lektor_procmacros::{EnumVariantCount, EnumVariantIter}; +use serde::{Deserialize, Serialize}; use std::str::FromStr; /// Priorities to insert into the queue. We are one based because that's how humans thinks about diff --git a/lektor_payloads/src/search.rs b/lektor_payloads/src/search.rs index bc4567b564e74ad3abf86a0d882e657409b8043a..4468c9956eb5763662c468de7af5ef741a70dc2b 100644 --- a/lektor_payloads/src/search.rs +++ b/lektor_payloads/src/search.rs @@ -18,7 +18,7 @@ pub enum KaraFilter { List(bool, Vec<KId>), /// The content of a playlist. - Playlist(bool, PlaylistName), + Playlist(bool, KId), } #[cfg(test)] diff --git a/lektor_payloads/src/userid.rs b/lektor_payloads/src/userid.rs index 28ec6f3ef39306e1e71ada0bbd304aeeebecd84c..502483c6c9a41d44551c7aa3ac15a87cebb52c84 100644 --- a/lektor_payloads/src/userid.rs +++ b/lektor_payloads/src/userid.rs @@ -1,6 +1,7 @@ //! Specify who is the person making the request in the header. Extractor for when an //! identification is needed or optional. +use anyhow::{anyhow, bail}; use axum::{ extract::FromRequestParts, http::header, @@ -17,32 +18,6 @@ pub enum LektorUser { Unverified(Box<str>, Box<str>), } -pub struct Error(anyhow::Error); - -impl std::fmt::Debug for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl<E: Into<anyhow::Error>> From<E> for Error { - fn from(err: E) -> Self { - Self(err.into()) - } -} - -impl IntoResponse for Error { - fn into_response(self) -> axum::response::Response { - (StatusCode::NOT_ACCEPTABLE, format!("{self}")).into_response() - } -} - impl LektorUser { /// Is the user an admin? pub fn is_admin(&self) -> bool { @@ -93,6 +68,51 @@ impl LektorUser { let (name, token) = self.into_parts(); Self::User(name, token) } + + /// Returns [Some] user if it is an admin, [None] otherwise. + pub fn maybe_admin(self) -> anyhow::Result<Self> { + match self { + user @ LektorUser::Admin(..) => Ok(user), + LektorUser::User(n, _) | LektorUser::Unverified(n, _) => { + bail!("user '{n}' is not an admin") + } + } + } + + /// Returns [Some] user if it is an admin or a simple user, [None] otherwise (i.e. the user is + /// not authentified.) + pub fn maybe_user(self) -> anyhow::Result<Self> { + match self { + user @ (LektorUser::Admin(..) | LektorUser::User(..)) => Ok(user), + LektorUser::Unverified(n, _) => bail!("user '{n}' is not authentified"), + } + } +} + +pub struct Error(anyhow::Error); + +impl std::fmt::Debug for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<E: Into<anyhow::Error>> From<E> for Error { + fn from(err: E) -> Self { + Self(err.into()) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + (StatusCode::NOT_ACCEPTABLE, format!("{self}")).into_response() + } } #[async_trait::async_trait] @@ -103,11 +123,11 @@ impl<S> FromRequestParts<S> for LektorUser { match parts.headers.get(header::AUTHORIZATION) { Some(header) => { let Some((name, token)) = header.to_str()?.split_once('=') else { - return Err(anyhow::Error::msg("invalid header").into()); + return Err(anyhow!("invalid header").into()); }; Ok(LektorUser::Unverified(name.into(), token.into())) } - None => Err(anyhow::Error::msg("headers not found").into()), + None => Err(anyhow!("headers not found").into()), } } } diff --git a/lektor_repo/src/downloader.rs b/lektor_repo/src/downloader.rs index 87dc8d63423a93ed587339e40957413767f99a6e..f02ee940278657a4cd7da27b53f29c70c87428d4 100644 --- a/lektor_repo/src/downloader.rs +++ b/lektor_repo/src/downloader.rs @@ -6,7 +6,6 @@ use futures::{ stream::{self, FuturesUnordered}, StreamExt, }; -use hashbrown::HashSet; use kurisu_api::{ v2::{Infos, Kara}, SHA256, @@ -21,32 +20,14 @@ use serde::Deserialize; pub async fn download_dbinfos(config: &LektorRepoConfig) -> Result<Infos> { let name = config.name.as_str(); - let (infos, epochs) = stream::iter(config.urls.iter().map(get_dbinfos)) + let infos = stream::iter(config.urls.iter().map(get_dbinfos)) .then(|url| async { reqwest::get(url).await?.json::<Infos>().await }) .collect::<FuturesUnordered<_>>() .await .into_iter() .filter_map(|r| r.map_err(|e| log::error!("error for {name}: {e}")).ok()) - .fold( - (Infos::default(), HashSet::new()), - |(mut res, mut epochs), info| { - epochs.insert(info.dbepoch); - res.dbepoch = std::cmp::max(res.dbepoch, info.dbepoch); - res.tag_keys.extend(info.tag_keys); - res.valueless_tag_keys.extend(info.valueless_tag_keys); - (res, epochs) - }, - ); - if epochs.len() >= 2 { - log::warn!("urls for {name} are not in syncs, epochs are: {epochs:?}") - } - let intersection: Vec<_> = infos - .valueless_tag_keys - .intersection(&infos.tag_keys) - .collect(); - if !intersection.is_empty() { - log::warn!("tags are indicated as valueless and with value: {intersection:?}") - } + .fold(Infos::default(), |res, info| res.merge(info)); + infos.warn_on_conflicting_tags(); Ok(infos) } @@ -91,7 +72,6 @@ pub async fn download_kara<Storage: DatabaseStorage>( .map(|b| get_kara_file(b, rkid.remote_id())), ) .then(|url| { - let kid = kid.clone(); async move { // Build the request let request = Request::new( @@ -116,7 +96,7 @@ pub async fn download_kara<Storage: DatabaseStorage>( } // Download the file - let mut file = handle.prepare_kara(kid.clone()).await?; + let mut file = handle.prepare_kara(kid).await?; while let Some(chunk) = res.chunk().await? { handle.write_kara(&mut file, &chunk).await?; } diff --git a/lektor_repo/src/lib.rs b/lektor_repo/src/lib.rs index 2d8d6f5e274788a5fdf564e0f3d3e26da922e832..5e0ccebcc515f0df0cd70491494859da01e35edd 100644 --- a/lektor_repo/src/lib.rs +++ b/lektor_repo/src/lib.rs @@ -55,14 +55,10 @@ impl Repo { }); stream::iter(list).then(move |kara| async move { log::trace!("got kara from {}: {kara:#?}", cfg.name); - match handler.add_kara_v2(&cfg.name, kara).await { - Ok(Some(to_dl)) => { - if let Err(err) = download_kara(cfg, handler, to_dl).await { - log::error!("{err}"); - } + if let Some(to_dl) = handler.add_kara_v2(&cfg.name, kara).await { + if let Err(err) = download_kara(cfg, handler, to_dl).await { + log::error!("{err}"); } - Err(err) => log::error!("failed to update kara: {err}"), - _ => {} } }) }) diff --git a/lektor_utils/src/base64.rs b/lektor_utils/src/base64.rs index 39e341f6891bd8a2845dbebcfe09fe28a873dcdf..6e80a103e54da69138409cff35acc3f71ffe3c13 100644 --- a/lektor_utils/src/base64.rs +++ b/lektor_utils/src/base64.rs @@ -1,6 +1,8 @@ +use core::fmt; + use anyhow::Result; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use serde::{Deserialize, Serialize}; +use serde::{ser::Error, Deserialize, Serialize}; /// Re-export of the slice decode error pub use base64::EncodeSliceError; @@ -10,19 +12,37 @@ pub use base64::EncodeSliceError; /// playlists urls to 128 ($2^7$) characters and the queue/history is limited to u64 integers. pub const BASE64_BUFFER_SIZE: usize = 1024; +/// Struct to wrap the result of [encode_base64] and [encode_base64_value]. Can be displayed and +/// turn into parts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Base64Encoded(usize, [u8; BASE64_BUFFER_SIZE]); + +impl Base64Encoded { + pub fn into_parts(self) -> (usize, [u8; BASE64_BUFFER_SIZE]) { + (self.0, self.1) + } +} + +impl fmt::Display for Base64Encoded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + std::str::from_utf8(&self.1[..self.0]).map_err(|_| { + fmt::Error::custom("encoded buffer contains invalid utf8 characters") + })?, + ) + } +} + /// Encode a string as a base64 string and store it into a buffer without allocatig any memory. -pub fn encode_base64(input: impl AsRef<str>) -> Result<(usize, [u8; BASE64_BUFFER_SIZE])> { +pub fn encode_base64(input: impl AsRef<str>) -> Result<Base64Encoded> { let mut buffer = [0_u8; BASE64_BUFFER_SIZE]; let len = URL_SAFE_NO_PAD.encode_slice(input.as_ref(), &mut buffer)?; - Ok((len, buffer)) + Ok(Base64Encoded(len, buffer)) } /// Serialize to json and encode a value in base64. -pub fn encode_base64_value(value: impl Serialize) -> Result<(usize, [u8; BASE64_BUFFER_SIZE])> { - let json = serde_json::to_string(&value)?; - let size = encode_base64(&json)?; - log::trace!("encoding json {json}"); - Ok(size) +pub fn encode_base64_value(value: impl Serialize) -> Result<Base64Encoded> { + encode_base64(&serde_json::to_string(&value)?) } /// Decode a base64 encoded string without allocation then deserialize the json into the approriate diff --git a/lektord/Cargo.toml b/lektord/Cargo.toml index 7bbd03c3ee0a6ab6aebcff89b5269db9d5c07034..9b51010e15138e46594b022bc71893f60b0696e5 100644 --- a/lektord/Cargo.toml +++ b/lektord/Cargo.toml @@ -17,7 +17,6 @@ anyhow.workspace = true hashbrown.workspace = true futures.workspace = true -async-trait.workspace = true axum.workspace = true tokio.workspace = true diff --git a/lektord/src/app/mod.rs b/lektord/src/app/mod.rs index 15c32be3b48b91a698636db40dfa73b77dfe7f63..be5a06b9978fba346d4f0852630381c1f368675c 100644 --- a/lektord/src/app/mod.rs +++ b/lektord/src/app/mod.rs @@ -3,10 +3,12 @@ #![forbid(unsafe_code)] mod mpris; +mod play_state; +mod queue; mod routes; use crate::LektorConfig; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result}; use axum::{ extract::Request, http::{response, StatusCode}, @@ -19,6 +21,8 @@ use lektor_nkdb::{Database, DatabaseDiskStorage}; use lektor_payloads::LektorUser; use lektor_repo::Repo; use lektor_utils::config::LektorDatabaseConfig; +use play_state::StoredPlayState; +use queue::{Queue, QueueHandle}; use std::sync::{Arc, Weak}; use tokio::sync::{oneshot::Sender, RwLock}; @@ -79,33 +83,38 @@ pub async fn app( // Simple search things ; "/search/:b64" -> get: routes::search ; "/count/:b64" -> get: routes::count - ; "/get/id/:id" -> get: routes::get_kara_by_id ; "/get/kid/:id" -> get: routes::get_kara_by_kid - // Queue and history functions. You need to specify the range, like: - // `/queue?range=..` - ; "/queue" -> get: routes::get_queue_range - , post: routes::add_to_queue - , patch: routes::update_queue_range - , delete: routes::delete_queue_range - ; "/history" -> get: routes::get_history_range - , delete: routes::delete_history_range - ; "/queue/count" -> get: routes::get_queue_count - ; "/history/count" -> get: routes::get_history_count - ; "/queue/:prio/:kid" -> post: routes::add_kid_to_queue - , delete: routes::delete_kid_from_queue - ; "/queue/:what" -> delete: routes::delete_from_queue - , put: routes::shuffle_queue_level - , get: routes::get_from_queue - ; "/history/:kid" -> delete: routes::delete_kid_from_history + // Queue functions. You need to specify the range, like: `/queue?range=..` + ; "/queue" -> get: routes::get_queue_range + , post: routes::add_to_queue + , patch: routes::update_queue_range + , delete: routes::delete_queue_range + ; "/queue/count" -> get: routes::get_queue_count + ; "/queue/level/:prio/:kid" -> post: routes::add_kid_to_queue + , delete: routes::delete_kid_from_queue + ; "/queue/level/:prio" -> get: routes::get_queue_level + , put: routes::shuffle_queue_level + , delete: routes::delete_queue_level + ; "/queue/:kid" -> delete: routes::delete_from_queue + , put: routes::shuffle_queue_level + , get: routes::get_from_queue + + // History functions. You need to specify the range, like: `/history?range=..` + ; "/history" -> get: routes::get_history_range + , delete: routes::delete_history_range + ; "/history/count" -> get: routes::get_history_count + ; "/history/:kid" -> delete: routes::delete_kid_from_history // Playlists things, to modify or delete a playlist, the user must be the owner or // the super user... - ; "/playlist" -> get: routes::get_playlists - ; "/playlist/:name" -> get: routes::get_playlist_content - , patch: routes::update_playlist - , delete: routes::delete_playlist - , put: routes::create_playlist + ; "/playlists" -> get: routes::get_playlists + ; "/playlist/:name/content" -> get: routes::get_playlist_content + ; "/playlist/:name/info" -> get: routes::get_playlist_info + ; "/playlist/:name" -> get: routes::get_playlist + , patch: routes::update_playlist + , delete: routes::delete_playlist + , put: routes::create_playlist // Admin routes, requires to be the super user to do. ; "/adm/update" -> post: routes::adm_update @@ -143,7 +152,13 @@ async fn log_requests(request: Request, next: Next) -> Response { #[derive(Debug)] pub struct LektorState { /// The database. - pub(crate) database: Database, + database: Database, + + /// The queue. + queue: Queue, + + /// The playstate of the player. + playstate: StoredPlayState, /// The thing that will handle updates for us. pub(crate) repo: Repo, @@ -188,6 +203,8 @@ impl LektorState { repo: Repo::new(repo), users: users.collect(), mpris: Default::default(), + queue: Default::default(), + playstate: Default::default(), shutdown: RwLock::new(Some(shutdown)), })); if config.mpris { @@ -205,7 +222,7 @@ impl LektorState { } /// Check whever the user is valid or not. - pub fn verify_user(&self, user: LektorUser) -> LektorUser { + fn verify_user(&self, user: LektorUser) -> LektorUser { let (name, tok) = user.as_parts(); for config in &self.users { match config { @@ -217,30 +234,27 @@ impl LektorState { user } + /// Get a handle to read or write the queue, alongside the playstate. + pub(crate) fn queue(&self) -> QueueHandle { + QueueHandle::new(&self.queue, &self.playstate) + } + /// Play the next kara in the queue. - pub async fn play_next(&self) -> Result<()> { + pub(crate) async fn play_next(&self) -> Result<()> { let id = self - .database - .next() - .await - .ok_or(anyhow!("no next kara to play"))?; - crate::c_wrapper::player_load_file( - self.database.get_kara_uri(id.clone())?.to_string(), - id.local_id(), - ) + .queue() + .write(|queue, _| queue.next().context("no next kara to play")) + .await?; + crate::c_wrapper::player_load_file(self.database.get_kara_uri(id)?.as_str(), id) } /// Play the previous kara in the queue. - pub async fn play_previous(&self) -> Result<()> { + pub(crate) async fn play_previous(&self) -> Result<()> { let id = self - .database - .previous() - .await - .ok_or_else(|| anyhow!("no previous kara to play"))?; - crate::c_wrapper::player_load_file( - self.database.get_kara_uri(id.clone())?.to_string(), - id.local_id(), - ) + .queue() + .write(|queue, _| queue.previous().context("no next kara to play")) + .await?; + crate::c_wrapper::player_load_file(self.database.get_kara_uri(id)?.as_str(), id) } } diff --git a/lektord/src/app/mpris.rs b/lektord/src/app/mpris.rs index 9f57e52f5f1092bf4864d166f57801ef76703129..b34e9deae6ef71d0da091525b2418fc7198430bc 100644 --- a/lektord/src/app/mpris.rs +++ b/lektord/src/app/mpris.rs @@ -4,7 +4,7 @@ use lektor_nkdb::Kara; use std::sync::Arc; /// The play state, convertible to the thing liked by MPRIS. -pub struct PlayState(lektor_nkdb::PlayState); +pub struct PlayState(lektor_payloads::PlayState); /// A thing used to convert a [Kara] into track metadatas for MPRIS. pub struct TrackMdt(Arc<Kara>); @@ -95,15 +95,21 @@ impl AsMpris for LektorStateWeakPtr { } } -impl From<PlayState> for lektor_nkdb::PlayState { +impl From<PlayState> for lektor_payloads::PlayState { fn from(PlayState(value): PlayState) -> Self { value } } +impl From<lektor_payloads::PlayState> for PlayState { + fn from(value: lektor_payloads::PlayState) -> Self { + Self(value) + } +} + impl From<lektor_mpris::types::PlaybackStatus> for PlayState { fn from(value: lektor_mpris::types::PlaybackStatus) -> Self { - use {lektor_mpris::types::PlaybackStatus, lektor_nkdb::PlayState}; + use {lektor_mpris::types::PlaybackStatus, lektor_payloads::PlayState}; match value { PlaybackStatus::Stopped => Self(PlayState::Stop), PlaybackStatus::Playing => Self(PlayState::Play), @@ -115,9 +121,9 @@ impl From<lektor_mpris::types::PlaybackStatus> for PlayState { impl From<PlayState> for lektor_mpris::types::PlaybackStatus { fn from(PlayState(value): PlayState) -> Self { match value { - lektor_nkdb::PlayState::Stop => Self::Stopped, - lektor_nkdb::PlayState::Play => Self::Playing, - lektor_nkdb::PlayState::Pause => Self::Paused, + lektor_payloads::PlayState::Stop => Self::Stopped, + lektor_payloads::PlayState::Play => Self::Playing, + lektor_payloads::PlayState::Pause => Self::Paused, } } } diff --git a/lektord/src/app/play_state.rs b/lektord/src/app/play_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..89c4ed3722373ce27a978f5bcdebcf475ead6803 --- /dev/null +++ b/lektord/src/app/play_state.rs @@ -0,0 +1,26 @@ +//! Contains things to store the playstate for the player, in a way that can be accessed in a +//! thread-safe maner. + +use lektor_payloads::PlayState; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// The playstate, but stored in the database. We take into account the fact that it can be +/// accessed in a parallel context. +#[derive(Debug, Default)] +pub struct StoredPlayState { + state: AtomicU8, +} + +impl StoredPlayState { + pub fn get(&self) -> PlayState { + match self.state.load(Ordering::Acquire) { + 1 => PlayState::Play, + 2 => PlayState::Pause, + _ => PlayState::Stop, + } + } + + pub fn set(&self, state: PlayState) { + self.state.store(state as u8, Ordering::Release); + } +} diff --git a/lektord/src/app/queue.rs b/lektord/src/app/queue.rs new file mode 100644 index 0000000000000000000000000000000000000000..a368cba3d0b7e150be5113402f59e92bd788c58b --- /dev/null +++ b/lektord/src/app/queue.rs @@ -0,0 +1,307 @@ +use crate::app::play_state::StoredPlayState; +use lektor_payloads::{KId, PlayState, Priority, PRIORITY_LENGTH}; +use lektor_utils::{filter_range, BoundedBoundRange}; +use rand::{seq::SliceRandom, thread_rng}; +use std::{ + ops::{RangeBounds, RangeInclusive}, + sync::atomic::{AtomicU64, Ordering}, +}; +use tokio::sync::RwLock; + +/// The queue contains the playing kara and the following ones. This type is just a wrapper around +/// the [QueueContent] with a [RwLock]. For function documentation see [QueueContent]. +#[derive(Debug, Default)] +pub(crate) struct Queue { + content: RwLock<QueueContent>, + epoch: AtomicU64, +} + +pub struct QueueHandle<'a> { + queue: &'a Queue, + state: &'a StoredPlayState, +} + +impl<'a> QueueHandle<'a> { + pub(crate) fn new(queue: &'a Queue, state: &'a StoredPlayState) -> Self { + Self { queue, state } + } + + /// Read informations about the queue. + pub async fn read<T>(&self, cb: impl FnOnce(&QueueContent, PlayState) -> T) -> T { + cb(&*self.queue.content.read().await, self.state.get()) + } + + /// Modifies the queue. + pub async fn write<T>(&self, cb: impl FnOnce(&mut QueueContent, &StoredPlayState) -> T) -> T { + self.queue.epoch.fetch_add(1, Ordering::AcqRel); + cb(&mut *self.queue.content.write().await, self.state) + } + + pub fn epoch(&self) -> u64 { + self.queue.epoch.load(Ordering::Acquire) + } +} + +/// The content of the queue. Is protected by a RwLock from tokio. +#[derive(Debug, Default)] +pub struct QueueContent { + /// The current kara + current: Option<KId>, + + /// The queue, stored as a priority list, stored in reverse order from the priority. + queue: [Vec<KId>; PRIORITY_LENGTH], + + /// The history, we push back into it, so the most recent one is the one at the end of the + /// vector. + history: Vec<KId>, +} + +impl QueueContent { + /// Get the current kara if it exists. + pub fn current(&self) -> Option<KId> { + self.current + } + + pub fn split_range_per_level( + &self, + range: impl RangeBounds<usize>, + ) -> Vec<(Priority, RangeInclusive<usize>)> { + let (mut start, mut end) = ( + match range.start_bound() { + std::ops::Bound::Included(pos) => *pos, + std::ops::Bound::Excluded(pos) => pos + 1, + std::ops::Bound::Unbounded => 0, + }, + match range.end_bound() { + std::ops::Bound::Included(pos) => *pos, + std::ops::Bound::Excluded(pos) => pos - 1, + std::ops::Bound::Unbounded => self.count(), + }, + ); + self.queue + .iter() + .enumerate() + .rev() + .flat_map(|(idx, lvl)| { + let ret = (start <= lvl.len()) + .then(|| (Priority::from(idx + 1), start..=(lvl.len() - 1).min(end))); + (start, end) = (start - lvl.len(), end - lvl.len()); + ret + }) + .collect() + } + + /// Clear the queue up to the position specified, returning it into the current kara. + pub fn play_from_position(&mut self, position: usize) -> Option<KId> { + (position < self.count()) + .then(|| { + self.split_range_per_level(0..=position) + .into_iter() + .fold(None, |_, (prio, range)| { + self.queue[prio.index()].drain(range).last() + }) + }) + .flatten() + } + + /// Shuffle the elemenst in the queue, the kara won't change priorities. + pub fn shuffle(&mut self, range: impl RangeBounds<usize> + Copy) { + self.split_range_per_level(range) + .into_iter() + .for_each(|(prio, range)| { + self.queue[prio.index()][range].shuffle(&mut thread_rng()); + }); + } + + /// Shuffle the elemenst in the level of the queue. + pub fn shuffle_level(&mut self, level: Priority) { + self.queue[level.index()].shuffle(&mut thread_rng()); + } + + /// Get the number of element in the range. Handles the out of bound stuff. + pub fn range_count(&self, range: impl RangeBounds<usize> + Copy) -> usize { + let (start, end) = ( + match range.start_bound() { + std::ops::Bound::Included(pos) => *pos, + std::ops::Bound::Excluded(pos) => pos + 1, + std::ops::Bound::Unbounded => 0, + }, + match range.end_bound() { + std::ops::Bound::Included(pos) => *pos, + std::ops::Bound::Excluded(pos) => pos - 1, + std::ops::Bound::Unbounded => self.count(), + }, + ); + end - start + } + + /// Move a kara after a position. + pub fn move_after(&mut self, range: impl RangeBounds<usize> + Copy, after: usize) { + if !range.contains(&after) { + let start = match range.start_bound() { + std::ops::Bound::Included(start) => *start, + std::ops::Bound::Excluded(start) => start - 1, + std::ops::Bound::Unbounded => 0, + }; + let mut after = if after < start { after } else { after - start }; + let count = self.range_count(range); + let ids = self.delete_get(range).into_iter().map(|(_, id)| id); + for lvl in self.queue.iter_mut().rev() { + if after > lvl.len() { + after -= lvl.len(); + continue; + } else { + lvl.reserve(count); + ids.rev().for_each(|id| lvl.insert(after, id)); + break; + } + } + } else { + log::error!("can't move a range after a position included in it"); + } + } + + /// Remove elements from a level of the queue. + pub fn delete_level(&mut self, level: Priority) { + self.queue[level.index()].clear() + } + + /// Remove elements from the queue by their position. + pub fn delete(&mut self, range: impl RangeBounds<usize> + Copy) { + self.split_range_per_level(range) + .into_iter() + .for_each(|(prio, range)| { + self.queue[prio.index()].drain(range); + }) + } + + /// Remove elements from the queue by their position and returns what was deleted. + pub fn delete_get(&mut self, range: impl RangeBounds<usize> + Copy) -> Vec<(Priority, KId)> { + self.split_range_per_level(range) + .into_iter() + .flat_map(|(prio, range)| { + self.queue[prio.index()] + .drain(range) + .map(move |id| (prio, id)) + .collect::<Vec<_>>() + }) + .collect() + } + + /// Get the content of the level of the queue. + pub fn get_level(&self, prio: Priority) -> Vec<KId> { + self.queue[prio.index()].clone() + } + + /// Iterate over the queue. + pub fn get(&self, range: impl RangeBounds<usize> + Copy) -> Vec<(Priority, KId)> { + let iter = self + .queue + .iter() + .enumerate() + .map(|(idx, list)| list.iter().map(move |&id| (Priority::from(idx + 1), id))) + .rev() + .flatten(); + filter_range(iter, range).collect::<Vec<_>>() + } + + /// Returns the number of karas present in the queue. + pub fn count(&self) -> usize { + self.queue.iter().map(|lvl| lvl.len()).sum() + } + + /// Returns the number of elements in each level of the queue + pub fn count_per_level(&self) -> [usize; PRIORITY_LENGTH] { + [ + self.queue[Priority::Add.index()].len(), + self.queue[Priority::Suggest.index()].len(), + self.queue[Priority::Insert.index()].len(), + self.queue[Priority::Enforce.index()].len(), + ] + } + + /// Insert something into one priority of the queue. + pub fn insert(&mut self, prio: Priority, kara: KId) { + self.queue[prio.index()].insert(0, kara); + } + + /// Insert something into one priority of the queue. + pub fn insert_slice(&mut self, prio: Priority, kara: &[KId]) { + let level = &mut self.queue[prio.index()]; + level.reserve(kara.len()); + kara.iter().rev().for_each(|&id| level.insert(0, id)); + } + + /// Insert something into one priority of the queue. + pub fn insert_all(&mut self, prio: Priority, kara: Vec<KId>) { + self.insert_slice(prio, &kara) + } + + /// Get the next kara to play from the queue and insert the old-playing one into the history. + /// The returned kara is the new current one. + pub fn next(&mut self) -> Option<KId> { + let mut iter = self.queue.iter_mut().rev(); + let current = iter.find_map(|lvl| (!lvl.is_empty()).then(|| lvl.remove(0)))?; + if let Some(current) = self.current.replace(current) { + self.history.push(current); + } + Some(current) + } + + /// Get the previous kara to play from the history and insert the old-playing one into the + /// queue at the same priority as the next one. The returned kara is the new current one. If + /// the queue is empty we add as the default priority. + pub fn previous(&mut self) -> Option<KId> { + let current = self.history.pop()?; + if let Some(current) = self.current.replace(current) { + match self.queue.iter_mut().rev().find(|lvl| !lvl.is_empty()) { + Some(lvl) => lvl.insert(0, current), + None => self.insert(Default::default(), current), + } + } + Some(current) + } + + /// Delete all karas by and their id in the queue + pub fn delete_all(&mut self, id: KId) { + self.queue + .iter_mut() + .for_each(|lvl| lvl.retain(|kid| id != *kid)) + } + + /// Delete all karas by and their id from a level in the queue + pub fn delete_all_from_level(&mut self, prio: Priority, id: KId) { + self.queue[prio.index()].retain(|kid| id.ne(kid)) + } + + /// Delete all karas by and their id in the history + pub fn history_delete_all(&mut self, id: KId) { + self.history.retain(|kid| id.ne(kid)) + } + + /// Returns the number of karas present in the history. + pub fn history_count(&self) -> usize { + self.history.len() + } + + /// Remove elements from the history by their position. + pub fn history_delete(&mut self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { + use std::ops::Bound::{self, *}; + let count = self.history_count(); + let inverse = |bound: Bound<&usize>| match bound { + Included(idx) => Included(count - idx - 1), + Excluded(idx) => Excluded(count - idx - 1), + Unbounded => Unbounded, + }; + let range = + BoundedBoundRange::from((inverse(range.start_bound()), inverse(range.end_bound()))); + self.history.drain(range).collect() + } + + /// Iterate over the history. We iterate from the most recent one to the most old one. + pub fn history(&self, range: impl RangeBounds<usize> + Copy) -> Vec<KId> { + filter_range(self.history.iter().rev(), range) + .cloned() + .collect() + } +} diff --git a/lektord/src/app/routes.rs b/lektord/src/app/routes.rs index 7f7becf7afbf173b64ce693b31695a29b2c42422..c4f09ecdaca623717a134f42adac0effd2da5d9f 100644 --- a/lektord/src/app/routes.rs +++ b/lektord/src/app/routes.rs @@ -6,32 +6,41 @@ #![forbid(unsafe_code)] use crate::*; -use anyhow::{anyhow, Error}; +use anyhow::anyhow; use axum::{ extract::{Path, State}, - http::{header::CONTENT_TYPE, HeaderValue, StatusCode}, + http::{header::CONTENT_TYPE, HeaderValue}, response::{IntoResponse, Response}, Json, }; +use futures::prelude::*; use lektor_nkdb::*; use lektor_payloads::*; use lektor_utils::decode_base64_json; use rand::{seq::SliceRandom, thread_rng}; -use std::{ops::RangeBounds, str::FromStr}; +use std::ops::RangeBounds; use tokio::task::LocalSet; /// Get informations abount the lektord server. +#[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn root(State(state): State<LektorStatePtr>) -> Json<Infos> { Json(Infos { version: crate::version().to_string(), - last_epoch: state.database.last_epoch_num().await, + last_epoch: state + .database + .last_epoch() + .map(|epoch| epoch.map(Epoch::num)) + .await, }) } /// Get the play state of the server. Be aware of race conditions... #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn get_state(State(state): State<LektorStatePtr>) -> Json<PlayStateWithCurrent> { - let (state, current) = state.database.current().await; + let (state, current) = state + .queue() + .read(|content, state| (state, content.current())) + .await; let current = current.map(|id| { let elapsed = c_wrapper::player_get_elapsed() .map_err(|err| log::error!("{err}")) @@ -64,14 +73,11 @@ pub(crate) async fn play_from_queue_pos( Path(position): Path<usize>, ) -> Result<(), LektordError> { let id = state - .database - .play_from_position(position) + .queue() + .write(|content, _| content.play_from_position(position)) .await - .ok_or(anyhow!("position {position} doesn't exists in queue"))?; - crate::c_wrapper::player_load_file( - state.database.get_kara_uri(id.clone())?.to_string(), - id.local_id(), - )?; + .with_context(|| format!("position {position} doesn't exists in queue"))?; + crate::c_wrapper::player_load_file(state.database.get_kara_uri(id)?.to_string(), id)?; Ok(()) } @@ -82,17 +88,22 @@ pub(crate) async fn set_play_state( Json(to): Json<PlayState>, ) -> Result<(), LektordError> { if to == PlayState::Stop { - Ok(c_wrapper::player_stop()?) - } else { - match state.database.current().await { - (PlayState::Stop, _) if to == PlayState::Play => Ok(state.play_next().await?), - (PlayState::Stop, _) => { - log::trace!("playback is set to stopped, can't set it to {to:?}"); - Ok(()) + c_wrapper::player_stop()? + } else if state + .queue() + .write(|_, state| match state.get() { + PlayState::Stop if to == PlayState::Play => Ok(true), + stop @ PlayState::Stop => { + log::trace!("can't change playback from {stop:?} to {to:?}"); + Ok(false) } - _ => Ok(c_wrapper::player_set_paused(to)?), - } + _ => c_wrapper::player_set_paused(to).map(|_| false), + }) + .await? + { + state.play_next().await? } + Ok(()) } /// Toggle the play state of the server, Be aware of race conditions... @@ -101,35 +112,15 @@ pub(crate) async fn toggle_play_state() -> Result<(), LektordError> { Ok(crate::c_wrapper::player_toggle_pause()?) } -/// Get all the informations about a kara by its id. Returns the kara as a json object to avoid -/// cloning the kara structure and directly return the serialized object. -#[axum::debug_handler(state = LektorStatePtr)] -pub(crate) async fn get_kara_by_id( - State(state): State<LektorStatePtr>, - Path(id): Path<u64>, -) -> Result<Response, LektordError> { - let mut kara = serde_json::to_string(state.database.get_kara_by_id(id).await?) - .map_err(|err| anyhow!("{err}"))? - .into_response(); - kara.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - Ok(kara) -} - /// Get all the informations about a kara by its id. Returns the kara as a json object to avoid /// cloning the kara structure and directly return the serialized object. #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn get_kara_by_kid( State(state): State<LektorStatePtr>, - Path(id): Path<String>, + Path(id): Path<KId>, ) -> Result<Response, LektordError> { - let id = state - .database - .get_kid_from_str(&decode_base64(&id)?) - .await - .ok_or_else(|| anyhow!("no kara found with id {id}"))?; let mut kara = serde_json::to_string(state.database.get_kara_by_kid(id).await?) - .map_err(|err| anyhow!("{err}"))? + .context("failed to find kara with id")? .into_response(); kara.headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -144,7 +135,8 @@ pub(crate) async fn search( Path(uri): Path<String>, ) -> Result<Json<Vec<KId>>, LektordError> { let SearchData { from, regex } = decode_base64_json(uri)?; - Ok(Json(state.database.search(from, regex).await?)) + // Ok(Json(state.database.search(from, regex).await?)) + todo!() } /// Count karas available in a search set. We take the json argument as base64 encoded @@ -155,21 +147,39 @@ pub(crate) async fn count( Path(uri): Path<String>, ) -> Result<Json<usize>, LektordError> { let SearchData { from, regex } = decode_base64_json(uri)?; - Ok(Json(state.database.count(from, regex).await)) + // Ok(Json(state.database.count(from, regex).await)) + todo!() } /// Get all playlists from the database with their associated informations. #[axum::debug_handler(state = LektorStatePtr)] -pub(crate) async fn get_playlists( +pub(crate) async fn get_playlists(State(state): State<LektorStatePtr>) -> Json<Vec<(KId, String)>> { + Json(state.database.playlists().list().await) +} + +/// Get the informations relative to a specific playlist. +#[axum::debug_handler(state = LektorStatePtr)] +pub(crate) async fn get_playlist_info( State(state): State<LektorStatePtr>, -) -> Json<Vec<(lektor_payloads::PlaylistName, PlaylistInfo)>> { - Json(Vec::from_iter( - state - .database - .playlists() - .await - .into_iter() - .map(|(n, info)| (n.into(), info)), + Path(id): Path<KId>, +) -> Result<Json<PlaylistInfo>, LektordError> { + Ok(Json( + (state.database.playlists()) + .read(id, |plt| plt.get_infos()) + .await?, + )) +} + +/// Get everything about a specific playlist. +#[axum::debug_handler(state = LektorStatePtr)] +pub(crate) async fn get_playlist( + State(state): State<LektorStatePtr>, + Path(id): Path<KId>, +) -> Result<Json<Playlist>, LektordError> { + Ok(Json( + (state.database.playlists()) + .read(id, |plt| plt.clone()) + .await?, )) } @@ -177,14 +187,13 @@ pub(crate) async fn get_playlists( #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn get_playlist_content( State(state): State<LektorStatePtr>, - name: lektor_payloads::PlaylistName, + Path(id): Path<KId>, ) -> Result<Json<Vec<KId>>, LektordError> { - Ok(state - .database - .playlist_get_content(&name) - .await - .ok_or_else(|| Error::msg(format!("failed to get content of playlist {name}"))) - .map(Json)?) + Ok(Json( + (state.database.playlists()) + .read(id, |plt| plt.iter_seq_content().collect::<Vec<_>>()) + .await?, + )) } /// Update the content of a playlist. For that the client must be the owner of the playlist or the @@ -193,42 +202,60 @@ pub(crate) async fn get_playlist_content( pub(crate) async fn update_playlist( State(state): State<LektorStatePtr>, user_infos: LektorUser, - name: lektor_payloads::PlaylistName, + Path(id): Path<KId>, Json(action): Json<PlaylistUpdateAction>, ) -> Result<(), LektordError> { - let user_infos = state.verify_user(user_infos); + let user_infos = state.verify_user(user_infos).maybe_user()?; let (uin, uia) = (user_infos.name(), user_infos.is_admin()); - let db = &state.database; - let name = name.into(); + let db = &state.database.playlists(); - Ok(match action { - PlaylistUpdateAction::Delete => db.playlist_delete(name, uin, uia).await, + match action { + PlaylistUpdateAction::Delete => db.delete(id, uin, uia).await?, PlaylistUpdateAction::GiveTo(new_user) => { - db.playlist_give_to(name, new_user, uin, uia).await + db.write(id, uin, uia, |plt| _ = plt.add_owner(new_user)) + .await? } PlaylistUpdateAction::Rename(new_name) => { - db.playlist_rename(name, new_name.into(), uin, uia).await + db.write(id, uin, uia, |plt| _ = plt.set_name(new_name)) + .await? } PlaylistUpdateAction::Remove(filter) => match filter { - KaraFilter::KId(kid) => db.playlist_delete_id(name, kid, uin, uia).await, - KaraFilter::List(_, kids) => db.playlist_delete_ids(name, kids, uin, uia).await, + KaraFilter::KId(kid) => db.write(id, uin, uia, |plt| plt.remove(kid)).await?, + KaraFilter::List(_, kids) => { + db.write(id, uin, uia, move |plt| plt.retain(|id| !kids.contains(id))) + .await? + } KaraFilter::Playlist(_, from) => { - db.playlist_delete_playlist(name, from, uin, uia).await + let kids = db + .read(from, |plt| plt.iter_seq_content().collect::<Vec<_>>()) + .await?; + db.write(id, uin, uia, move |plt| plt.retain(|id| !kids.contains(id))) + .await? } }, PlaylistUpdateAction::Add(filter) => match filter { - KaraFilter::KId(kid) => db.playlist_add_id(name, kid, uin, uia).await, + KaraFilter::KId(kid) => db.write(id, uin, uia, |plt| plt.push(kid)).await?, KaraFilter::Playlist(rand, from) => { - db.playlist_add_playlist(name, from, rand, uin, uia).await + let mut kids = db + .read(from, |plt| plt.iter_seq_content().collect::<Vec<_>>()) + .await?; + if rand { + kids[..].shuffle(&mut thread_rng()); + } + db.write(id, uin, uia, move |plt| plt.append(&mut kids)) + .await? } - KaraFilter::List(false, kids) => db.playlist_add_ids(name, kids, uin, uia).await, - KaraFilter::List(true, mut kids) => { - kids[..].shuffle(&mut thread_rng()); - db.playlist_add_ids(name, kids, uin, uia).await + KaraFilter::List(rand, mut kids) => { + if rand { + kids[..].shuffle(&mut thread_rng()); + } + db.write(id, uin, uia, move |plt| plt.append(&mut kids)) + .await? } }, - }?) + } + Ok(()) } /// Delete a playlist. For that the client must be the owner of the playlist or the super user of @@ -237,13 +264,13 @@ pub(crate) async fn update_playlist( pub(crate) async fn delete_playlist( State(state): State<LektorStatePtr>, user_infos: LektorUser, - name: lektor_payloads::PlaylistName, + Path(id): Path<KId>, ) -> Result<(), LektordError> { - let user_infos = state.verify_user(user_infos); - Ok(state - .database - .playlist_delete(name, user_infos.name(), user_infos.is_admin()) - .await?) + let user_infos = state.verify_user(user_infos).maybe_user()?; + (state.database.playlists()) + .delete(id, user_infos.name(), user_infos.is_admin()) + .await?; + Ok(()) } /// Create an empty playlist. The user must be identified or the playlist will belong to anyone. @@ -251,13 +278,19 @@ pub(crate) async fn delete_playlist( pub(crate) async fn create_playlist( State(state): State<LektorStatePtr>, user_infos: LektorUser, - name: lektor_payloads::PlaylistName, + Path(name): Path<String>, ) -> Result<(), LektordError> { - let info = match state.verify_user(user_infos) { - LektorUser::Unverified(_, _) => PlaylistInfo::new(), - LektorUser::User(n, _) | LektorUser::Admin(n, _) => PlaylistInfo::new().user(n), - }; - Ok(state.database.playlist_new(name, info).await?) + let user = state + .verify_user(user_infos) + .maybe_user() + .map(|user| user.into_parts().0); + (state.database.playlists()) + .create(name, |plt| match user { + Ok(user) => plt.with_owner_sync(user), + _ => plt, + }) + .await?; + Ok(()) } /// Update the database from the remotes. The user must be the super user to do that. We can't use @@ -268,11 +301,7 @@ pub(crate) async fn adm_update( State(state): State<LektorStatePtr>, admin: LektorUser, ) -> Result<(), LektordError> { - let admin = match state.verify_user(admin) { - user @ LektorUser::Admin(_, _) => user, - user => return Err(anyhow!("user {user:?} is not an admin").into()), - }; - + let admin = state.verify_user(admin).maybe_admin()?; log::info!("launching update requested by {admin:?}"); std::thread::spawn(move || { let local = LocalSet::new(); @@ -281,7 +310,6 @@ pub(crate) async fn adm_update( let handle = state.database.update().await; let count = state.repo.update_with(&handle).await?; handle.finished().await; - state.database.refresh_playlist_contents().await; Ok::<_, anyhow::Error>(count) }) .await @@ -295,7 +323,7 @@ pub(crate) async fn adm_update( .max_blocking_threads(1024) .enable_all() .build() - .expect("failed to build tokio runtime to update database from repo") + .expect("can't build tokio runtime") .block_on(local); }); Ok(()) @@ -306,29 +334,35 @@ pub(crate) async fn adm_update( pub(crate) async fn adm_shutdown( State(state): State<LektorStatePtr>, admin: LektorUser, -) -> StatusCode { - match state.verify_user(admin) { - admin @ LektorUser::Admin(..) => { - log::info!("shutdown requested by {admin:?}"); - if let Some(shutdown) = state.shutdown.write().await.take() { - let _ = shutdown.send(()); - } - StatusCode::OK - } - _ => StatusCode::FORBIDDEN, - } +) -> Result<(), LektordError> { + let admin = state.verify_user(admin).maybe_admin()?; + log::info!("shutdown requested by {admin:?}"); + (state.shutdown.write().await.take()) + .map(|shutdown| _ = shutdown.send(())) + .context("failed to send shutdown signal")?; + Ok(()) } #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn get_queue_count( State(state): State<LektorStatePtr>, ) -> Json<[usize; PRIORITY_LENGTH]> { - Json(state.database.queue_count().await) + Json( + state + .queue() + .read(|content, _| content.count_per_level()) + .await, + ) } #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn get_history_count(State(state): State<LektorStatePtr>) -> Json<usize> { - Json(state.database.history_count().await) + Json( + state + .queue() + .read(|content, _| content.history_count()) + .await, + ) } /// Returns the karas in the indicated range of the queue. @@ -339,8 +373,8 @@ pub(crate) async fn get_queue_range( ) -> Json<Vec<(Priority, KId)>> { Json( state - .database - .queue((range.start_bound(), range.end_bound())) + .queue() + .read(|content, _| content.get((range.start_bound(), range.end_bound()))) .await, ) } @@ -350,26 +384,41 @@ pub(crate) async fn get_queue_range( pub(crate) async fn add_to_queue( State(state): State<LektorStatePtr>, Json(QueueAddAction { priority, action }): Json<QueueAddAction>, -) { +) -> Result<(), LektordError> { match action { - KaraFilter::KId(id) => state.database.queue_insert(priority, id).await, - KaraFilter::List(false, ids) => state.database.queue_insert_all(priority, ids).await, - KaraFilter::List(true, mut ids) => { - ids[..].shuffle(&mut thread_rng()); - state.database.queue_insert_all(priority, ids).await + KaraFilter::KId(id) => { + state + .queue() + .write(|content, _| content.insert(priority, id)) + .await } - KaraFilter::Playlist(true, plt) => { - if let Some(mut plt) = state.database.playlist_get_content(plt).await { - plt[..].shuffle(&mut thread_rng()); - state.database.queue_insert_all(priority, plt).await + KaraFilter::List(shuffle, mut ids) => { + if shuffle { + ids[..].shuffle(&mut thread_rng()); } + state + .queue() + .write(|content, _| content.insert_all(priority, ids)) + .await } - KaraFilter::Playlist(false, plt) => { - if let Some(plt) = state.database.playlist_get_content(plt).await { - state.database.queue_insert_all(priority, plt).await - } + KaraFilter::Playlist(shuffle, plt) => { + let karas = (state.database.playlists()) + .read(plt, |plt| match shuffle { + true => { + let mut content: Vec<_> = plt.iter_uniq_content().collect(); + content[..].shuffle(&mut thread_rng()); + content + } + false => plt.iter_seq_content().collect(), + }) + .await?; + state + .queue() + .write(|content, _| content.insert_all(priority, karas)) + .await } } + Ok(()) } /// Enqueue a kara in the queue, at a specific priority. @@ -378,7 +427,10 @@ pub(crate) async fn add_kid_to_queue( State(state): State<LektorStatePtr>, Path((prio, kid)): Path<(Priority, KId)>, ) { - state.database.queue_insert(prio, kid).await + state + .queue() + .write(|content, _| content.insert(prio, kid)) + .await } /// Remove a kara from the queue, only a specific priority. @@ -387,7 +439,36 @@ pub(crate) async fn delete_kid_from_queue( State(state): State<LektorStatePtr>, Path((prio, kid)): Path<(Priority, KId)>, ) { - state.database.queue_delete_all_from_level(prio, kid).await + state + .queue() + .write(|content, _| content.delete_all_from_level(prio, kid)) + .await +} + +/// Get the queue level +#[axum::debug_handler(state = LektorStatePtr)] +pub(crate) async fn get_queue_level( + State(state): State<LektorStatePtr>, + Path(prio): Path<Priority>, +) -> Json<Vec<KId>> { + Json( + state + .queue() + .read(|content, _| content.get_level(prio)) + .await, + ) +} + +/// Delete the queue level +#[axum::debug_handler(state = LektorStatePtr)] +pub(crate) async fn delete_queue_level( + State(state): State<LektorStatePtr>, + Path(prio): Path<Priority>, +) { + state + .queue() + .write(|content, _| content.delete_level(prio)) + .await } /// Shuffle a level of the queue. @@ -396,7 +477,10 @@ pub(crate) async fn shuffle_queue_level( State(state): State<LektorStatePtr>, Path(prio): Path<Priority>, ) { - state.database.queue_shuffle_level(prio).await + state + .queue() + .write(|content, _| content.shuffle_level(prio)) + .await } /// Get informations from the queue. @@ -405,20 +489,21 @@ pub(crate) async fn get_from_queue( State(state): State<LektorStatePtr>, Path(level): Path<Priority>, ) -> Json<Vec<KId>> { - Json(state.database.queue_get_level(level).await) + Json( + state + .queue() + .read(|content, _| content.get_level(level)) + .await, + ) } /// Remove something from the queue, can be a level, a kara, etc. #[axum::debug_handler(state = LektorStatePtr)] -pub(crate) async fn delete_from_queue( - State(state): State<LektorStatePtr>, - Path(what): Path<String>, -) { - match Priority::from_str(&what) { - Ok(prio) => state.database.queue_delete_level(prio).await, - // Not a priority, try to parse a KId and delete it. - _ => state.database.queue_delete_all_maybe_id(what).await, - } +pub(crate) async fn delete_from_queue(State(state): State<LektorStatePtr>, Path(kid): Path<KId>) { + state + .queue() + .write(|content, _| content.delete_all(kid)) + .await } /// Remove a kara from the history, each occurances. @@ -427,7 +512,10 @@ pub(crate) async fn delete_kid_from_history( State(state): State<LektorStatePtr>, Path(id): Path<KId>, ) { - state.database.history_delete_all(id).await + state + .queue() + .write(|content, _| content.history_delete_all(id)) + .await } /// Updates the karas in the indicated range of the queue. @@ -437,18 +525,35 @@ pub(crate) async fn update_queue_range( range: Range, Json(action): Json<QueueUpdateAction>, ) { - use QueueUpdateAction::*; match action { - Shuffle => state.database.queue_shuffle(range).await, - Delete => state.database.queue_delete(range).await, - MoveAfter(after) => state.database.queue_move_after(range, after).await, + QueueUpdateAction::Shuffle => { + state + .queue() + .write(|content, _| content.shuffle(range)) + .await + } + QueueUpdateAction::Delete => { + state + .queue() + .write(|content, _| content.delete(range)) + .await + } + QueueUpdateAction::MoveAfter(after) => { + state + .queue() + .write(|content, _| content.move_after(range, after)) + .await + } } } /// Removes the karas in the indicated range of the queue. #[axum::debug_handler(state = LektorStatePtr)] pub(crate) async fn delete_queue_range(State(state): State<LektorStatePtr>, range: Range) { - state.database.queue_delete(range).await; + state + .queue() + .write(|content, _| content.delete(range)) + .await } /// Returns the karas in the indicated range of the history. @@ -457,7 +562,12 @@ pub(crate) async fn get_history_range( State(state): State<LektorStatePtr>, range: Range, ) -> Json<Vec<KId>> { - Json(state.database.history(range).await) + Json( + state + .queue() + .write(|content, _| content.history(range)) + .await, + ) } /// Removes the karas in the indicated range of the history. @@ -466,5 +576,10 @@ pub(crate) async fn delete_history_range( State(state): State<LektorStatePtr>, range: Range, ) -> Json<Vec<KId>> { - Json(state.database.history_delete(range).await) + Json( + state + .queue() + .write(|content, _| content.history_delete(range)) + .await, + ) } diff --git a/lektord/src/c_wrapper/commands.rs b/lektord/src/c_wrapper/commands.rs index 6132438b77931633a21745bde56736cf5ee58acc..c12fe75a376d7976f2862d0f76b06269a51dde1f 100644 --- a/lektord/src/c_wrapper/commands.rs +++ b/lektord/src/c_wrapper/commands.rs @@ -1,6 +1,6 @@ use super::{PlayerEvent, STATE}; use anyhow::{bail, Context, Error, Result}; -use lektor_nkdb::PlayState; +use lektor_payloads::{KId, PlayState}; use std::{ffi::*, path::Path, ptr::NonNull}; /// Safe wrapper around `mod_stop_playback`. Stops the playback. Same as [player_toggle_pause], don't update @@ -77,7 +77,7 @@ pub(crate) fn player_get_elapsed() -> Result<i64> { } /// Safe wrapper around `mod_load_file` Tell the player to load a file by its path. -pub(crate) fn player_load_file(path: impl AsRef<Path>, id: u64) -> Result<()> { +pub(crate) fn player_load_file(path: impl AsRef<Path>, id: KId) -> Result<()> { extern "C" { fn mod_load_file(_: NonNull<c_char>, _: u64) -> c_int; } @@ -86,7 +86,7 @@ pub(crate) fn player_load_file(path: impl AsRef<Path>, id: u64) -> Result<()> { .to_str() .ok_or_else(|| Error::msg("path contained non-utf8 characters"))?; let cstr = CString::new(path)?; - (0 == unsafe { mod_load_file(NonNull::new_unchecked(cstr.as_ptr() as *mut _), id) }) + (0 == unsafe { mod_load_file(NonNull::new_unchecked(cstr.as_ptr() as *mut _), id.into()) }) .then_some(()) .with_context(|| format!("failed load file {path}")) } diff --git a/lektord/src/c_wrapper/mod.rs b/lektord/src/c_wrapper/mod.rs index 23c595e596987ef82784757a8af6977d5ec160e5..97b4d3c79288753929436443d580475a9c14df34 100644 --- a/lektord/src/c_wrapper/mod.rs +++ b/lektord/src/c_wrapper/mod.rs @@ -2,7 +2,6 @@ use crate::LektorStatePtr; use anyhow::{bail, ensure, Context, Result}; -use lektor_nkdb::PlayState; use lektor_utils::config::LektorPlayerConfig; use std::{ ffi::*, @@ -69,36 +68,51 @@ pub(crate) fn init_player_module(ptr: LektorStatePtr, config: LektorPlayerConfig force_x11, } = config; - unsafe { - // We must use non-async stuff to propagate things from the player module back to the tokio - // runtime... The code called form C/C++ will use `sender.blocking_send`. - let (sender, mut receiver) = tokio::sync::mpsc::channel::<PlayerEvent>(10); - let weak_ptr = Arc::downgrade(&ptr); - let handle = tokio::task::spawn(async move { - while let Some(event) = receiver.recv().await { - use {LktPlayState as LPS, PlayState as PS}; - match event { - PlayerEvent::SetPlayState(state) => match LktPlayState::try_from(state) { - Ok(LPS::Stop) => ptr.database.set_playstate(PS::Stop).await, - Ok(LPS::Play) => ptr.database.set_playstate(PS::Play).await, - Ok(LPS::Pause) => ptr.database.set_playstate(PS::Pause).await, - Ok(LPS::Toggle) => ptr.database.toggle_playstate().await, - Err(e) => log::error!("invalid playstate: {e}"), - }, - PlayerEvent::PlayNext => { - if let Some(ptr) = weak_ptr.upgrade() { - let _ = ptr.play_next().await.map_err(|e| log::error!("{e}")); - } + // We must use non-async stuff to propagate things from the player module back to the tokio + // runtime... The code called form C/C++ will use `sender.blocking_send`. + let (sender, mut receiver) = tokio::sync::mpsc::channel::<PlayerEvent>(10); + let weak_ptr = Arc::downgrade(&ptr); + let handle = tokio::task::spawn(async move { + while let Some(event) = receiver.recv().await { + use {lektor_payloads::PlayState as PS, LktPlayState as LPS}; + match event { + PlayerEvent::SetPlayState(state) => match LktPlayState::try_from(state) { + Ok(state @ (LPS::Stop | LPS::Play | LPS::Pause)) => { + (ptr.queue()) + .write(|_, stored| { + stored.set(match state { + LPS::Pause => PS::Pause, + LPS::Play => PS::Play, + _ => PS::Stop, + }) + }) + .await + } + Ok(LPS::Toggle) => { + (ptr.queue()) + .write(|_, stored| match stored.get() { + PS::Stop | PS::Pause => stored.set(PS::Play), + PS::Play => stored.set(PS::Pause), + }) + .await + } + Err(e) => log::error!("invalid playstate: {e}"), + }, + PlayerEvent::PlayNext => { + if let Some(ptr) = weak_ptr.upgrade() { + let _ = ptr.play_next().await.map_err(|e| log::error!("{e}")); } - PlayerEvent::PlayPrev => { - if let Some(ptr) = weak_ptr.upgrade() { - let _ = ptr.play_previous().await.map_err(|e| log::error!("{e}")); - } + } + PlayerEvent::PlayPrev => { + if let Some(ptr) = weak_ptr.upgrade() { + let _ = ptr.play_previous().await.map_err(|e| log::error!("{e}")); } } } - }); + } + }); + unsafe { // Set the state for the C/C++ code ensure!( STATE.set((sender, handle)).is_ok(), diff --git a/lektord/src/c_wrapper/playstate.rs b/lektord/src/c_wrapper/playstate.rs index d77d0fc511d11ea3f2398767ce92078cc216480c..9221061f7576fce455156664eeff90c2339e62cf 100644 --- a/lektord/src/c_wrapper/playstate.rs +++ b/lektord/src/c_wrapper/playstate.rs @@ -1,4 +1,4 @@ -use lektor_nkdb::PlayState; +use lektor_payloads::PlayState; use std::ffi::c_int; /// The play state that can be send from/to the player module. They don't directly map to what is diff --git a/lektord/src/lib.rs b/lektord/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..fa39c033ffc7a7340ad04dc58fe984a69f2ae51c --- /dev/null +++ b/lektord/src/lib.rs @@ -0,0 +1,106 @@ +mod app; +mod c_wrapper; +pub mod cmd; +pub mod config; +mod error; + +include!(concat!(env!("OUT_DIR"), "/lektord_build_infos.rs")); + +use crate::{app::*, config::*, error::*}; +use anyhow::{Context, Result}; +use futures::{stream::FuturesUnordered, TryStreamExt as _}; +use hyper::service::service_fn; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder as ServerBuilder, +}; +use std::net::SocketAddr; +use tokio::{net::TcpListener, signal, sync::oneshot::Receiver}; +use tower::Service; + +/// Launches the server, for each socket to listen to we have one task. We do that to be able to +/// handle each sockets in a concurrent way. +pub async fn launch_server(config: LektorConfig) -> Result<()> { + // Write to apply any changes... + log::info!("starting the lektord daemon"); + lektor_utils::config::write_config_async("lektord", config.clone()).await?; + + // Init the application. + let addrs = config.listen.clone(); + let (app, shutdown) = app(config).await.context("failed to build service")?; + + // Launch an instance for each socket to listen to. + FuturesUnordered::from_iter(addrs.into_iter().map(|listen| async move { + TcpListener::bind(listen) + .await + .map(|socket| (listen, socket)) + .with_context(|| format!("failed to bind to {listen}")) + })) + .try_collect::<Vec<_>>() + .await? + .into_iter() + .for_each(|(addr, socket)| { + tokio::spawn(server_instance(addr, socket, app.clone())); + }); + + // Wait for terminazon... + shutdown_signal(shutdown).await; + Ok(()) +} + +/// Have the instance of the server for one socket that we listen to. For each client we will +/// create a new task to be able to handle clients concurrently for one socket. +async fn server_instance(addr: SocketAddr, socket: TcpListener, app: axum::Router) { + loop { + let Ok((stream, client)) = socket.accept().await else { + return log::error!("failed to accept socket at {addr}"); + }; + let app = app.clone(); // One thread per client, they all share the same state! + tokio::spawn(async move { + ServerBuilder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service_fn(|r| app.clone().call(r))) + .await + .inspect_err(|e| log::error!("failed to serve {client} from {addr}: {e}")) + }); + } +} + +/// Gracefull ctrl+c handling. +async fn shutdown_signal(shutdown: Receiver<()>) { + let shutdown = async { + let _ = shutdown.await; + log::info!("shutdown signal!") + }; + + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + log::info!("ctrl+c signal!") + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + log::info!("terminate signal!") + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + _ = shutdown => {}, + } + + log::info!("received termination signal shutting down, try to unregister the state from the player module"); + let _ = c_wrapper::close_player_module() + .await + .map_err(|e| log::error!("{e}")); + log::info!("will now call exit(EXIT_SUCCESS)"); + std::process::exit(0); +} diff --git a/lektord/src/main.rs b/lektord/src/main.rs index 42035fdc16fa3c0dd06d1612ea33f38994deefc0..7d532034dcb3a54adc94711fb59e13f3fd320fae 100644 --- a/lektord/src/main.rs +++ b/lektord/src/main.rs @@ -1,28 +1,10 @@ -mod app; -pub mod c_wrapper; -mod cmd; -mod config; -mod error; - -include!(concat!(env!("OUT_DIR"), "/lektord_build_infos.rs")); - -use crate::{app::*, config::*, error::*}; use anyhow::{Context, Result}; -use cmd::SubCommand; -use futures::{stream::FuturesUnordered, TryStreamExt as _}; use lektor_utils::*; - -// Server things - -use hyper::service::service_fn; -use hyper_util::{ - rt::{TokioExecutor, TokioIo}, - server::conn::auto::Builder as ServerBuilder, +use lektord::{ + cmd::{self, SubCommand}, + config::*, }; -use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::{net::TcpListener, signal, sync::oneshot::Receiver}; -use tower::Service; fn main() -> Result<()> { logger::Builder::default() @@ -62,95 +44,8 @@ fn main() -> Result<()> { .enable_all() .thread_stack_size(3 * 1024 * 1024) // 3Mio for each thread, should be enaugh .build()? - .block_on(launch_server(config)), + .block_on(lektord::launch_server(config)), args => unreachable!("{args:?}"), } } - -/// Launches the server, for each socket to listen to we have one task. We do that to be able to -/// handle each sockets in a concurrent way. -async fn launch_server(config: LektorConfig) -> Result<()> { - // Write to apply any changes... - log::info!("starting the lektord daemon"); - lektor_utils::config::write_config_async("lektord", config.clone()).await?; - - // Init the application. - let addrs = config.listen.clone(); - let (app, shutdown) = app(config).await.context("failed to build service")?; - - // Launch an instance for each socket to listen to. - FuturesUnordered::from_iter(addrs.into_iter().map(|listen| async move { - TcpListener::bind(listen) - .await - .map(|socket| (listen, socket)) - .with_context(|| format!("failed to bind to {listen}")) - })) - .try_collect::<Vec<_>>() - .await? - .into_iter() - .for_each(|(addr, socket)| { - tokio::spawn(server_instance(addr, socket, app.clone())); - }); - - // Wait for terminazon... - shutdown_signal(shutdown).await; - Ok(()) -} - -/// Have the instance of the server for one socket that we listen to. For each client we will -/// create a new task to be able to handle clients concurrently for one socket. -async fn server_instance(addr: SocketAddr, socket: TcpListener, app: axum::Router) { - loop { - let Ok((stream, client)) = socket.accept().await else { - return log::error!("failed to accept socket at {addr}"); - }; - let app = app.clone(); // One thread per client, they all share the same state! - tokio::spawn(async move { - ServerBuilder::new(TokioExecutor::new()) - .serve_connection(TokioIo::new(stream), service_fn(|r| app.clone().call(r))) - .await - .inspect_err(|e| log::error!("failed to serve {client} from {addr}: {e}")) - }); - } -} - -/// Gracefull ctrl+c handling. -async fn shutdown_signal(shutdown: Receiver<()>) { - let shutdown = async { - let _ = shutdown.await; - log::info!("shutdown signal!") - }; - - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - log::info!("ctrl+c signal!") - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - log::info!("terminate signal!") - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - _ = shutdown => {}, - } - - log::info!("received termination signal shutting down, try to unregister the state from the player module"); - let _ = c_wrapper::close_player_module() - .await - .map_err(|e| log::error!("{e}")); - log::info!("will now call exit(EXIT_SUCCESS)"); - std::process::exit(0); -} diff --git a/lkt/Cargo.toml b/lkt/Cargo.toml index c97c8633a085fc71c1dff7bf628fb4d240f20aea..6924ce8f823cfdc0156d62c06c7fa1a6eed95efa 100644 --- a/lkt/Cargo.toml +++ b/lkt/Cargo.toml @@ -11,7 +11,6 @@ description = "Simple command line utility to interact with the lektord daemon" tokio.workspace = true reqwest.workspace = true futures.workspace = true -async-trait.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/lkt/src/args/mod.rs b/lkt/src/args/mod.rs index e4831946cb44c1bbef1e3aa2cc516eaa91fa6b06..a9814e26cbba14a7a010ab94fad58028370a81ac 100644 --- a/lkt/src/args/mod.rs +++ b/lkt/src/args/mod.rs @@ -2,6 +2,7 @@ mod parsers; use clap::{Parser, Subcommand}; use clap_complete::Shell; +use lektor_payloads::KId; #[derive(Parser, Debug)] #[command( author @@ -206,7 +207,7 @@ pub enum SubCommand { , long = "info" , value_name = "ID" )] - get: Option<u64>, + get: Option<KId>, /// Search kara in a playlist with a query. The first element is the name of the playlist. #[arg( action = clap::ArgAction::Append diff --git a/lkt/src/lib.rs b/lkt/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..db3d322c133be68427879458ecd07dc0906b0dcb --- /dev/null +++ b/lkt/src/lib.rs @@ -0,0 +1,346 @@ +#![forbid(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/lkt_build_infos.rs")); + +pub mod args; +pub mod config; +pub mod manpage; + +use crate::{args::*, config::*}; +use anyhow::{anyhow, Context as _, Result}; +use futures::{ + stream::{self, FuturesUnordered}, + StreamExt, +}; +use lektor_lib::*; +use lektor_payloads::*; +use std::{borrow::Cow, ops::RangeBounds}; + +pub async fn collect_karas(config: &ConnectConfig, ids: Vec<KId>) -> Result<Vec<Kara>> { + let count = ids.len(); + let res: Vec<_> = stream::iter(ids.into_iter()) + .then(|id| requests::get_kara_by_kid(config, id)) + .filter_map(|res| async { res.map_err(|err| log::error!("{err}")).ok() }) + .collect::<_>() + .await; + res.len() + .eq(&count) + .then_some(res) + .ok_or(anyhow!("got errors, didn't received all the karas' data")) +} + +pub async fn simple_karas_print(config: &ConnectConfig, ids: Vec<KId>) -> Result<()> { + let res = collect_karas(config, ids).await?; + let count = res.len().to_string().len(); + res.into_iter() + .enumerate() + .for_each(|(i, k)| println!("[{i:0>count$}] {k}")); + Ok(()) +} + +pub async fn exec_lkt(config: LktConfig, cmd: SubCommand) -> Result<()> { + // Write to apply changes... + lektor_utils::config::write_config_async("lkt", config.clone()).await?; + let config = ConnectConfig::from(config); + let config = &config; + + use crate::args::SubCommand::*; + match cmd { + // ============= // + // Queue options // + // ============= // + + // Display current kara in one line (for status lines...) + Playback { current: true, .. } => { + let PlayStateWithCurrent { state, current } = requests::get_status(config).await?; + match current { + None => println!("[{state:?}]"), + Some((kid, elapsed, duration)) => { + let current = requests::get_kara_by_kid(config, kid).await?.to_string(); + println!("[{state:?}] {elapsed}/{duration} {current}"); + } + } + Ok(()) + } + + // Display the status of lektord + Playback { status: true, .. } => { + let Infos { + version, + last_epoch, + } = requests::get_infos(config).await?; + let PlayStateWithCurrent { state, current } = requests::get_status(config).await?; + let current = match current { + None => None, + Some((kid, elapsed, duration)) => Some(( + requests::get_kara_by_kid(config, kid).await?, + elapsed, + duration, + )), + }; + let queue_counts = requests::get_queue_count(config).await?; + let history_count = requests::get_history_count(config).await?; + let last_epoch = match last_epoch { + Some(num) => num.to_string().into(), + None => Cow::Borrowed("None"), + }; + + println!("Version: {version}"); + println!("Playback State: {state:?}"); + if let Some((current, elapsed, duration)) = current { + println!("Current Kara: {current}"); + println!("Kara elapsed: {elapsed}s"); + println!("Kara duration: {duration}s"); + } + println!("Karas in Queue: {queue_counts:?}"); + println!("Karas in Hustory: {history_count}"); + println!("Database epoch: {last_epoch}"); + + Ok(()) + } + + // Play from a position in the queue + Playback { + play: Some(play), .. + } => requests::play_from_position(config, play.unwrap_or_default()).await, + + // Toggle the play state + Playback { + pause: Some(None), .. + } => requests::toggle_playback_state(config).await, + + // Pause the playback + Playback { + pause: Some(Some(true)), + .. + } => requests::set_playback_state(config, PlayState::Pause).await, + + // Force to resume the playback + Playback { + pause: Some(Some(false)), + .. + } => requests::set_playback_state(config, PlayState::Play).await, + + Playback { next: true, .. } => requests::play_next(config).await, + Playback { prev: true, .. } => requests::play_previous(config).await, + Playback { stop: true, .. } => requests::set_playback_state(config, PlayState::Stop).await, + + // ============= // + // Queue options // + // ============= // + + // List kara in the queue + Queue { pos: Some(pos), .. } => { + let pos = pos.unwrap_or_default(); + let ids = requests::get_queue_range(config, pos).await?; + let prios: Vec<_> = ids.iter().map(|(prio, _)| *prio).collect(); + let karas: Vec<_> = + collect_karas(config, ids.into_iter().map(|(_, id)| id).collect()).await?; + let count = match pos { + Range::From(_) | Range::Full => prios.len().max(karas.len()), + Range::To(max) | Range::Bound(_, max) => max - 1, + Range::ToInclusive(max) | Range::BoundInclusive(_, max) => max, + }; + let start = match pos.start_bound() { + std::ops::Bound::Included(start) => *start, + std::ops::Bound::Excluded(start) => start - 1, + std::ops::Bound::Unbounded => 0, + }; + let count = count.to_string().len(); + let space = PRIORITY_VALUES + .iter() + .map(|prio| prio.as_str().len()) + .max() + .expect("oupsy daisy"); + std::iter::zip(prios, karas) + .enumerate() + .for_each(|(i, (l, k))| println!("[{:0>count$}] {l:>space$}: {k}", start + i)); + Ok(()) + } + + // Add karas to the queue + Queue { add: Some(add), .. } => { + let AddArguments { level: lvl, query } = add.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Database, [query]).await?; + let add = QueueAddAction { + priority: lvl, + action: KaraFilter::List(true, ids), + }; + requests::add_to_queue(config, add).await + } + + // Remove kara from the queue by their position + Queue { + remove: Some(r), .. + } => requests::remove_range_from_queue(config, r.unwrap_or_default()).await, + + // Shuffle the queue. + Queue { + shuffle: Some(shuffle), + .. + } => requests::shuffle_queue_range(config, shuffle.unwrap_or_default()).await, + + // =============== // + // History options // + // =============== // + History { clear: true, .. } => requests::remove_range_from_history(config, ..).await, + History { pos: Some(pos), .. } => { + let ids = requests::get_history_range(config, pos.unwrap_or_default()).await?; + simple_karas_print(config, ids).await + } + + // ============== // + // Search options // + // ============== // + Search { + database: Some(regex), + .. + } => { + let regex = regex.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Database, [regex]).await?; + simple_karas_print(config, ids).await + } + + Search { + count: Some(regex), .. + } => { + let regex = regex.join(" "); + let count = requests::count_karas(config, SearchFrom::Database, regex.parse()?).await?; + println!("Search in Database: {regex}"); + println!("Matched Count: {count}"); + Ok(()) + } + + Search { get: Some(kid), .. } => { + let kara = requests::get_kara_by_kid(config, kid).await?; + dbg!(kara); + Ok(()) + } + + Search { + plt: Some(mut args), + .. + } => { + let name = args.remove(0).parse()?; + let regex = args.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Playlist(name), [regex]).await?; + simple_karas_print(config, ids).await + } + + Search { + queue: Some(args), .. + } => { + let regex = args.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Queue, [regex]).await?; + simple_karas_print(config, ids).await + } + + Search { + history: Some(args), + .. + } => { + let regex = args.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::History, [regex]).await?; + simple_karas_print(config, ids).await + } + + // ================ // + // Playlist options // + // ================ // + + // Create a playlist + Playlist { + create: Some(n), .. + } => requests::create_playlist(config, n.into()).await, + + // Delete a playlist and all its content. + Playlist { + destroy: Some(n), .. + } => requests::delete_playlist(config, n.into()).await, + + // List all the playlists + Playlist { + list: Some(None), .. + } => { + let plts = stream::iter(requests::get_playlists(config).await?) + .then(|(id, _)| async move { requests::get_playlist_info(config, id).await }) + .collect::<FuturesUnordered<_>>() + .await + .into_iter() + .collect::<Result<Vec<_>, _>>()?; + + let count = plts.len().to_string().len(); + let padding: String = " ".repeat(count); + for (idx, playlist) in plts.into_iter().enumerate() { + if idx != 0 { + println!(); + } + println!("{idx:0>count$}: Playlist {}", playlist.name()); + if playlist.owners().count() != 0 { + println!( + "{padding} Owned by {}", + playlist.owners().collect::<Vec<&str>>().join(",") + ); + } + println!( + "{padding} Created at {}", + playlist.created_at().format("%Y-%m-%d %H:%M:%S") + ); + println!( + "{padding} Last updated at {}", + playlist.updated_at().format("%Y-%m-%d %H:%M:%S") + ); + } + Ok(()) + } + + // List the content of a playlist + Playlist { + list: Some(Some(n)), + .. + } => { + let id = requests::get_playlists(config) + .await? + .into_iter() + .find_map(|(id, name)| (n == name).then_some(id)) + .with_context(|| format!("failed to find a playlist with the name {n}"))?; + let ids = requests::get_playlist_content(config, id).await?; + simple_karas_print(config, ids).await + } + + // Add something to a playlist. + Playlist { + add: Some(mut args), + .. + } => { + let name = args.remove(0).into(); + let rgx = args.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Database, [rgx]).await?; + let add = KaraFilter::List(true, ids); + requests::add_to_playlist(config, name, add).await + } + + // Remove something from a playlist + Playlist { + remove: Some(mut args), + .. + } => { + let name = args.remove(0).into(); + let rgx = args.join(" ").parse()?; + let ids = requests::search_karas(config, SearchFrom::Database, [rgx]).await?; + let remove = KaraFilter::List(true, ids); + requests::remove_from_playlist(config, name, remove).await + } + + // ============= // + // Admin options // + // ============= // + Admin { kill: true, .. } => requests::shutdown_lektord(config).await, + Admin { update: true, .. } => requests::update_from_repo(config).await, + + // ============================================== // + // Can't be there... unless no flag was passed... // + // ============================================== // + _ => unreachable!("{cmd:#?}"), + } +} diff --git a/lkt/src/main.rs b/lkt/src/main.rs index 9afa21b2daa35d687df16e6fe43163c2fec07ecc..8bc0685ee44e68f46d3bf3c09c6b93ce25967375 100644 --- a/lkt/src/main.rs +++ b/lkt/src/main.rs @@ -1,19 +1,12 @@ #![forbid(unsafe_code)] -include!(concat!(env!("OUT_DIR"), "/lkt_build_infos.rs")); - -mod args; -mod config; -mod manpage; - -use crate::{args::*, config::*}; -use anyhow::{anyhow, Context as _, Result}; -use chrono::TimeZone; -use futures::{stream, StreamExt}; -use lektor_lib::*; -use lektor_payloads::*; +use anyhow::{Context as _, Result}; use lektor_utils::*; -use std::{borrow::Cow, ops::RangeBounds}; +use lkt::{ + args::{self, *}, + config::*, + manpage, +}; fn main() -> Result<()> { logger::Builder::default() @@ -46,326 +39,6 @@ fn main() -> Result<()> { tokio::runtime::Builder::new_current_thread() .enable_all() .build()? - .block_on(exec_lkt(config, args.action))?; + .block_on(lkt::exec_lkt(config, args.action))?; Ok(()) } - -async fn collect_karas(config: &ConnectConfig, ids: Vec<KId>) -> Result<Vec<Kara>> { - let count = ids.len(); - let res: Vec<_> = stream::iter(ids.into_iter()) - .then(|id| requests::get_kara_by_kid(config, id)) - .filter_map(|res| async { res.map_err(|err| log::error!("{err}")).ok() }) - .collect::<_>() - .await; - res.len() - .eq(&count) - .then_some(res) - .ok_or(anyhow!("got errors, didn't received all the karas' data")) -} - -async fn simple_karas_print(config: &ConnectConfig, ids: Vec<KId>) -> Result<()> { - let res = collect_karas(config, ids).await?; - let count = res.len().to_string().len(); - res.into_iter() - .enumerate() - .for_each(|(i, k)| println!("[{i:0>count$}] {k}")); - Ok(()) -} - -async fn exec_lkt(config: LktConfig, cmd: SubCommand) -> Result<()> { - // Write to apply changes... - lektor_utils::config::write_config_async("lkt", config.clone()).await?; - let config = ConnectConfig::from(config); - let config = &config; - - use crate::args::SubCommand::*; - match cmd { - // ============= // - // Queue options // - // ============= // - - // Display current kara in one line (for status lines...) - Playback { current: true, .. } => { - let PlayStateWithCurrent { state, current } = requests::get_status(config).await?; - match current { - None => println!("[{state:?}]"), - Some((kid, elapsed, duration)) => { - let current = requests::get_kara_by_kid(config, kid).await?.to_string(); - println!("[{state:?}] {elapsed}/{duration} {current}"); - } - } - Ok(()) - } - - // Display the status of lektord - Playback { status: true, .. } => { - let Infos { - version, - last_epoch, - } = requests::get_infos(config).await?; - let PlayStateWithCurrent { state, current } = requests::get_status(config).await?; - let current = match current { - None => None, - Some((kid, elapsed, duration)) => Some(( - requests::get_kara_by_kid(config, kid).await?, - elapsed, - duration, - )), - }; - let queue_counts = requests::get_queue_count(config).await?; - let history_count = requests::get_history_count(config).await?; - let last_epoch = match last_epoch { - Some(num) => num.to_string().into(), - None => Cow::Borrowed("None"), - }; - - println!("Version: {version}"); - println!("Playback State: {state:?}"); - if let Some((current, elapsed, duration)) = current { - println!("Current Kara: {current}"); - println!("Kara elapsed: {elapsed}s"); - println!("Kara duration: {duration}s"); - } - println!("Karas in Queue: {queue_counts:?}"); - println!("Karas in Hustory: {history_count}"); - println!("Database epoch: {last_epoch}"); - - Ok(()) - } - - // Play from a position in the queue - Playback { - play: Some(play), .. - } => requests::play_from_position(config, play.unwrap_or_default()).await, - - // Toggle the play state - Playback { - pause: Some(None), .. - } => requests::toggle_playback_state(config).await, - - // Pause the playback - Playback { - pause: Some(Some(true)), - .. - } => requests::set_playback_state(config, PlayState::Pause).await, - - // Force to resume the playback - Playback { - pause: Some(Some(false)), - .. - } => requests::set_playback_state(config, PlayState::Play).await, - - Playback { next: true, .. } => requests::play_next(config).await, - Playback { prev: true, .. } => requests::play_previous(config).await, - Playback { stop: true, .. } => requests::set_playback_state(config, PlayState::Stop).await, - - // ============= // - // Queue options // - // ============= // - - // List kara in the queue - Queue { pos: Some(pos), .. } => { - let pos = pos.unwrap_or_default(); - let ids = requests::get_queue_range(config, pos).await?; - let prios: Vec<_> = ids.iter().map(|(prio, _)| *prio).collect(); - let karas: Vec<_> = - collect_karas(config, ids.into_iter().map(|(_, id)| id).collect()).await?; - let count = match pos { - Range::From(_) | Range::Full => prios.len().max(karas.len()), - Range::To(max) | Range::Bound(_, max) => max - 1, - Range::ToInclusive(max) | Range::BoundInclusive(_, max) => max, - }; - let start = match pos.start_bound() { - std::ops::Bound::Included(start) => *start, - std::ops::Bound::Excluded(start) => start - 1, - std::ops::Bound::Unbounded => 0, - }; - let count = count.to_string().len(); - let space = PRIORITY_VALUES - .iter() - .map(|prio| prio.as_str().len()) - .max() - .expect("oupsy daisy"); - std::iter::zip(prios, karas) - .enumerate() - .for_each(|(i, (l, k))| println!("[{:0>count$}] {l:>space$}: {k}", start + i)); - Ok(()) - } - - // Add karas to the queue - Queue { add: Some(add), .. } => { - let AddArguments { level: lvl, query } = add.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Database, query).await?; - let add = QueueAddAction { - priority: lvl, - action: KaraFilter::List(true, ids), - }; - requests::add_to_queue(config, add).await - } - - // Remove kara from the queue by their position - Queue { - remove: Some(r), .. - } => requests::remove_range_from_queue(config, r.unwrap_or_default()).await, - - // Shuffle the queue. - Queue { - shuffle: Some(shuffle), - .. - } => requests::shuffle_queue_range(config, shuffle.unwrap_or_default()).await, - - // =============== // - // History options // - // =============== // - History { clear: true, .. } => requests::remove_range_from_history(config, ..).await, - History { pos: Some(pos), .. } => { - let ids = requests::get_history_range(config, pos.unwrap_or_default()).await?; - simple_karas_print(config, ids).await - } - - // ============== // - // Search options // - // ============== // - Search { - database: Some(regex), - .. - } => { - let regex = regex.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Database, regex).await?; - simple_karas_print(config, ids).await - } - - Search { - count: Some(regex), .. - } => { - let regex = regex.join(" "); - let count = requests::count_karas(config, SearchFrom::Database, regex.parse()?).await?; - println!("Search in Database: {regex}"); - println!("Matched Count: {count}"); - Ok(()) - } - - Search { get: Some(id), .. } => { - let kara = requests::get_kara_by_id(config, id).await?; - dbg!(kara); - Ok(()) - } - - Search { - plt: Some(mut args), - .. - } => { - let name = args.remove(0).parse()?; - let regex = args.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Playlist(name), regex).await?; - simple_karas_print(config, ids).await - } - - Search { - queue: Some(args), .. - } => { - let regex = args.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Queue, regex).await?; - simple_karas_print(config, ids).await - } - - Search { - history: Some(args), - .. - } => { - let regex = args.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::History, regex).await?; - simple_karas_print(config, ids).await - } - - // ================ // - // Playlist options // - // ================ // - - // Create a playlist - Playlist { - create: Some(n), .. - } => requests::create_playlist(config, n.into()).await, - - // Delete a playlist and all its content. - Playlist { - destroy: Some(n), .. - } => requests::delete_playlist(config, n.into()).await, - - // List all the playlists - Playlist { - list: Some(None), .. - } => { - let plts = requests::get_playlists(config).await?; - let count = plts.len().to_string().len(); - let padding: String = " ".repeat(count); - for (idx, (name, infos)) in plts.into_iter().enumerate() { - let PlaylistInfo { - user, - created_at, - updated_at, - } = infos; - if idx != 0 { - println!(); - } - println!("{idx:0>count$}: Playlist {name}"); - if let Some(user) = user { - println!("{padding} Created by {user}"); - } - if let Some(time) = chrono::Local.timestamp_opt(created_at, 0).latest() { - let time = time.format("%Y-%m-%d %H:%M:%S"); - println!("{padding} Created at {time}"); - }; - if let Some(time) = chrono::Local.timestamp_opt(updated_at, 0).latest() { - let time = time.format("%Y-%m-%d %H:%M:%S"); - println!("{padding} Last updated at {time}"); - }; - } - Ok(()) - } - - // List the content of a playlist - Playlist { - list: Some(Some(n)), - .. - } => { - let ids = requests::get_playlist_content(config, n.into()).await?; - simple_karas_print(config, ids).await - } - - // Add something to a playlist. - Playlist { - add: Some(mut args), - .. - } => { - let name = args.remove(0).into(); - let rgx = args.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Database, rgx).await?; - let add = KaraFilter::List(true, ids); - requests::add_to_playlist(config, name, add).await - } - - // Remove something from a playlist - Playlist { - remove: Some(mut args), - .. - } => { - let name = args.remove(0).into(); - let rgx = args.join(" ").parse()?; - let ids = requests::search_karas(config, SearchFrom::Database, rgx).await?; - let remove = KaraFilter::List(true, ids); - requests::remove_from_playlist(config, name, remove).await - } - - // ============= // - // Admin options // - // ============= // - Admin { kill: true, .. } => requests::shutdown_lektord(config).await, - Admin { update: true, .. } => requests::update_from_repo(config).await, - - // ============================================== // - // Can't be there... unless no flag was passed... // - // ============================================== // - _ => unreachable!("{cmd:#?}"), - } -}