diff options
Diffstat (limited to '')
23 files changed, 7554 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/.envrc b/pkgs/by-name/mp/mpdpopm/.envrc new file mode 100644 index 00000000..9f477e71 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/.envrc @@ -0,0 +1,22 @@ +#!/usr/bin/env sh + +# Mpdpopm - A mpd rating tracker +# +# Copyright (C) 2026 Benedikt Peetz, Michael Herstine <sp1ff@pobox.com> <benedikt.peetz@b-peetz.de, sp1ff@pobox.com> +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of Mpdpopm. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/agpl.txt>. + +use flake || use nix +watch_file flake.nix + +PATH_add ./scripts +PATH_add ./target/debug/ +PATH_add ./target/release/ + +if on_git_branch; then + echo && git status --short --branch +fi diff --git a/pkgs/by-name/mp/mpdpopm/.gitignore b/pkgs/by-name/mp/mpdpopm/.gitignore new file mode 100644 index 00000000..c80d7eef --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/.gitignore @@ -0,0 +1,16 @@ +# Mpdpopm - A mpd rating tracker +# +# Copyright (C) 2026 Benedikt Peetz, Michael Herstine <sp1ff@pobox.com> <benedikt.peetz@b-peetz.de, sp1ff@pobox.com> +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of Mpdpopm. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/agpl.txt>. + +# build +/target +/result + +# dev env +.direnv diff --git a/pkgs/by-name/mp/mpdpopm/Cargo.lock b/pkgs/by-name/mp/mpdpopm/Cargo.lock new file mode 100644 index 00000000..fbedffd4 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/Cargo.lock @@ -0,0 +1,1433 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mpdpopm" +version = "0.1.0" +dependencies = [ + "async-trait", + "backtrace", + "boolinator", + "chrono", + "clap", + "errno", + "futures", + "lalrpop", + "lalrpop-util", + "lazy_static", + "os_str_bytes", + "pin-project", + "regex", + "serde", + "serde_json", + "snafu", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_str_bytes" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eceb7b5d757011a87d08eb2123db15d87fb0c281f65d101ce30a1e96c3ad5c" +dependencies = [ + "memchr", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "backtrace", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/pkgs/by-name/mp/mpdpopm/Cargo.toml b/pkgs/by-name/mp/mpdpopm/Cargo.toml new file mode 100644 index 00000000..ccadfabb --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/Cargo.toml @@ -0,0 +1,45 @@ +# Mpdpopm - A mpd rating tracker +# +# Copyright (C) 2026 Benedikt Peetz, Michael Herstine <sp1ff@pobox.com> <benedikt.peetz@b-peetz.de, sp1ff@pobox.com> +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of Mpdpopm. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/agpl.txt>. + +[package] +name = "mpdpopm" +description = "Maintain ratings & playcounts for your mpd server" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" +homepage = "" +repository = "https://git.vhack.eu/bpeetz/nixos-config" +authors = ["Benedikt Peetz", "Mechael Herstine"] +keywords = ["mpd", "music", "daemon"] +categories = ["multimedia", "network-programming", "database"] + +[build-dependencies] +lalrpop = { version = "0.22", features = ["lexer"] } + +[dependencies] +async-trait = "0.1" +backtrace = "0.3" +boolinator = "2.4" +chrono = "0.4" +clap = {version = "4.5", features = ["derive"]} +errno = "0.3" +futures = "0.3" +lalrpop-util = { version = "0.22", features = ["lexer"] } +lazy_static = "1.5" +os_str_bytes = "7.1" +pin-project = "1.1" +regex = "1.12" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.149" +snafu = { version = "0.8.9", features = ["backtrace"] } +toml = "0.9" +tokio = { version = "1.49", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"]} diff --git a/pkgs/by-name/mp/mpdpopm/README.md b/pkgs/by-name/mp/mpdpopm/README.md new file mode 100644 index 00000000..3c2d961b --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/README.md @@ -0,0 +1,260 @@ + +# Table of Contents + +1. [Introduction](#orgb2618c9) +2. [What Can You Do With It?](#orgf1adf2c) +3. [Licsense](#org3f75b89) +4. [Prerequisites](#org67de102) +5. [Installing](#installing) + 1. [Use the pre-built binaries](#orgb2e3434) + 2. [Crates.io](#org971a8b3) + 3. [Use the Debian package](#org55e51f8) + 4. [Use the Arch package](#org49ada47) + 5. [Autotools source distributions](#org9c94559) + 6. [Building from source](#org64bc5dd) +6. [Getting Started](#getting_started) + 1. [Program Structure](#org4a22fae) + 2. [Getting Set-up](#orgfbd2d7d) + 1. [MPD](#orgb37b483) + 2. [mppopmd](#org38f4b69) + 3. [mppopm](#orgfa9dacf) +7. [Status & Roadmap](#orgd90c7da) + + + +<a id="orgb2618c9"></a> + +# Introduction + +[mpdpopm](https://github.com/sp1ff/mpdpopm) provides a companion daemon to [MPD](https://www.musicpd.org/) for maintaining play counts, ratings and last-played timestamps, along with an associated CLI for talking to the companion daemon. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust (which I prefer to Go), it will maintain this information in your sticker database. Along the lines of [mpdcron](https://alip.github.io/mpdcron), it will also allow you to keep that information up-to-date in your tags by invoking external (user-provided & -configured) commands. + +This README focuses on obtaining & installing [mpdpopm](https://github.com/sp1ff/mpdpopm); the user manual is distributed with the package in [Texinfo](https://www.gnu.org/software/texinfo/) format. The HTML version of the user manual is hosted on my personal [site](https://www.unwoundstack.com/doc/mpdpopm/curr). + + +<a id="orgf1adf2c"></a> + +# What Can You Do With It? + +Once you've [installed](#installing) & [started](#getting_started) [mpdpopm](https://github.com/sp1ff/mpdpopm), its daemon (`mppopmd`) will sit in the background noting the songs you play and updating play counts & last played timestamps in your [MPD](https://www.musicpd.org/) sticker database. If you'd like to rate a song, you can send `mppopmd` a message using your favorte MPD client, or with the `mppopm` CLI that comes along with this package; `mppopmd` will note the rating, as well. + +If you'd like to make use of this information in your song selection, you can ask `mppopmd` to queue-up songs on this basis by saying things like: + + mppopm findadd "(rating > 128)" + +to add all songs with a rating greater than 128 to the play queue, or + + mppopm findadd "(lastplayed <= \"2022-12-28\")" + +to add all songs that haven't been played in the last year. + + +<a id="org3f75b89"></a> + +# Licsense + +[mpdpopm](https://github.com/sp1ff/mpdpopm) is GPL v3 software. + + +<a id="org67de102"></a> + +# Prerequisites + +[Music Player Daemon](https://www.musicpd.org/): "Music Player Daemon (MPD) is a flexible, powerful, server-side application for playing music. Through plugins and libraries it can play a variety of sound files while being controlled by its network protocol." If you're reading this, I assume you're already running MPD, so this document won't have much to say on installing & configuring it other than that you **do** need to setup the sticker database by setting `sticker_file` in your configuration. + +If you choose to use the pre-built binaries or the Debian or Arch packages (available under [releases](https://github.com/sp1ff/mpdpopm/releases)), that's all you'll need– you can jump ahead to the section entitled [Installing](#getting_started), below. + +If you would prefer to download [mpdpopm](https://github.com/sp1ff/mpdpopm) from [crates.io](https://crates.io/crates/mpdpopm), you'll need need the [Rust](https://www.rust-lang.org/tools/install) toolchain ("Rust is a memory- & thread-safe language with no runtime or garbage collector"). Installing the toolchain is easy: + + curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh + +[mpdpopm](https://github.com/sp1ff/mpdpopm) is also available as an Autotools source distribution (also under [releases](https://github.com/sp1ff/mpdpopm/releases)), and of course you can just clone the repo & build the project from source. In either of those two cases you'll need the Gnu [Autotools](https://www.gnu.org/software/automake/manual/html_node/Autotools-Introduction.html) installed in addition to Rust. In the former case, grab the tarball in the format of your choice & perform the usual "./configure && make && make install" incantation. In the latter, you'll need to invoke "./bootstrap" after you clone the repo. Again, if you're considering that route, I assume you're familiar with the Autotools & won't say much about them here. + + +<a id="installing"></a> + +# Installing + +As mentioned above, you can install [mpdpopm](https://github.com/sp1ff/mpdpopm) in a few different ways. In increasing order of complexity: + + +<a id="orgb2e3434"></a> + +## Use the pre-built binaries + +Thanks to a suggestion by [m040601](https://github.com/m040601), you can download pre-built binaries for each [release](https://github.com/sp1ff/mpdpopm/releases). At the time of this writing, only Linux & MacOS are supported, and only on x86<sub>64</sub> at that. If that works for you, you can do something like: + + cd /tmp + curl -L --output mpdpopm-0.3.5.tar.gz https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm-0.3.5-x86_64-unknown-linux.tar.gz + tar xf mpdpopm-0.3.5.tar.gz + tree mpdpopm-0.3.5-x86_64-unknown-linux/ + mpdpopm-0.3.5-x86_64-unknown-linux/ + ├── bin + │ ├── mppopm + │ └── mppopmd + └── doc + ├── AUTHORS + ├── ChangeLog + ├── COPYING + ├── NEWS + ├── README.org + ├── THANKS + ├── mppopmd.conf + ├── mppopmd.info + └── mppopmd.service + + 2 directories, 10 files + +Copy the binaries `mppopmd` (the daemon) and `mppopm` (the CLI) to a convenient place (e.g. `/usr/local/bin` or `$HOME/.local/bin`) and proceed to [Getting Started](#getting_started), below. + + +<a id="org971a8b3"></a> + +## Crates.io + +If you've got the Rust toolchain installed, just say `cargo install mpdpopm`. The binaries will now be in `$HOME/.cargo/bin`, and you can proceed to [Getting Started](#getting_started), below. + + +<a id="org55e51f8"></a> + +## Use the Debian package + +If you're running on a Debian-based Linux distribution, and you're on an x86<sub>64</sub> processor, I've begun providing a Debian binary package, courtesy of the very cool [cargo-deb](https://github.com/mmstick/cargo-deb) Cargo helper command. Just do: + + cd /tmp + curl -L -O https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm_0.3.5_amd64.deb + sudo dpkg -i mpdpopm_0.3.5_amd64.deb + +The binaries will be placed in `/usr/local/bin`, and you can proceed to [Getting Started](#getting_started), below. + + +<a id="org49ada47"></a> + +## Use the Arch package + +If you're running on an Arch-based Linux distribution, I maintain a few packages in the [AUR](https://aur.archlinux.org/): + +- [mpdpopm](https://aur.archlinux.org/packages/mpdpopm): which will grab the latest release & build it locally +- [mpdpopm-git](https://aur.archlinux.org/packages/mpdpopm-git): grab `HEAD` from `master` & build it locally +- [mpdpopm-bin](https://aur.archlinux.org/packages/mpdpopm-bin): grab the pre-built binaries from the latest release & install 'em + +You can clone the git repo for whichever package you'd like to use (remotes at link), do `makepkg` & then use pacman to install the package you just built, or use an AUR package manager (I use `yay`, e.g.) + + +<a id="org9c94559"></a> + +## Autotools source distributions + +If you've got the Rust toolchain as well as Autotools installed, you can build from source via Autotools: + + cd /tmp + curl -L -O https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm-0.3.5.tar.xz + tar xf mpdpopm-0.3.5.tar.xz + cd mpdpopm-0.3.5 + ./configure + make + make check + sudo make install + +All the usual `configure` options apply (`--prefix`, e.g.) In particular, you can say `--enable-debug` to produce debug builds. + + +<a id="org64bc5dd"></a> + +## Building from source + +Finally, and again if you have the build toolchain (Rust & Autotools) installed, you can build from source: + + git clone git@github.com:sp1ff/mpdpopm.git + cd mpdpopm + ./bootstrap + ./configure + make + make check + sudo make install + +Notice the call to `./bootstrap`, in this case. + + +<a id="getting_started"></a> + +# Getting Started + +This README provides a "quick-start" guide to getting mpdpopm up & running. For detailed user docs, refer to the [manual](https://www.unwoundstack.com/doc/mpdpopm/curr). + + +<a id="org4a22fae"></a> + +## Program Structure + +[mpdpopm](https://github.com/sp1ff/mpdpopm) provides two programs: + +1. `mppopmd` is the companion daemon process +2. `mppopm` is the associated command-line interface to the daemon + +`mppopmd` will monitor `mpd` for song playback & note when songs complete; this is how it knows to increment the playcount & update the last played timestamp for each song to which you listen. `mppopmd` records this information (i.e. play counts, last played and ratings) using `mpd` [stickers](https://www.musicpd.org/doc/html/protocol.html#stickers). A sticker is a little bit of textual information which clients can attach to songs in the form of a name-value pair. [mpdpopm](https://github.com/sp1ff/mpdpopm) defines a new sticker name for each of these items & udpates the values for each song when & as requested. + +Of course, other `mpd` clients will not, in general, be aware of `mppopmd` or the stickers it sets: you the user will have to bridge that gap. You could of course just fire-up `netcat` & start sending commands over the MPD protocol using `sendmessage`, but that's not particularly convenient– that's where `mppopm` comes in. `mppopm` is the client interface; one can through it instruct `mppopmd` to set ratings, get & set the various stickers mpdpopm knows about, and even search for songs in terms of mpdpopm attributes & add them to the play queue. + + +<a id="orgfbd2d7d"></a> + +## Getting Set-up + + +<a id="orgb37b483"></a> + +### MPD + +If you're reading this, I assume you already have MPD up & running, so this section will be brief. One note, prompted by user [m040601](https://github.com/m040601), however: as mentioned above, [mpdpopm](https://github.com/sp1ff/mpdpopm) leverages the MPD sticker database. I was chagrined to find that if you do not configure MPD to maintain a sticker database, all sticker commands will simply be disabled. Therefore, before setting up [mpdpopm](https://github.com/sp1ff/mpdpopm), find your `mpd` configuration file and check to be sure you have a `sticker_file` entry; something like this: + + sticker_file "/home/sp1ff/lib/mpd/sticker.sql" + +Check also that the you have write access to the named file & its parent directory. + + +<a id="org38f4b69"></a> + +### mppopmd + +The daemon depends on a configuration file that you'll need to provide. Most `mppopmd` configuration items have sensible defaults, but there are a few that will need to be customized to your MPD setup. A sample configuration file is provided with all distributions; see also the user [manual](https://www.unwoundstack.com/doc/mpdpopm/curr#mppopmd-Configuration) for detailed documentation. + +You'll likely want to run the program in the foreground initially for ease of trouble-shooting, but after that you'll probably want to run it as a daemon. Again see the [manual](https://www.unwoundstack.com/doc/mpdopmd/curr#mppopmd-as-a-Daemon) for detailed instructions. + +Once you've got the daemon running to your satisfaction, if you're on a systemd-based Linux distribution, have a look at the sample systemd unit file thanks to [tanshoku](https://github.com/tanshoku). + +[tanshoku](https://github.com/tanshoku) was kind enough to contribute a systemd unit for this purpose. At present, the build does not install it, but provides it as an example and leaves it to the user to install should they desire (and after they have edited it to suit their configuration). You can find it in `${prefix}/share/mpdpopm/examples` for the Autotools distribution, `/usr/local/share/mpdpopm/examples` for the Debian package, and in the `doc` folder for the pre-built binaries. + + +<a id="orgfa9dacf"></a> + +### mppopm + +At this point, [mpdpopm](https://github.com/sp1ff/mpdpopm) will happily monitor your playback history & keep play counts & last played timestamps for you. If you would like to rate tracks, however, you will need to somehow induce your favorite mpd client to send a "rating" message to the [mpdpopm](https://github.com/sp1ff/mpdpopm) commands channel ("unwoundstack.com:commands" by default). Since this is unlikely to be convenient, I wrote an mpd client for the purpose: a little CLI called `mppopm`. You can simply execute + + mppopm set-rating '*****' + +to set the current track's rating to five "stars" (say `mppopm --help` for an explanation of the rating system; in brief– it's Winamp's). NB. the set rating command by default produces no output; if you want confirmation that something's happening, use the `-v` flag. + +The CLI offers "get" & "set" commands for play counts, last played timestamps & the rating. It also provides commands for searching your songs on the basis of play count, rating & last played times in addition to the usual artist, title &c. Say `mppopm --help` for a full list of options, including how to tell it where the mpd server can be found on your network. + + +<a id="orgd90c7da"></a> + +# Status & Roadmap + +I am currently using [mpdpopm](https://github.com/sp1ff/mpdpopm) day in & day out with my music collection, but it's early days; I have chosen the version number (0.n) in the hopes of indicating that. Right now, mpdpopm is the bare-bones of an app: it's plumbing, not the sink. + +Heretofore, you could use the `mppopm` CLI to, say, rate the current song, but in order to actually <span class="underline">do</span> anything with that rating in the future, you'd have had to write some kind of mpd client for yourself. With the 0.2 release, I've added support for extended MPD filter syntax that allows queries that include the stickers that [mpdpopm](https://github.com/sp1ff/mpdpopm) manages– so you can now, for instance, say: + + mppopm findadd "(artist =~ \"foo\") and (rating > 175)" + +MPD will handle the "artist =~" clause & [mpdpopm](https://github.com/sp1ff/mpdpopm) the "rating >" clause, as well as combining the results. + +This will hopefully be a start to making [mpdpopm](https://github.com/sp1ff/mpdpopm) into a more of a user-facing application than a developer-facing utlity. + +Windows support may be some time coming; the daemon depends on Unix signal handling, the MPD Unix socket, and the Unix daemon logic, especially `fork` & `exec`… if you'd like to run it on Windows, let me know– if there's enough interest, and I can get some kind of Windows VM setup, I'll look at a port. + +Longer-term, I see [mpdpopm](https://github.com/sp1ff/mpdpopm) as a "dual" to mpd– mpd commits to never altering your files. mpdpopm will take on that task in terms of tags, at least. To address the "plumbing, not the sink" problem, I'd like to author a client that will handle player control (of course), but also visualization & tag editing– a complete music library solution. + +Suggestions, bug reports & PRs welcome! + diff --git a/pkgs/by-name/mp/mpdpopm/README.org b/pkgs/by-name/mp/mpdpopm/README.org new file mode 100644 index 00000000..ebc91262 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/README.org @@ -0,0 +1,214 @@ +#+TITLE: README +#+AUTHOR: Michael Herstine +#+DESCRIPTION: mpdpopm +#+EMAIL: sp1ff@pobox.com +#+DATE: <2025-10-19 Sun 19:17> +#+AUTODATE: t + +* Introduction + +[[https://github.com/sp1ff/mpdpopm][mpdpopm]] provides a companion daemon to [[https://www.musicpd.org/][MPD]] for maintaining play counts, ratings and last-played timestamps, along with an associated CLI for talking to the companion daemon. Similar to [[https://github.com/vincent-petithory/mpdfav][mpdfav]], but written in Rust (which I prefer to Go), it will maintain this information in your sticker database. Along the lines of [[https://alip.github.io/mpdcron][mpdcron]], it will also allow you to keep that information up-to-date in your tags by invoking external (user-provided & -configured) commands. + +This README focuses on obtaining & installing [[https://github.com/sp1ff/mpdpopm][mpdpopm]]; the user manual is distributed with the package in [[https://www.gnu.org/software/texinfo/][Texinfo]] format. The HTML version of the user manual is hosted on my personal [[https://www.unwoundstack.com/doc/mpdpopm/curr][site]]. + +* What Can You Do With It? + +Once you've [[#installing][installed]] & [[#getting_started][started]] [[https://github.com/sp1ff/mpdpopm][mpdpopm]], its daemon (=mppopmd=) will sit in the background noting the songs you play and updating play counts & last played timestamps in your [[https://www.musicpd.org/][MPD]] sticker database. If you'd like to rate a song, you can send =mppopmd= a message using your favorte MPD client, or with the =mppopm= CLI that comes along with this package; =mppopmd= will note the rating, as well. + +If you'd like to make use of this information in your song selection, you can ask =mppopmd= to queue-up songs on this basis by saying things like: + +#+BEGIN_SRC bash +mppopm findadd "(rating > 128)" +#+END_SRC + +to add all songs with a rating greater than 128 to the play queue, or + +#+BEGIN_SRC bash +mppopm findadd "(lastplayed <= \"2022-12-28\")" +#+END_SRC + +to add all songs that haven't been played in the last year. + +* Licsense + +[[https://github.com/sp1ff/mpdpopm][mpdpopm]] is GPL v3 software. + +* Prerequisites + +[[https://www.musicpd.org/][Music Player Daemon]]: "Music Player Daemon (MPD) is a flexible, powerful, server-side application for playing music. Through plugins and libraries it can play a variety of sound files while being controlled by its network protocol." If you're reading this, I assume you're already running MPD, so this document won't have much to say on installing & configuring it other than that you *do* need to setup the sticker database by setting =sticker_file= in your configuration. + +If you choose to use the pre-built binaries or the Debian or Arch packages (available under [[https://github.com/sp1ff/mpdpopm/releases][releases]]), that's all you'll need-- you can jump ahead to the section entitled [[#getting_started][Installing]], below. + +If you would prefer to download [[https://github.com/sp1ff/mpdpopm][mpdpopm]] from [[https://crates.io/crates/mpdpopm][crates.io]], you'll need need the [[https://www.rust-lang.org/tools/install][Rust]] toolchain ("Rust is a memory- & thread-safe language with no runtime or garbage collector"). Installing the toolchain is easy: + +#+BEGIN_SRC bash +curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh +#+END_SRC + +[[https://github.com/sp1ff/mpdpopm][mpdpopm]] is also available as an Autotools source distribution (also under [[https://github.com/sp1ff/mpdpopm/releases][releases]]), and of course you can just clone the repo & build the project from source. In either of those two cases you'll need the Gnu [[https://www.gnu.org/software/automake/manual/html_node/Autotools-Introduction.html][Autotools]] installed in addition to Rust. In the former case, grab the tarball in the format of your choice & perform the usual "./configure && make && make install" incantation. In the latter, you'll need to invoke "./bootstrap" after you clone the repo. Again, if you're considering that route, I assume you're familiar with the Autotools & won't say much about them here. + +* Installing + :PROPERTIES: + :CUSTOM_ID: installing + :END: + +As mentioned above, you can install [[https://github.com/sp1ff/mpdpopm][mpdpopm]] in a few different ways. In increasing order of complexity: + +** Use the pre-built binaries + +Thanks to a suggestion by [[https://github.com/m040601][m040601]], you can download pre-built binaries for each [[https://github.com/sp1ff/mpdpopm/releases][release]]. At the time of this writing, only Linux & MacOS are supported, and only on x86_64 at that. If that works for you, you can do something like: + +#+BEGIN_SRC bash +cd /tmp +curl -L --output mpdpopm-0.3.5.tar.gz https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm-0.3.5-x86_64-unknown-linux.tar.gz +tar xf mpdpopm-0.3.5.tar.gz +tree mpdpopm-0.3.5-x86_64-unknown-linux/ +mpdpopm-0.3.5-x86_64-unknown-linux/ +├── bin +│ ├── mppopm +│ └── mppopmd +└── doc + ├── AUTHORS + ├── ChangeLog + ├── COPYING + ├── NEWS + ├── README.org + ├── THANKS + ├── mppopmd.conf + ├── mppopmd.info + └── mppopmd.service + +2 directories, 10 files +#+END_SRC + +Copy the binaries =mppopmd= (the daemon) and =mppopm= (the CLI) to a convenient place (e.g. =/usr/local/bin= or =$HOME/.local/bin=) and proceed to [[#getting_started][Getting Started]], below. + +** Crates.io + +If you've got the Rust toolchain installed, just say =cargo install mpdpopm=. The binaries will now be in =$HOME/.cargo/bin=, and you can proceed to [[#getting_started][Getting Started]], below. + +** Use the Debian package + +If you're running on a Debian-based Linux distribution, and you're on an x86_64 processor, I've begun providing a Debian binary package, courtesy of the very cool [[https://github.com/mmstick/cargo-deb][cargo-deb]] Cargo helper command. Just do: + +#+BEGIN_SRC bash +cd /tmp +curl -L -O https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm_0.3.5_amd64.deb +sudo dpkg -i mpdpopm_0.3.5_amd64.deb +#+END_SRC + +The binaries will be placed in =/usr/local/bin=, and you can proceed to [[#getting_started][Getting Started]], below. + +** Use the Arch package + +If you're running on an Arch-based Linux distribution, I maintain a few packages in the [[https://aur.archlinux.org/][AUR]]: + + - [[https://aur.archlinux.org/packages/mpdpopm][mpdpopm]]: which will grab the latest release & build it locally + - [[https://aur.archlinux.org/packages/mpdpopm-git][mpdpopm-git]]: grab =HEAD= from =master= & build it locally + - [[https://aur.archlinux.org/packages/mpdpopm-bin][mpdpopm-bin]]: grab the pre-built binaries from the latest release & install 'em + +You can clone the git repo for whichever package you'd like to use (remotes at link), do =makepkg= & then use pacman to install the package you just built, or use an AUR package manager (I use =yay=, e.g.) +** Autotools source distributions + +If you've got the Rust toolchain as well as Autotools installed, you can build from source via Autotools: + +#+BEGIN_SRC bash +cd /tmp +curl -L -O https://github.com/sp1ff/mpdpopm/releases/download/0.3.5/mpdpopm-0.3.5.tar.xz +tar xf mpdpopm-0.3.5.tar.xz +cd mpdpopm-0.3.5 +./configure +make +make check +sudo make install +#+END_SRC + +All the usual =configure= options apply (=--prefix=, e.g.) In particular, you can say =--enable-debug= to produce debug builds. + +** Building from source + +Finally, and again if you have the build toolchain (Rust & Autotools) installed, you can build from source: + +#+BEGIN_SRC bash +git clone git@github.com:sp1ff/mpdpopm.git +cd mpdpopm +./bootstrap +./configure +make +make check +sudo make install +#+END_SRC + +Notice the call to =./bootstrap=, in this case. + +* Getting Started + :PROPERTIES: + :CUSTOM_ID: getting_started + :END: + +This README provides a "quick-start" guide to getting mpdpopm up & running. For detailed user docs, refer to the [[https://www.unwoundstack.com/doc/mpdpopm/curr][manual]]. + +** Program Structure + +[[https://github.com/sp1ff/mpdpopm][mpdpopm]] provides two programs: + + 1. =mppopmd= is the companion daemon process + 2. =mppopm= is the associated command-line interface to the daemon + +=mppopmd= will monitor =mpd= for song playback & note when songs complete; this is how it knows to increment the playcount & update the last played timestamp for each song to which you listen. =mppopmd= records this information (i.e. play counts, last played and ratings) using =mpd= [[https://www.musicpd.org/doc/html/protocol.html#stickers][stickers]]. A sticker is a little bit of textual information which clients can attach to songs in the form of a name-value pair. [[https://github.com/sp1ff/mpdpopm][mpdpopm]] defines a new sticker name for each of these items & udpates the values for each song when & as requested. + +Of course, other =mpd= clients will not, in general, be aware of =mppopmd= or the stickers it sets: you the user will have to bridge that gap. You could of course just fire-up =netcat= & start sending commands over the MPD protocol using =sendmessage=, but that's not particularly convenient-- that's where =mppopm= comes in. =mppopm= is the client interface; one can through it instruct =mppopmd= to set ratings, get & set the various stickers mpdpopm knows about, and even search for songs in terms of mpdpopm attributes & add them to the play queue. + +** Getting Set-up + +*** MPD + +If you're reading this, I assume you already have MPD up & running, so this section will be brief. One note, prompted by user [[https://github.com/m040601][m040601]], however: as mentioned above, [[https://github.com/sp1ff/mpdpopm][mpdpopm]] leverages the MPD sticker database. I was chagrined to find that if you do not configure MPD to maintain a sticker database, all sticker commands will simply be disabled. Therefore, before setting up [[https://github.com/sp1ff/mpdpopm][mpdpopm]], find your =mpd= configuration file and check to be sure you have a =sticker_file= entry; something like this: + +#+BEGIN_EXAMPLE + sticker_file "/home/sp1ff/lib/mpd/sticker.sql" +#+END_EXAMPLE + +Check also that the you have write access to the named file & its parent directory. + +*** mppopmd + +The daemon depends on a configuration file that you'll need to provide. Most =mppopmd= configuration items have sensible defaults, but there are a few that will need to be customized to your MPD setup. A sample configuration file is provided with all distributions; see also the user [[https://www.unwoundstack.com/doc/mpdpopm/curr#mppopmd-Configuration][manual]] for detailed documentation. + +You'll likely want to run the program in the foreground initially for ease of trouble-shooting, but after that you'll probably want to run it as a daemon. Again see the [[https://www.unwoundstack.com/doc/mpdopmd/curr#mppopmd-as-a-Daemon][manual]] for detailed instructions. + +Once you've got the daemon running to your satisfaction, if you're on a systemd-based Linux distribution, have a look at the sample systemd unit file thanks to [[https://github.com/tanshoku][tanshoku]]. + +[[https://github.com/tanshoku][tanshoku]] was kind enough to contribute a systemd unit for this purpose. At present, the build does not install it, but provides it as an example and leaves it to the user to install should they desire (and after they have edited it to suit their configuration). You can find it in =${prefix}/share/mpdpopm/examples= for the Autotools distribution, =/usr/local/share/mpdpopm/examples= for the Debian package, and in the =doc= folder for the pre-built binaries. + +*** mppopm + +At this point, [[https://github.com/sp1ff/mpdpopm][mpdpopm]] will happily monitor your playback history & keep play counts & last played timestamps for you. If you would like to rate tracks, however, you will need to somehow induce your favorite mpd client to send a "rating" message to the [[https://github.com/sp1ff/mpdpopm][mpdpopm]] commands channel ("unwoundstack.com:commands" by default). Since this is unlikely to be convenient, I wrote an mpd client for the purpose: a little CLI called =mppopm=. You can simply execute + +#+BEGIN_SRC bash +mppopm set-rating '*****' +#+END_SRC + +to set the current track's rating to five "stars" (say =mppopm --help= for an explanation of the rating system; in brief-- it's Winamp's). NB. the set rating command by default produces no output; if you want confirmation that something's happening, use the =-v= flag. + +The CLI offers "get" & "set" commands for play counts, last played timestamps & the rating. It also provides commands for searching your songs on the basis of play count, rating & last played times in addition to the usual artist, title &c. Say =mppopm --help= for a full list of options, including how to tell it where the mpd server can be found on your network. + +* Status & Roadmap + +I am currently using [[https://github.com/sp1ff/mpdpopm][mpdpopm]] day in & day out with my music collection, but it's early days; I have chosen the version number (0.n) in the hopes of indicating that. Right now, mpdpopm is the bare-bones of an app: it's plumbing, not the sink. + +Heretofore, you could use the =mppopm= CLI to, say, rate the current song, but in order to actually _do_ anything with that rating in the future, you'd have had to write some kind of mpd client for yourself. With the 0.2 release, I've added support for extended MPD filter syntax that allows queries that include the stickers that [[https://github.com/sp1ff/mpdpopm][mpdpopm]] manages-- so you can now, for instance, say: + +#+BEGIN_EXAMPLE +mppopm findadd "(artist =~ \"foo\") and (rating > 175)" +#+END_EXAMPLE + +MPD will handle the "artist =~" clause & [[https://github.com/sp1ff/mpdpopm][mpdpopm]] the "rating >" clause, as well as combining the results. + +This will hopefully be a start to making [[https://github.com/sp1ff/mpdpopm][mpdpopm]] into a more of a user-facing application than a developer-facing utlity. + +Windows support may be some time coming; the daemon depends on Unix signal handling, the MPD Unix socket, and the Unix daemon logic, especially =fork= & =exec=... if you'd like to run it on Windows, let me know-- if there's enough interest, and I can get some kind of Windows VM setup, I'll look at a port. + +Longer-term, I see [[https://github.com/sp1ff/mpdpopm][mpdpopm]] as a "dual" to mpd-- mpd commits to never altering your files. mpdpopm will take on that task in terms of tags, at least. To address the "plumbing, not the sink" problem, I'd like to author a client that will handle player control (of course), but also visualization & tag editing-- a complete music library solution. + +Suggestions, bug reports & PRs welcome! diff --git a/pkgs/by-name/mp/mpdpopm/build.rs b/pkgs/by-name/mp/mpdpopm/build.rs new file mode 100644 index 00000000..3d8bbd48 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/build.rs @@ -0,0 +1,12 @@ +extern crate lalrpop; +fn main() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + + lalrpop::Configuration::new() + .emit_comments(true) + .emit_whitespace(true) + .log_verbose() + .set_out_dir(out_dir) + .process_dir("./") + .unwrap(); +} diff --git a/pkgs/by-name/mp/mpdpopm/config.lsp b/pkgs/by-name/mp/mpdpopm/config.lsp new file mode 100644 index 00000000..4b657226 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/config.lsp @@ -0,0 +1,13 @@ +;; SAMPLE MPPOPMD CONFIGURATION FILE -*- mode: lisp; -*- +;; You will need to edit this to suit your particular installation. +;; In particular, examine the `log' & `local_music_dir' values since those +;; are difficult to guess. Also check the `host' & `port' settings. +((version . "1") + (log . "/home/soispha/.local/share/mppopmd/log") + ;; (conn TCP (host . "localhost") (port . 6600)) + ;; Replace the above line with this to use the local socket + (conn Local (path . "/run/user/1000/mpd/socket")) + (local_music_dir . "/home/soispha/media/music/beets") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (commands_chan . "unwoundstack.com:commands")) diff --git a/pkgs/by-name/mp/mpdpopm/flake.lock b/pkgs/by-name/mp/mpdpopm/flake.lock new file mode 100644 index 00000000..c1d50dc3 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1769237874, + "narHash": "sha256-saOixpqPT4fiE/M8EfHv9I98f3sSEvt6nhMJ/z0a7xI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "523257564973361cc3e55e3df3e77e68c20b0b80", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768158989, + "narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/pkgs/by-name/mp/mpdpopm/flake.nix b/pkgs/by-name/mp/mpdpopm/flake.nix new file mode 100644 index 00000000..f6b622fe --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/flake.nix @@ -0,0 +1,66 @@ +# Mpdpopm - A mpd rating tracker +# +# Copyright (C) 2026 Benedikt Peetz, Michael Herstine <sp1ff@pobox.com> <benedikt.peetz@b-peetz.de, sp1ff@pobox.com> +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of Mpdpopm. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/agpl.txt>. +{ + description = "A mpd rating tracker"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs = { + nixpkgs.follows = "nixpkgs"; + }; + }; + }; + + outputs = { + self, + nixpkgs, + treefmt-nix, + ... + }: let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages."${system}"; + + treefmtEval = import ./treefmt.nix {inherit treefmt-nix pkgs;}; + in { + checks."${system}" = { + formatting = treefmtEval.config.build.check self; + }; + + formatter."${system}" = treefmtEval.config.build.wrapper; + + devShells."${system}".default = pkgs.mkShell { + packages = [ + # rust stuff + pkgs.cargo + pkgs.clippy + pkgs.rustc + pkgs.rustfmt + pkgs.mold + + pkgs.cargo-edit + pkgs.cargo-expand + pkgs.cargo-flamegraph + + # Releng + pkgs.git-bug + pkgs.reuse + pkgs.cocogitto + + # Perf + pkgs.hyperfine + ]; + }; + }; +} +# vim: ts=2 + diff --git a/pkgs/by-name/mp/mpdpopm/package.nix b/pkgs/by-name/mp/mpdpopm/package.nix new file mode 100644 index 00000000..907bb1cf --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/package.nix @@ -0,0 +1,29 @@ +# nixos-config - My current NixOS configuration +# +# Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of my nixos-config. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +{ + rustPlatform, + lib, +}: +rustPlatform.buildRustPackage (finalAttrs: { + pname = "mpdpopm"; + version = "0.1.0"; + + buildInputs = []; + nativeBuildInputs = [ ]; + + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + }; + + meta = { + mainProgram = "mpdpopm"; + }; +}) diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs new file mode 100644 index 00000000..82a354d6 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs @@ -0,0 +1,746 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mppopm +//! +//! mppopmd client +//! +//! # Introduction +//! +//! `mppopmd` is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). `mppopm` is a command-line client +//! for `mppopmd`. Run `mppopm --help` for detailed usage. + +use mpdpopm::{ + clients::{Client, PlayerStatus, quote}, + config::{self, Config}, + storage::{last_played, play_count, rating_count}, +}; + +use backtrace::Backtrace; +use clap::{Parser, Subcommand}; +use tracing::{debug, info, level_filters::LevelFilter, trace}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +use std::{fmt, path::PathBuf}; + +#[non_exhaustive] +pub enum Error { + NoSubCommand, + NoConfigArg, + NoRating, + NoPlayCount, + NoLastPlayed, + NoConfig { + config: std::path::PathBuf, + cause: std::io::Error, + }, + PlayerStopped, + BadPath { + path: PathBuf, + back: Backtrace, + }, + NoPlaylist, + Client { + source: mpdpopm::clients::Error, + back: Backtrace, + }, + Ratings { + source: Box<mpdpopm::storage::Error>, + back: Backtrace, + }, + Playcounts { + source: Box<mpdpopm::storage::Error>, + back: Backtrace, + }, + ExpectedInt { + source: std::num::ParseIntError, + back: Backtrace, + }, + Config { + source: crate::config::Error, + back: Backtrace, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NoSubCommand => write!(f, "No sub-command given"), + Error::NoConfigArg => write!(f, "No argument given for the configuration option"), + Error::NoRating => write!(f, "No rating supplied"), + Error::NoPlayCount => write!(f, "No play count supplied"), + Error::NoLastPlayed => write!(f, "No last played timestamp given"), + Error::NoConfig { config, cause } => write!(f, "Bad config ({:?}): {}", config, cause), + Error::PlayerStopped => write!(f, "The player is stopped"), + Error::BadPath { path, back: _ } => write!(f, "Bad path: {:?}", path), + Error::NoPlaylist => write!(f, "No playlist given"), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Ratings { source, back: _ } => write!(f, "Rating error: {}", source), + Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), + Error::ExpectedInt { source, back: _ } => write!(f, "Expected integer: {}", source), + Error::Config { source, back: _ } => { + write!(f, "Error reading configuration: {}", source) + } + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +type Result<T> = std::result::Result<T, Error>; + +/// Map `tracks' argument(s) to a Vec of String containing one or more mpd URIs +/// +/// Several sub-commands take zero or more positional arguments meant to name tracks, with the +/// convention that zero indicates that the sub-command should use the currently playing track. +/// This is a convenience function for mapping the value returned by [`get_many`] to a +/// convenient representation of the user's intentions. +/// +/// [`get_many`]: [`clap::ArgMatches::get_many`] +async fn map_tracks(client: &mut Client, args: Option<Vec<String>>) -> Result<Vec<String>> { + let files = match args { + Some(iter) => iter, + None => { + let file = provide_file(client, None).await?; + vec![file] + } + }; + Ok(files) +} + +async fn provide_file(client: &mut Client, maybe_file: Option<String>) -> Result<String> { + let file = match maybe_file { + Some(file) => file, + None => { + match client.status().await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| Error::BadPath { + path: curr.file.clone(), + back: Backtrace::new(), + })? + .to_string(), + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped); + } + } + } + }; + + Ok(file) +} + +/// Retrieve ratings for one or more tracks +async fn get_ratings( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut ratings: Vec<(String, i8)> = Vec::new(); + + for file in map_tracks(client, tracks).await? { + let rating = rating_count::get(client, &file) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + ratings.push((file, rating.unwrap_or_default())); + } + + if ratings.len() == 1 && !with_uri { + println!("{}", ratings[0].1); + } else { + for pair in ratings { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Rate a track +async fn set_rating(client: &mut Client, rating: i8, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + rating_count::set(client, &file, rating) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + match is_current { + false => info!("Set the rating for \"{}\" to \"{}\".", file, rating), + true => info!("Set the rating for the current song to \"{}\".", rating), + } + + Ok(()) +} + +/// Rate a track by incrementing the current rating +async fn inc_rating(client: &mut Client, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + let now = rating_count::get(client, &file) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + rating_count::set(client, &file, now.unwrap_or_default().saturating_add(1)) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + match is_current { + false => info!("Incremented the rating for \"{}\".", file), + true => info!("Incremented the rating for the current song."), + } + + Ok(()) +} + +/// Rate a track by decrementing the current rating +async fn decr_rating(client: &mut Client, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + let now = rating_count::get(client, &file) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + rating_count::set(client, &file, now.unwrap_or_default().saturating_sub(1)) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + match is_current { + false => info!("Decremented the rating for \"{}\".", file), + true => info!("Decremented the rating for the current song."), + } + + Ok(()) +} + +/// Retrieve the playcount for one or more tracks +async fn get_play_counts( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut playcounts: Vec<(String, usize)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let playcount = play_count::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })? + .unwrap_or_default(); + playcounts.push((file, playcount)); + } + + if playcounts.len() == 1 && !with_uri { + println!("{}", playcounts[0].1); + } else { + for pair in playcounts { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_play_counts(client: &mut Client, playcount: usize, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + play_count::set(client, &file, playcount) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })?; + + match is_current { + false => info!("Set the playcount for \"{}\" to \"{}\".", file, playcount), + true => info!( + "Set the playcount for the current song to \"{}\".", + playcount + ), + } + + Ok(()) +} + +/// Retrieve the last played time for one or more tracks +async fn get_last_playeds( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut lastplayeds: Vec<(String, Option<u64>)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let lastplayed = + last_played::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })?; + lastplayeds.push((file, lastplayed)); + } + + if lastplayeds.len() == 1 && !with_uri { + println!( + "{}", + match lastplayeds[0].1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } else { + for pair in lastplayeds { + println!( + "{}: {}", + pair.0, + match pair.1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_last_playeds(client: &mut Client, lastplayed: u64, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + last_played::set(client, &file, lastplayed) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })?; + + match is_current { + false => info!("Set last played for \"{}\" to \"{}\".", file, lastplayed), + true => info!( + "Set last played for the current song to \"{}\".", + lastplayed + ), + } + + Ok(()) +} + +/// Retrieve the list of stored playlists +async fn get_playlists(client: &mut Client) -> Result<()> { + let mut pls = client + .get_stored_playlists() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + pls.sort(); + println!("Stored playlists:"); + for pl in pls { + println!("{}", pl); + } + Ok(()) +} + +/// Add songs selected by filter to the queue +async fn findadd(client: &mut Client, chan: &str, filter: &str, case: bool) -> Result<()> { + let qfilter = quote(filter); + debug!("findadd: got ``{}'', quoted to ``{}''.", filter, qfilter); + let cmd = format!("{} {}", if case { "findadd" } else { "searchadd" }, qfilter); + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// Send an arbitrary command +async fn send_command(client: &mut Client, chan: &str, args: Vec<String>) -> Result<()> { + client + .send_message( + chan, + args.iter() + .map(String::as_str) + .map(quote) + .collect::<Vec<String>>() + .join(" ") + .as_str(), + ) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// `mppopmd' client +#[derive(Parser)] +struct Args { + /// path to configuration file + #[arg(short, long)] + config: Option<PathBuf>, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, + + #[command(subcommand)] + command: SubCommand, +} + +#[derive(Subcommand)] +enum RatingCommand { + /// retrieve the rating for one or more tracks + /// + /// With no arguments, retrieve the rating of the current song & print it + /// on stdout. With one argument, retrieve that track's rating & print it + /// on stdout. With multiple arguments, print their ratings on stdout, one + /// per line, prefixed by the track name. + /// + /// Ratings are expressed as an integer between 0 & 255, inclusive, with + /// the convention that 0 denotes "un-rated". + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the rating for one track + /// + /// With one argument, set the rating of the current song to that argument. + /// With a second argument, rate that song at the first argument. Ratings + /// may be expressed a an integer between 0 & 255, inclusive. + #[clap(verbatim_doc_comment)] + Set { rating: i8, track: Option<String> }, + + /// increment the rating for one track + /// + /// With one argument, increment the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + Inc { track: Option<String> }, + + /// decrement the rating for one track + /// + /// With one argument, decrement the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + Decr { track: Option<String> }, +} + +#[derive(Subcommand)] +enum PlayCountCommand { + /// retrieve the play count for one or more tracks + /// + /// With no arguments, retrieve the play count of the current song & print it + /// on stdout. With one argument, retrieve that track's play count & print it + /// on stdout. With multiple arguments, print their play counts on stdout, one + /// per line, prefixed by the track name. + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the play count for one track + /// + /// With one argument, set the play count of the current song to that argument. With a + /// second argument, set the play count for that song to the first. + #[clap(verbatim_doc_comment)] + Set { + play_count: usize, + track: Option<String>, + }, +} + +#[derive(Subcommand)] +enum LastPlayedCommand { + /// retrieve the last played timestamp for one or more tracks + /// + /// With no arguments, retrieve the last played timestamp of the current + /// song & print it on stdout. With one argument, retrieve that track's + /// last played time & print it on stdout. With multiple arguments, print + /// their last played times on stdout, one per line, prefixed by the track + /// name. + /// + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the last played timestamp for one track + /// + /// With one argument, set the last played time of the current song. With two + /// arguments, set the last played time for the second argument to the first. + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + Set { + last_played: u64, + track: Option<String>, + }, +} + +#[derive(Subcommand)] +enum PlaylistsCommand { + /// retrieve the list of stored playlists + #[clap(verbatim_doc_comment)] + Get {}, +} + +#[derive(Subcommand)] +enum SubCommand { + /// Change details about rating. + Rating { + #[command(subcommand)] + command: RatingCommand, + }, + + /// Change details about play count. + PlayCount { + #[command(subcommand)] + command: PlayCountCommand, + }, + + /// Change details about last played date. + LastPlayed { + #[command(subcommand)] + command: LastPlayedCommand, + }, + + /// Change details about generated playlists. + Playlists { + #[command(subcommand)] + command: PlaylistsCommand, + }, + + /// search case-sensitively for songs matching matching a filter and add them to the queue + /// + /// This command extends the MPD command `findadd' (which will search the MPD database) to allow + /// searches on attributes managed by mpdpopm: rating, playcount & last played time. + /// + /// The MPD `findadd' <https://www.musicpd.org/doc/html/protocol.html#command-findadd> will search the + /// MPD database for songs that match a given filter & add them to the play queue. The filter syntax is + /// documented here <https://www.musicpd.org/doc/html/protocol.html#filter-syntax>. + /// + /// This command adds three new terms on which you can filter: rating, playcount & lastplayed. Each is + /// expressed as an unsigned integer, with zero interpreted as "not set". For instance: + /// + /// mppopm findadd "(rating > 128)" + /// + /// Will add all songs in the library with a rating sticker > 128 to the play queue. + /// + /// mppopm also introduces OR clauses (MPD only supports AND), so that: + /// + /// mppopm findadd "((rating > 128) AND (artist =~ \"pogues\"))" + /// + /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than + /// 128. + /// + /// `findadd' is case-sensitive; for case-insensitive searching see the `searchadd' command. + #[clap(verbatim_doc_comment)] + Findadd { filter: String }, + + /// search case-insensitively for songs matching matching a filter and add them to the queue + /// + /// This command extends the MPD command `searchadd' (which will search the MPD database) to allow + /// searches on attributes managed by mpdpopm: rating, playcount & last played time. + /// + /// The MPD `searchadd' <https://www.musicpd.org/doc/html/protocol.html#command-searchadd> will search + /// the MPD database for songs that match a given filter & add them to the play queue. The filter syntax + /// is documented here <https://www.musicpd.org/doc/html/protocol.html#filter-syntax>. + /// + /// This command adds three new terms on which you can filter: rating, playcount & lastplayed. Each is + /// expressed as an unsigned integer, with zero interpreted as "not set". For instance: + /// + /// mppopm searchadd "(rating > 128)" + /// + /// Will add all songs in the library with a rating sticker > 128 to the play queue. + /// + /// mppopm also introduces OR clauses (MPD only supports AND), so that: + /// + /// mppopm searchadd "((rating > 128) AND (artist =~ \"pogues\"))" + /// + /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than + /// 128. + /// + /// `searchadd' is case-insensitive; for case-sensitive searching see the `findadd' command. + #[clap(verbatim_doc_comment)] + Searchadd { filter: String }, + + /// Send a command to mpd. + #[clap(verbatim_doc_comment)] + SendCommand { args: Vec<String> }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let config = if let Some(configpath) = &args.config { + match std::fs::read_to_string(configpath) { + Ok(text) => config::from_str(&text).map_err(|err| Error::Config { + source: err, + back: Backtrace::new(), + })?, + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + return Err(Error::NoConfig { + config: PathBuf::from(configpath), + cause: err, + }); + } + } + } else { + Config::default() + }; + + // Handle log verbosity: debug => verbose + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::WARN, + }; + + tracing::subscriber::set_global_default( + Registry::default() + .with( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(std::io::stdout), + ) + .with( + EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .unwrap(), + ), + ) + .unwrap(); + + trace!("logging configured."); + + let mut client = match config.conn { + config::Connection::Local { path } => { + Client::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + config::Connection::TCP { host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + match args.command { + SubCommand::Rating { command } => match command { + RatingCommand::Get { with_uri, tracks } => { + get_ratings(&mut client, tracks, with_uri).await + } + RatingCommand::Set { rating, track } => set_rating(&mut client, rating, track).await, + RatingCommand::Inc { track } => inc_rating(&mut client, track).await, + RatingCommand::Decr { track } => decr_rating(&mut client, track).await, + }, + SubCommand::PlayCount { command } => match command { + PlayCountCommand::Get { with_uri, tracks } => { + get_play_counts(&mut client, tracks, with_uri).await + } + PlayCountCommand::Set { play_count, track } => { + set_play_counts(&mut client, play_count, track).await + } + }, + SubCommand::LastPlayed { command } => match command { + LastPlayedCommand::Get { with_uri, tracks } => { + get_last_playeds(&mut client, tracks, with_uri).await + } + LastPlayedCommand::Set { last_played, track } => { + set_last_playeds(&mut client, last_played, track).await + } + }, + SubCommand::Playlists { command } => match command { + PlaylistsCommand::Get {} => get_playlists(&mut client).await, + }, + SubCommand::Findadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, true).await + } + SubCommand::Searchadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, false).await + } + SubCommand::SendCommand { args } => { + send_command(&mut client, &config.commands_chan, args).await + } + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs new file mode 100644 index 00000000..e903774c --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs @@ -0,0 +1,233 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mppopmd +//! +//! Maintain ratings & playcounts for your mpd server. +//! +//! # Introduction +//! +//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). + +use mpdpopm::config; +use mpdpopm::config::Config; +use mpdpopm::mpdpopm; + +use backtrace::Backtrace; +use clap::Parser; +use tracing::{info, level_filters::LevelFilter}; +use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; + +use std::{fmt, io, path::PathBuf, sync::MutexGuard}; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// mppopmd application Error type // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[non_exhaustive] +pub enum Error { + NoConfigArg, + NoConfig { + config: std::path::PathBuf, + cause: std::io::Error, + }, + Filter { + source: tracing_subscriber::filter::FromEnvError, + back: Backtrace, + }, + Fork { + errno: errno::Errno, + back: Backtrace, + }, + PathContainsNull { + back: Backtrace, + }, + OpenLockFile { + errno: errno::Errno, + back: Backtrace, + }, + LockFile { + errno: errno::Errno, + back: Backtrace, + }, + WritePid { + errno: errno::Errno, + back: Backtrace, + }, + Config { + source: crate::config::Error, + back: Backtrace, + }, + MpdPopm { + source: Box<mpdpopm::Error>, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NoConfigArg => write!(f, "No configuration file given"), + Error::NoConfig { config, cause } => { + write!(f, "Configuration error ({:?}): {}", config, cause) + } + Error::Fork { errno, back: _ } => write!(f, "When forking, got errno {}", errno), + Error::PathContainsNull { back: _ } => write!(f, "Path contains a null character"), + Error::OpenLockFile { errno, back: _ } => { + write!(f, "While opening lock file, got errno {}", errno) + } + Error::LockFile { errno, back: _ } => { + write!(f, "While locking the lock file, got errno {}", errno) + } + Error::WritePid { errno, back: _ } => { + write!(f, "While writing pid file, got errno {}", errno) + } + Error::Config { source, back: _ } => write!(f, "Configuration error: {}", source), + Error::MpdPopm { source, back: _ } => write!(f, "mpdpopm error: {}", source), + _ => write!(f, "Unknown mppopmd error"), + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +type Result = std::result::Result<(), Error>; + +pub struct MyMutexGuardWriter<'a>(MutexGuard<'a, std::fs::File>); + +impl io::Write for MyMutexGuardWriter<'_> { + #[inline] + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + self.0.write(buf) + } + + #[inline] + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } + + #[inline] + fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> { + self.0.write_vectored(bufs) + } + + #[inline] + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.0.write_all(buf) + } + + #[inline] + fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> io::Result<()> { + self.0.write_fmt(fmt) + } +} + +/// mpd + POPM +/// +/// `mppopmd' is a companion daemon for `mpd' that maintains playcounts & ratings, +/// as well as implementing some handy functions. It maintains ratings & playcounts in the sticker +/// database, but it allows you to keep that information in your tags, as well, by invoking external +/// commands to keep your tags up-to-date. +#[derive(Parser)] +struct Args { + /// path to configuration file + #[arg(short, long)] + config: Option<PathBuf>, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, +} + +/// Entry point for `mppopmd'. +/// +/// Do *not* use the #[tokio::main] attribute here! If this program is asked to daemonize (the usual +/// case), we will fork after tokio has started its thread pool, with disastrous consequences. +/// Instead, stay synchronous until we've daemonized (or figured out that we don't need to), and +/// only then fire-up the tokio runtime. +fn main() -> Result { + use mpdpopm::vars::VERSION; + + let args = Args::parse(); + + let config = if let Some(cfgpath) = &args.config { + match std::fs::read_to_string(cfgpath) { + Ok(text) => config::from_str(&text).map_err(|err| Error::Config { + source: err, + back: Backtrace::new(), + })?, + // The config file (defaulted or not) either didn't exist, or we were unable to read its + // contents... + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + return Err(Error::NoConfig { + config: PathBuf::from(cfgpath), + cause: err, + }); + } + } + } else { + Config::default() + }; + + // `--verbose' & `--debug' work as follows: if `--debug' is present, log at level Trace, no + // matter what. Else, if `--verbose' is present, log at level Debug. Else, log at level Info. + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::INFO, + }; + + let filter = EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })?; + + let formatter: Box<dyn Layer<Registry> + Send + Sync> = { + Box::new( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(io::stdout), + ) + }; + + tracing::subscriber::set_global_default(Registry::default().with(formatter).with(filter)) + .unwrap(); + + info!("mppopmd {VERSION} logging at level {lf:#?}."); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(mpdpopm(config)).map_err(|err| Error::MpdPopm { + source: Box::new(err), + back: Backtrace::new(), + }) +} diff --git a/pkgs/by-name/mp/mpdpopm/src/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs new file mode 100644 index 00000000..587063b2 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs @@ -0,0 +1,1417 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! mpd clients and associated utilities. +//! +//! # Introduction +//! +//! This module contains basic types implementing various MPD client operations (cf. the [mpd +//! protocol](http://www.musicpd.org/doc/protocol/)). Since issuing the "idle" command will tie up +//! the connection, MPD clients often use multiple connections to the server (one to listen for +//! updates, one or more on which to issue commands). This modules provides two different client +//! types: [Client] for general-purpose use and [IdleClient] for long-lived connections listening +//! for server notifiations. +//! +//! Note that there *is* another idiom (used in [libmpdel](https://github.com/mpdel/libmpdel), +//! e.g.): open a single connection & issue an "idle" command. When you want to issue a command, +//! send a "noidle", then the command, then "idle" again. This isn't a race condition, as the +//! server will buffer any changes that took place when you were not idle & send them when you +//! re-issue the "idle" command. This crate however takes the approach of two channels (like +//! [mpdfav](https://github.com/vincent-petithory/mpdfav)). + +use async_trait::async_trait; +use regex::Regex; +use snafu::{Backtrace, IntoError, OptionExt, ResultExt, prelude::*}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpStream, ToSocketAddrs, UnixStream}; +use tracing::{debug, info}; + +use lazy_static::lazy_static; + +use std::{ + collections::HashMap, + convert::TryFrom, + fmt, + marker::{Send, Unpin}, + path::{Path, PathBuf}, + str::FromStr, +}; + +// The Protocol error, below, gets used a *lot*; anywhere we receive a message from the MPD server +// that "should" never happen. To help give a bit of context beyond a stack trace, I use this +// enumeration of "operations" +/// Enumerated list of MPD operations; used in Error::Protocol to distinguish which operation it was +/// that elicited the protocol error. +#[derive(Debug)] +#[non_exhaustive] +pub enum Operation { + Connect, + Status, + GetSticker, + SetSticker, + SendToPlaylist, + SendMessage, + Update, + GetStoredPlaylists, + RspToUris, + GetStickers, + GetAllSongs, + Add, + Idle, + GetMessages, +} + +impl std::fmt::Display for Operation { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Operation::Connect => write!(f, "Connect"), + Operation::Status => write!(f, "Status"), + Operation::GetSticker => write!(f, "GetSticker"), + Operation::SetSticker => write!(f, "SetSticker"), + Operation::SendToPlaylist => write!(f, "SendToPlaylist"), + Operation::SendMessage => write!(f, "SendMessage"), + Operation::Update => write!(f, "Update"), + Operation::GetStoredPlaylists => write!(f, "GetStoredPlaylists"), + Operation::RspToUris => write!(f, "RspToUris"), + Operation::GetStickers => write!(f, "GetStickers"), + Operation::GetAllSongs => write!(f, "GetAllSongs"), + Operation::Add => write!(f, "Add"), + Operation::Idle => write!(f, "Idle"), + Operation::GetMessages => write!(f, "GetMessages"), + _ => write!(f, "Unknown client operation"), + } + } +} + +/// An MPD client error +#[derive(Debug, Snafu)] +#[non_exhaustive] +pub enum Error { + #[snafu(display("Protocol error ({}): {}", op, msg))] + Protocol { + op: Operation, + msg: String, + backtrace: Backtrace, + }, + #[snafu(display("Protocol errror ({}): {}", op, source))] + ProtocolConv { + op: Operation, + source: Box<dyn std::error::Error>, + backtrace: Backtrace, + }, + #[snafu(display("I/O error: {}", source))] + Io { + source: std::io::Error, + backtrace: Backtrace, + }, + #[snafu(display("Encoding error: {}", source))] + Encoding { + source: std::string::FromUtf8Error, + backtrace: Backtrace, + }, + #[snafu(display("While converting sticker ``{}'': {}", sticker, source))] + StickerConversion { + sticker: String, + source: Box<dyn std::error::Error>, + backtrace: Backtrace, + }, + #[snafu(display("``{}'' is not a recognized Idle subsystem", text))] + IdleSubSystem { text: String, backtrace: Backtrace }, +} + +pub type Result<T> = std::result::Result<T, Error>; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// A description of the current track, suitable for our purposes (as in, it only tracks the +/// attributes needed for this module's functionality). +#[derive(Clone, Debug)] +pub struct CurrentSong { + /// Identifier, unique within the play queue, identifying this particular track; if the same + /// file is listed twice in the `mpd' play queue each instance will get a distinct songid + pub songid: u64, + + /// Path, relative to `mpd' music directory root of this track + pub file: std::path::PathBuf, + + /// Elapsed time, in seconds, in this track + pub elapsed: f64, + + /// Total track duration, in seconds + pub duration: f64, +} + +impl CurrentSong { + fn new(songid: u64, file: std::path::PathBuf, elapsed: f64, duration: f64) -> CurrentSong { + CurrentSong { + songid, + file, + elapsed, + duration, + } + } + /// Compute the ratio of the track that has elapsed, expressed as a floating point between 0 & 1 + pub fn played_pct(&self) -> f64 { + self.elapsed / self.duration + } +} + +/// The MPD player itself can be in one of three states: playing, paused or stopped. In the first +/// two there is a "current" song. +#[derive(Clone, Debug)] +pub enum PlayerStatus { + Play(CurrentSong), + Pause(CurrentSong), + Stopped, +} + +impl PlayerStatus { + pub fn current_song(&self) -> Option<&CurrentSong> { + match self { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => Some(curr), + PlayerStatus::Stopped => None, + } + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Connection // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// A trait representing a simple, textual request/response protocol like that +/// [employed](https://www.musicpd.org/doc/html/protocol.html) by [MPD](https://www.musicpd.org/): +/// the caller sends a textual command & the server responds with a (perhaps multi-line) textual +/// response. +/// +/// This trait also enables unit testing client implementations. Note that it is async-- cf. +/// [async_trait](https://docs.rs/async-trait/latest/async_trait/). +#[async_trait] +pub trait RequestResponse { + async fn req(&mut self, msg: &str) -> Result<String>; + /// The hint is used to size the buffer prior to reading the response + async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String>; +} + +#[cfg(test)] +pub mod test_mock { + use super::*; + + /// Mock is an implementation of [`RequestRespone`] that checks expected requests & responses, + /// and will panic if it sees anything unexpected + pub struct Mock { + inmsgs: Vec<String>, + outmsgs: Vec<String>, + } + + impl Mock { + pub fn new(convo: &[(&str, &str)]) -> Mock { + let (left, right): (Vec<&str>, Vec<&str>) = convo.iter().copied().rev().unzip(); + Mock { + inmsgs: left.iter().map(|x| x.to_string()).collect(), + outmsgs: right.iter().map(|x| x.to_string()).collect(), + } + } + } + + #[async_trait] + impl RequestResponse for Mock { + async fn req(&mut self, msg: &str) -> Result<String> { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, _hint: usize) -> Result<String> { + assert_eq!(msg, self.inmsgs.pop().unwrap()); + Ok(self.outmsgs.pop().unwrap()) + } + } + + #[tokio::test] + async fn mock_smoke_test() { + let mut mock = Mock::new(&[("ping", "pong"), ("from", "to")]); + assert_eq!(mock.req("ping").await.unwrap(), "pong"); + assert_eq!(mock.req("from").await.unwrap(), "to"); + } + + #[tokio::test] + #[should_panic] + async fn mock_negative_test() { + let mut mock = Mock::new(&[("ping", "pong")]); + assert_eq!(mock.req("ping").await.unwrap(), "pong"); + let _should_panic = mock.req("not there!").await.unwrap(); + } +} + +/// [MPD](https://www.musicpd.org/) connections talk the same +/// [protocol](https://www.musicpd.org/doc/html/protocol.html) over either a TCP or a Unix socket. +/// +/// # Examples +/// +/// Implementations are provided for tokio [UnixStream] and [TcpStream], but [MpdConnection] is a +/// trait that can work in terms of any asynchronous communications channel (so long as it is also +/// [Send] and [Unpin] so async executors can pass them between threads. +/// +/// To create a connection to an `MPD` server over a Unix domain socket: +/// +/// ```no_run +/// use std::path::Path; +/// use tokio::net::UnixStream; +/// use mpdpopm::clients::MpdConnection; +/// let local_conn = MpdConnection::<UnixStream>::connect(Path::new("/var/run/mpd/mpd.sock")); +/// ``` +/// +/// In this example, `local_conn` is a Future that will resolve to a Result containing the +/// [MpdConnection] Unix domain socket implementation once the socket has been established, the MPD +/// server greets us & the protocol version has been parsed. +/// +/// or over a TCP socket: +/// +/// ```no_run +/// use std::net::SocketAddrV4; +/// use tokio::net::{TcpStream, ToSocketAddrs}; +/// use mpdpopm::clients::MpdConnection; +/// let tcp_conn = MpdConnection::<TcpStream>::connect("localhost:6600".parse::<SocketAddrV4>().unwrap()); +/// ``` +/// +/// Here, `tcp_conn` is a Future that will resolve to a Result containing the [MpdConnection] TCP +/// implementation on successful connection to the MPD server (i.e. the connection is established, +/// the server greets us & we parse the protocol version). +/// +/// +pub struct MpdConnection<T: AsyncRead + AsyncWrite + Send + Unpin> { + sock: T, + _protocol_ver: String, +} + +/// MpdConnection implements RequestResponse using the usual (async) socket I/O +/// +/// The callers need not include the trailing newline in their requests; the implementation will +/// append it. +#[async_trait] +impl<T> RequestResponse for MpdConnection<T> +where + T: AsyncRead + AsyncWrite + Send + Unpin, +{ + async fn req(&mut self, msg: &str) -> Result<String> { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String> { + self.sock + .write_all(format!("{}\n", msg).as_bytes()) + .await + .context(IoSnafu)?; + let mut buf = Vec::with_capacity(hint); + + // Given the request/response nature of the MPD protocol, our callers expect a complete + // response. Therefore we need to loop here until we see either "...^OK\n" or + // "...^ACK...\n". + let mut cb = 0; // # bytes read so far + let mut more = true; // true as long as there is more to read + while more { + cb += self.sock.read_buf(&mut buf).await.context(IoSnafu)?; + + // The shortest complete response has three bytes. If the final byte in `buf' is not a + // newline, then don't bother looking further. + if cb > 2 && char::from(buf[cb - 1]) == '\n' { + // If we're here, `buf' *may* contain a complete response. Search backward for the + // previous newline. It may not exist: many responses are of the form "OK\n". + let mut idx = cb - 2; + while idx > 0 { + if char::from(buf[idx]) == '\n' { + idx += 1; + break; + } + idx -= 1; + } + + if (idx + 2 < cb && char::from(buf[idx]) == 'O' && char::from(buf[idx + 1]) == 'K') + || (idx + 3 < cb + && char::from(buf[idx]) == 'A' + && char::from(buf[idx + 1]) == 'C' + && char::from(buf[idx + 2]) == 'K') + { + more = false; + } + } + } + + // Only doing this to trouble-shoot issue 11 + String::from_utf8(buf.clone()).context(EncodingSnafu) + } +} + +/// Utility function to parse the initial response to a connection from mpd +async fn parse_connect_rsp<T>(sock: &mut T) -> Result<String> +where + T: AsyncReadExt + AsyncWriteExt + Send + Unpin, +{ + let mut buf = Vec::with_capacity(32); + let _cb = sock.read_buf(&mut buf).await.context(IoSnafu)?; + // Only doing this to trouble-shoot issue 11 + let text = String::from_utf8(buf.clone()).context(EncodingSnafu)?; + ensure!( + text.starts_with("OK MPD "), + ProtocolSnafu { + op: Operation::Connect, + msg: text.trim() + } + ); + info!("Connected {}.", text[7..].trim()); + Ok(text[7..].trim().to_string()) +} + +impl MpdConnection<TcpStream> { + pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<Box<dyn RequestResponse>> { + let mut sock = TcpStream::connect(addr).await.context(IoSnafu)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection::<TcpStream> { + sock, + _protocol_ver: proto_ver, + })) + } +} + +impl MpdConnection<UnixStream> { + // NTS: we have to box the return value because a `dyn RequestResponse` isn't Sized. + pub async fn connect<P: AsRef<Path>>(pth: P) -> Result<Box<dyn RequestResponse>> { + let mut sock = UnixStream::connect(pth).await.context(IoSnafu)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection::<UnixStream> { + sock, + _protocol_ver: proto_ver, + })) + } +} + +/// Quote an argument by backslash-escaping " & \ characters +pub fn quote(text: &str) -> String { + if text.contains(&[' ', '\t', '\'', '"'][..]) { + let mut s = String::from("\""); + for c in text.chars() { + if c == '"' || c == '\\' { + s.push('\\'); + } + s.push(c); + } + s.push('"'); + s + } else { + text.to_string() + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Client // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// General-purpose [mpd](https://www.musicpd.org) +/// [client](https://www.musicpd.org/doc/html/protocol.html): "general-purpose" in the sense that we +/// send commands through it; the interface is narrowly scoped to this program's needs. +/// +/// # Introduction +/// +/// This is the primary abstraction of the MPD client protocol, written for the convenience of +/// [mpdpopm](crate). Construct instances with a TCP socket, a Unix socket, or any [RequestResponse] +/// implementation. You can then carry out assorted operations in the MPD client protocol by +/// invoking its methods. +/// +/// ```no_run +/// use std::path::Path; +/// use mpdpopm::clients::Client; +/// let client = Client::open(Path::new("/var/run/mpd.sock")); +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to a [Client] instance talking to `/var/run/mpd.sock`. +/// +/// ```no_run +/// use mpdpopm::clients::Client; +/// let client = Client::connect("localhost:6600"); +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to a [Client] instance talking TCP to the MPD server on localhost at port 6600. +pub struct Client { + stream: Box<dyn RequestResponse>, +} + +// Thanks to <https://stackoverflow.com/questions/35169259/how-to-make-a-compiled-regexp-a-global-variable> +lazy_static! { + static ref RE_STATE: regex::Regex = Regex::new(r"(?m)^state: (play|pause|stop)$").unwrap(); + static ref RE_SONGID: regex::Regex = Regex::new(r"(?m)^songid: ([0-9]+)$").unwrap(); + static ref RE_ELAPSED: regex::Regex = Regex::new(r"(?m)^elapsed: ([.0-9]+)$").unwrap(); + static ref RE_FILE: regex::Regex = Regex::new(r"(?m)^file: (.*)$").unwrap(); + static ref RE_DURATION: regex::Regex = Regex::new(r"(?m)^duration: (.*)$").unwrap(); +} + +impl Client { + pub async fn connect<A: ToSocketAddrs>(addrs: A) -> Result<Client> { + Self::new(MpdConnection::<TcpStream>::connect(addrs).await?) + } + + pub async fn open<P: AsRef<Path>>(pth: P) -> Result<Client> { + Self::new(MpdConnection::<UnixStream>::connect(pth).await?) + } + + pub fn new(stream: Box<dyn RequestResponse>) -> Result<Client> { + Ok(Client { stream }) + } +} + +impl Client { + /// Retrieve the current server status. + pub async fn status(&mut self) -> Result<PlayerStatus> { + // We begin with sending the "status" command: "Reports the current status of the player and + // the volume level." Per the docs, "MPD may omit lines which have no (known) value", so I + // can't really count on particular lines being there. Tho nothing is said in the docs, I + // also don't want to depend on the order. + let text = self.stream.req("status").await?; + + let proto = || -> Error { + ProtocolSnafu { + op: Operation::Status, + msg: text.to_owned(), + } + .build() + }; + + // I first thought to avoid the use (and cost) of regular expressions by just doing + // sub-string searching on "state: ", but when I realized I needed to only match at the + // beginning of a line I bailed & just went ahead. This makes for more succinct code, since + // I can't count on order, either. + let state = RE_STATE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + + match state { + "stop" => Ok(PlayerStatus::Stopped), + "play" | "pause" => { + let songid = RE_SONGID + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<u64>() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + let elapsed = RE_ELAPSED + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<f64>() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + // navigate from `songid'-- don't send a "currentsong" message-- the current song + // could have changed + let text = self.stream.req(&format!("playlistid {}", songid)).await?; + + let file = RE_FILE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + let duration = RE_DURATION + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<f64>() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration); + + if state == "play" { + Ok(PlayerStatus::Play(curr)) + } else { + Ok(PlayerStatus::Pause(curr)) + } + } + _ => ProtocolSnafu { + op: Operation::Status, + msg: state.to_owned(), + } + .fail(), + } + } + + /// Retrieve a song sticker by name + pub async fn get_sticker<T: FromStr>( + &mut self, + file: &str, + sticker_name: &str, + ) -> Result<Option<T>> + where + <T as FromStr>::Err: std::error::Error + Sync + Send + 'static, + { + let msg = format!("sticker get song {} {}", quote(file), quote(sticker_name)); + let text = self.stream.req(&msg).await?; + debug!("Sent message `{}'; got `{}'", &msg, &text); + + let prefix = format!("sticker: {}=", sticker_name); + if text.starts_with(&prefix) { + let s = text[prefix.len()..] + .split('\n') + .next() + .context(ProtocolSnafu { + op: Operation::GetSticker, + msg, + })?; + Ok(Some(T::from_str(s).map_err(|err| { + StickerConversionSnafu { + sticker: sticker_name.to_owned(), + } + .into_error(Box::new(err)) + })?)) + } else { + // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17) + ensure!( + text.starts_with("ACK [50@0]"), + ProtocolSnafu { + op: Operation::GetSticker, + msg, + } + ); + Ok(None) + } + } + + /// Set a song sticker by name + pub async fn set_sticker<T: std::fmt::Display>( + &mut self, + file: &str, + sticker_name: &str, + sticker_value: &T, + ) -> Result<()> { + let value_as_str = format!("{}", sticker_value); + let msg = format!( + "sticker set song {} {} {}", + quote(file), + quote(sticker_name), + quote(&value_as_str) + ); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SetSticker, + msg + } + ); + Ok(()) + } + + /// Send a file to a playlist + pub async fn send_to_playlist(&mut self, file: &str, pl: &str) -> Result<()> { + let msg = format!("playlistadd {} {}", quote(pl), quote(file)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SendToPlaylist, + msg + } + ); + Ok(()) + } + + /// Send an arbitrary message + pub async fn send_message(&mut self, chan: &str, msg: &str) -> Result<()> { + let msg = format!("sendmessage {} {}", chan, quote(msg)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SendMessage, + msg: text + } + ); + Ok(()) + } + + /// Update a URI + pub async fn update(&mut self, uri: &str) -> Result<u64> { + let msg = format!("update \"{}\"", uri); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + // We expect a response of the form: + // updating_db: JOBID + // OK + // on success, and + // ACK ERR + // on failure. + + let prefix = "updating_db: "; + ensure!( + text.starts_with(prefix), + ProtocolSnafu { + op: Operation::Update, + msg: &text + } + ); + text[prefix.len()..].split('\n').collect::<Vec<&str>>()[0] + .to_string() + .parse::<u64>() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Update, + } + .into_error(Box::new(err)) + }) + } + + /// Get the list of stored playlists + pub async fn get_stored_playlists(&mut self) -> Result<std::vec::Vec<String>> { + let text = self.stream.req("listplaylists").await?; + debug!("Sent listplaylists; got `{}'.", &text); + + // We expect a response of the form: + // playlist: a + // Last-Modified: 2020-03-13T17:20:16Z + // playlsit: b + // Last-Modified: 2020-03-13T17:20:16Z + // ... + // OK + // + // or + // + // ACK... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetStoredPlaylists, + msg: text + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("playlist: ").map(String::from)) + .collect::<Vec<String>>()) + } + + /// Process a search (either find or search) response + fn search_rsp_to_uris(&self, text: &str) -> Result<std::vec::Vec<String>> { + // We expect a response of the form: + // file: P/Pogues, The - A Pistol For Paddy Garcia.mp3 + // Last-Modified: 2007-12-26T19:18:00Z + // Format: 44100:24:2 + // ... + // file: P/Pogues, The - Billy's Bones.mp3 + // ... + // OK + // + // or + // + // ACK... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::RspToUris, + msg: text.to_owned() + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::<Vec<String>>()) + } + + /// Search the database for songs matching filter (unary operator) + /// + /// Set `case` to true to request a case-sensitive search (false yields case-insensitive) + pub async fn find1( + &mut self, + cond: &str, + val: &str, + case: bool, + ) -> Result<std::vec::Vec<String>> { + let cmd = format!( + "{} {}", + if case { "find" } else { "search" }, + quote(&format!("({} {})", cond, val)) + ); + let text = self.stream.req(&cmd).await?; + self.search_rsp_to_uris(&text) + } + + /// Search the database for songs matching filter (case-sensitive, binary operator) + /// + /// Set `case` to true to request a case-sensitive search (false yields case-insensitive) + pub async fn find2( + &mut self, + attr: &str, + op: &str, + val: &str, + case: bool, + ) -> Result<std::vec::Vec<String>> { + let cmd = format!( + "{} {}", + if case { "find" } else { "search" }, + quote(&format!("({} {} {})", attr, op, val)) + ); + debug!("find2 sending ``{}''", cmd); + let text = self.stream.req(&cmd).await?; + self.search_rsp_to_uris(&text) + } + + /// Retrieve all instances of a given sticker under the music directory + /// + /// Return a mapping from song URI to textual sticker value + pub async fn get_stickers(&mut self, sticker: &str) -> Result<HashMap<String, String>> { + let text = self + .stream + .req(&format!("sticker find song \"\" {}", sticker)) + .await?; + + // We expect a response of the form: + // + // file: U-Z/Zafari - Addis Adaba.mp3 + // sticker: unwoundstack.com:rating=64 + // ... + // file: U-Z/Zero 7 - In Time (Album Version).mp3 + // sticker: unwoundstack.com:rating=255 + // OK + // + // or + // + // ACK ... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetStickers, + msg: text, + } + ); + let mut m = HashMap::new(); + let mut lines = text.lines(); + loop { + let file = lines.next().context(ProtocolSnafu { + op: Operation::GetStickers, + msg: text.to_owned(), + })?; + if "OK" == file { + break; + } + let val = lines.next().context(ProtocolSnafu { + op: Operation::GetStickers, + msg: text.to_owned(), + })?; + + m.insert( + String::from(&file[6..]), + String::from(&val[10 + sticker.len()..]), + ); + } + Ok(m) + } + + /// Retrieve the song URIs of all songs in the database + /// + /// Returns a vector of String + pub async fn get_all_songs(&mut self) -> Result<std::vec::Vec<String>> { + let text = self.stream.req("find \"(base '')\"").await?; + // We expect a response of the form: + // file: 0-A/A Positive Life - Lighten Up!.mp3 + // Last-Modified: 2020-11-18T22:47:07Z + // Format: 44100:24:2 + // Time: 399 + // duration: 398.550 + // Artist: A Positive Life + // Title: Lighten Up! + // Genre: Electronic + // file: 0-A/A Positive Life - Pleidean Communication.mp3 + // ... + // OK + // + // or "ACK..." + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetAllSongs, + msg: text, + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::<Vec<String>>()) + } + + pub async fn add(&mut self, uri: &str) -> Result<()> { + let msg = format!("add {}", quote(uri)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Add, + msg: &text + } + ); + Ok(()) + } +} + +#[cfg(test)] +/// Let's test Client! +mod client_tests { + + use super::test_mock::Mock; + use super::*; + + /// Some basic "smoke" tests + #[tokio::test] + async fn client_smoke_test() { + let mock = Box::new(Mock::new(&[( + "sticker get song foo.mp3 stick", + "sticker: stick=splat\nOK\n", + )])); + let mut cli = Client::new(mock).unwrap(); + let val = cli + .get_sticker::<String>("foo.mp3", "stick") + .await + .unwrap() + .unwrap(); + assert_eq!(val, "splat"); + } + + /// Test the `status' method + #[tokio::test] + async fn test_status() { + let mock = Box::new(Mock::new(&[ + ( + "status", + // When the server is playing or paused, the response will look something like this: + "volume: -1 +repeat: 0 +random: 0 +single: 0 +consume: 0 +playlist: 3 +playlistlength: 87 +mixrampdb: 0.000000 +state: play +song: 14 +songid: 15 +time: 141:250 +bitrate: 128 +audio: 44100:24:2 +nextsong: 15 +nextsongid: 16 +elapsed: 140.585 +OK", + ), + // Should respond with a playlist id request + ( + "playlistid 15", + // Should look something like this: + "file: U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3 +Last-Modified: 2004-12-24T19:26:13Z +Artist: U2 +Title: Who's Gonna RIDE Your WILD HOR +Genre: Pop +Time: 316 +Pos: 41 +Id: 42 +duration: 249.994 +OK", + ), + ( + "status", + // But if the state is "stop", much of that will be missing; it will look more like: + "volume: -1 +repeat: 0 +random: 0 +single: 0 +consume: 0 +playlist: 84 +playlistlength: 27 +mixrampdb: 0.000000 +state: stop +OK", + ), + // Finally, let's simulate something being really wrong + ( + "status", + "volume: -1 +repeat: 0 +state: no-idea!?", + ), + ])); + let mut cli = Client::new(mock).unwrap(); + let stat = cli.status().await.unwrap(); + match stat { + PlayerStatus::Play(curr) => { + assert_eq!(curr.songid, 15); + assert_eq!( + curr.file.to_str().unwrap(), + "U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3" + ); + assert_eq!(curr.elapsed, 140.585); + assert_eq!(curr.duration, 249.994); + } + _ => panic!(), + } + + let stat = cli.status().await.unwrap(); + match stat { + PlayerStatus::Stopped => (), + _ => panic!(), + } + + let stat = cli.status().await; + match stat { + Err(_) => (), + Ok(_) => panic!(), + } + } + + /// Test the `get_sticker' method + #[tokio::test] + async fn test_get_sticker() { + let mock = Box::new(Mock::new(&[ + ( + "sticker get song foo.mp3 stick", + // On success, should get something like this... + "sticker: stick=2\nOK\n", + ), + ( + "sticker get song foo.mp3 stick", + // and on failure, something like this: + "ACK [50@0] {sticker} no such sticker\n", + ), + ( + "sticker get song foo.mp3 stick", + // Finally, let's try something nuts + "", + ), + ( + "sticker get song \"filename_with\\\"doublequotes\\\".flac\" unwoundstack.com:playcount", + "sticker: unwoundstack.com:playcount=11\nOK\n", + ), + ])); + let mut cli = Client::new(mock).unwrap(); + let val = cli + .get_sticker::<String>("foo.mp3", "stick") + .await + .unwrap() + .unwrap(); + assert_eq!(val, "2"); + let _val = cli + .get_sticker::<String>("foo.mp3", "stick") + .await + .unwrap() + .is_none(); + let _val = cli + .get_sticker::<String>("foo.mp3", "stick") + .await + .unwrap_err(); + let val = cli + .get_sticker::<String>( + "filename_with\"doublequotes\".flac", + "unwoundstack.com:playcount", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(val, "11"); + } + + /// Test the `set_sticker' method + #[tokio::test] + async fn test_set_sticker() { + let mock = Box::new(Mock::new(&[ + ("sticker set song foo.mp3 stick 2", "OK\n"), + ( + "sticker set song foo.mp3 stick 2", + "ACK [50@0] {sticker} some error", + ), + ( + "sticker set song foo.mp3 stick 2", + "this makes no sense as a response", + ), + ])); + let mut cli = Client::new(mock).unwrap(); + let () = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap(); + let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err(); + let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err(); + } + + /// Test the `send_to_playlist' method + #[tokio::test] + async fn test_send_to_playlist() { + let mock = Box::new(Mock::new(&[ + ("playlistadd foo.m3u foo.mp3", "OK\n"), + ( + "playlistadd foo.m3u foo.mp3", + "ACK [101@0] {playlist} some error\n", + ), + ])); + let mut cli = Client::new(mock).unwrap(); + let () = cli.send_to_playlist("foo.mp3", "foo.m3u").await.unwrap(); + let _val = cli + .send_to_playlist("foo.mp3", "foo.m3u") + .await + .unwrap_err(); + } + + /// Test the `update' method + #[tokio::test] + async fn test_update() { + let mock = Box::new(Mock::new(&[ + ("update \"foo.mp3\"", "updating_db: 2\nOK\n"), + ("update \"foo.mp3\"", "ACK [50@0] {update} blahblahblah"), + ("update \"foo.mp3\"", "this makes no sense as a response"), + ])); + let mut cli = Client::new(mock).unwrap(); + let _val = cli.update("foo.mp3").await.unwrap(); + let _val = cli.update("foo.mp3").await.unwrap_err(); + let _val = cli.update("foo.mp3").await.unwrap_err(); + } + + /// Test retrieving stored playlists + #[tokio::test] + async fn test_get_stored_playlists() { + let mock = Box::new(Mock::new(&[ + ( + "listplaylists", + "playlist: saturday-afternoons-in-santa-cruz +Last-Modified: 2020-03-13T17:20:16Z +playlist: gaelic-punk +Last-Modified: 2020-05-24T00:36:02Z +playlist: morning-coffee +Last-Modified: 2020-03-13T17:20:16Z +OK +", + ), + ("listplaylists", "ACK [1@0] {listplaylists} blahblahblah"), + ])); + + let mut cli = Client::new(mock).unwrap(); + let val = cli.get_stored_playlists().await.unwrap(); + assert_eq!( + val, + vec![ + String::from("saturday-afternoons-in-santa-cruz"), + String::from("gaelic-punk"), + String::from("morning-coffee") + ] + ); + let _val = cli.get_stored_playlists().await.unwrap_err(); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// IdleClient // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[non_exhaustive] +#[derive(Debug, PartialEq, Eq)] +pub enum IdleSubSystem { + Player, + Message, +} + +impl TryFrom<&str> for IdleSubSystem { + type Error = Error; + fn try_from(text: &str) -> std::result::Result<Self, Self::Error> { + let x = text.to_lowercase(); + if x == "player" { + Ok(IdleSubSystem::Player) + } else if x == "message" { + Ok(IdleSubSystem::Message) + } else { + Err(IdleSubSystemSnafu { + text: String::from(text), + } + .build()) + } + } +} + +impl fmt::Display for IdleSubSystem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + IdleSubSystem::Player => write!(f, "Player"), + IdleSubSystem::Message => write!(f, "Message"), + } + } +} + +/// [MPD](https://www.musicpd.org) client for "idle" connections. +/// +/// # Introduction +/// +/// This is an MPD client designed to "idle": it opens a long-lived connection to the MPD server and +/// waits for MPD to respond with a message indicating that there's been a change to a subsystem of +/// interest. At present, there are only two subsystems in which [mpdpopm](crate) is interested: the player +/// & messages (cf. [IdleSubSystem]). +/// +/// ```no_run +/// use std::path::Path; +/// use tokio::runtime::Runtime; +/// use mpdpopm::clients::IdleClient; +/// +/// let mut rt = Runtime::new().unwrap(); +/// rt.block_on( async { +/// let mut client = IdleClient::open(Path::new("/var/run/mpd.sock")).await.unwrap(); +/// client.subscribe("player").await.unwrap(); +/// client.idle().await.unwrap(); +/// // Arrives here when the player's state changes +/// }) +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to an [IdleClient] instance talking to `/var/run/mpd.sock`. +/// +pub struct IdleClient { + conn: Box<dyn RequestResponse>, +} + +impl IdleClient { + /// Create a new [mpdpopm::client::IdleClient][IdleClient] instance from something that + /// implements [ToSocketAddrs] + pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<IdleClient> { + Self::new(MpdConnection::<TcpStream>::connect(addr).await?) + } + + pub async fn open<P: AsRef<Path>>(pth: P) -> Result<IdleClient> { + Self::new(MpdConnection::<UnixStream>::connect(pth).await?) + } + + pub fn new(stream: Box<dyn RequestResponse>) -> Result<IdleClient> { + Ok(IdleClient { conn: stream }) + } + + /// Subscribe to an mpd channel + pub async fn subscribe(&mut self, chan: &str) -> Result<()> { + let text = self.conn.req(&format!("subscribe {}", chan)).await?; + debug!("Sent subscribe message for {}; got `{}'.", chan, text); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Connect, + msg: &text + } + ); + debug!("Subscribed to {}.", chan); + Ok(()) + } + + /// Enter idle state-- return the subsystem that changed, causing the connection to return. NB + /// this may block for some time. + pub async fn idle(&mut self) -> Result<IdleSubSystem> { + let text = self.conn.req("idle player message").await?; + debug!("Sent idle message; got `{}'.", text); + + // If the player state changes, we'll get: "changed: player\nOK\n" + // + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we respond + // "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + + ensure!( + text.starts_with("changed: "), + ProtocolSnafu { + op: Operation::Idle, + msg: &text + } + ); + let idx = text.find('\n').context(ProtocolSnafu { + op: Operation::Idle, + msg: text.to_owned(), + })?; + + let result = IdleSubSystem::try_from(&text[9..idx])?; + let text = text[idx + 1..].to_string(); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Idle, + msg: &text + } + ); + + Ok(result) + } + + /// This method simply returns the results of a "readmessages" as a HashMap of channel name to + /// Vec of (String) messages for that channel + pub async fn get_messages(&mut self) -> Result<HashMap<String, Vec<String>>> { + let text = self.conn.req("readmessages").await?; + debug!("Sent readmessages; got `{}'.", text); + + // We expect something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + + let mut m: HashMap<String, Vec<String>> = HashMap::new(); + + // Populate `m' with a little state machine: + enum State { + Init, + Running, + Finished, + } + let mut state = State::Init; + let mut chan = String::new(); + let mut msgs: Vec<String> = Vec::new(); + for line in text.lines() { + match state { + State::Init => { + ensure!( + line.starts_with("channel: "), + ProtocolSnafu { + op: Operation::GetMessages, + msg: line.to_owned() + } + ); + chan = String::from(&line[9..]); + state = State::Running; + } + State::Running => { + if let Some(stripped) = line.strip_prefix("message: ") { + msgs.push(String::from(stripped)); + } else if let Some(stripped) = line.strip_prefix("channel: ") { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + chan = String::from(stripped); + msgs = Vec::new(); + } else if line == "OK" { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + state = State::Finished; + } else { + return Err(ProtocolSnafu { + op: Operation::GetMessages, + msg: text, + } + .build()); + } + } + State::Finished => { + // Should never be here! + return Err(ProtocolSnafu { + op: Operation::GetMessages, + msg: String::from(line), + } + .build()); + } + } + } + + Ok(m) + } +} + +#[cfg(test)] +/// Let's test IdleClient! +mod idle_client_tests { + + use super::test_mock::Mock; + use super::*; + + /// Some basic "smoke" tests + #[tokio::test] + async fn test_get_messages() { + let mock = Box::new(Mock::new(&[( + "readmessages", + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we + // respond "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + "channel: ratings +message: 255 +message: 128 +channel: send-to-playlist +message: foo.m3u +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("ratings").unwrap(); + assert_eq!(val.len(), 2); + let val = hm.get("send-to-playlist").unwrap(); + assert!(val.len() == 1); + } + + /// Test issue #1 + #[tokio::test] + async fn test_issue_1() { + let mock = Box::new(Mock::new(&[( + "readmessages", + "channel: playcounts +message: a +channel: playcounts +message: b +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("playcounts").unwrap(); + assert_eq!(val.len(), 2); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs new file mode 100644 index 00000000..08509e47 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/config.rs @@ -0,0 +1,278 @@ +// Copyright (C) 2021-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mpdpopm Configuration +//! +//! ## Introduction +//! +//! This module defines the configuration struct & handles deserialization thereof. +//! +//! ## Discussion +//! +//! In the first releases of [mpdpopm](crate) I foolishly forgot to add a version field to the +//! configuration structure. I am now paying for my sin by having to attempt serializing two +//! versions until one succeeds. +//! +//! The idiomatic approach to versioning [serde](https://docs.serde.rs/serde/) structs seems to be +//! using an +//! [enumeration](https://www.reddit.com/r/rust/comments/44dds3/handling_multiple_file_versions_with_serde_or/). This +//! implementation *now* uses that, but that leaves us with the problem of handling the initial, +//! un-tagged version. I proceed as follows: +//! +//! 1. attempt to deserialize as a member of the modern enumeration +//! 2. if that succeeds, with the most-recent version, we're good +//! 3. if that succeeds with an archaic version, convert to the most recent and warn the user +//! 4. if that fails, attempt to deserialize as the initial struct version +//! 5. if that succeeds, convert to the most recent & warn the user +//! 6. if that fails, I'm kind of stuck because I don't know what the user was trying to express; +//! bundle-up all the errors, report 'em & urge the user to use the most recent version +use crate::vars::{LOCALSTATEDIR, PREFIX}; + +use serde::{Deserialize, Serialize}; + +use std::path::PathBuf; + +/// [mpdpopm](crate) can communicate with MPD over either a local Unix socket, or over regular TCP +#[derive(Debug, Deserialize, PartialEq, Serialize)] +pub enum Connection { + /// Local Unix socket-- payload is the path to the socket + Local { path: PathBuf }, + /// TCP-- payload is the hostname & port number + TCP { host: String, port: u16 }, +} + +#[cfg(test)] +mod test_connection { + use super::Connection; + + #[test] + fn test_serde() { + use serde_json::to_string; + + use std::path::PathBuf; + + let text = to_string(&Connection::Local { + path: PathBuf::from("/var/run/mpd.sock"), + }) + .unwrap(); + + assert_eq!( + text, + String::from(r#"{"Local":{"path":"/var/run/mpd.sock"}}"#) + ); + + let text = to_string(&Connection::TCP { + host: String::from("localhost"), + port: 6600, + }) + .unwrap(); + assert_eq!( + text, + String::from(r#"{"TCP":{"host":"localhost","port":6600}}"#) + ); + } +} + +impl std::default::Default for Connection { + fn default() -> Self { + Connection::TCP { + host: String::from("localhost"), + port: 6600, + } + } +} + +/// This is the most recent `mppopmd` configuration struct. +#[derive(Deserialize, Debug, Serialize)] +#[serde(default)] +pub struct Config { + /// Configuration format version-- must be "1" + // Workaround to https://github.com/rotty/lexpr-rs/issues/77 + // When this gets fixed, I can remove this element from the struct & deserialize as + // a Configurations element-- the on-disk format will be the same. + #[serde(rename = "version")] + _version: String, + + /// Location of log file + pub log: PathBuf, + + /// How to connect to mpd + pub conn: Connection, + + /// The `mpd' root music directory, relative to the host on which *this* daemon is running + pub local_music_dir: PathBuf, + + /// Percentage threshold, expressed as a number between zero & one, for considering a song to + /// have been played + pub played_thresh: f64, + + /// The interval, in milliseconds, at which to poll `mpd' for the current state + pub poll_interval_ms: u64, + + /// Channel to setup for assorted commands-- channel names must satisfy "[-a-zA-Z-9_.:]+" + pub commands_chan: String, +} + +impl Default for Config { + fn default() -> Config { + Config { + _version: String::from("1"), + log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(), + conn: Connection::default(), + local_music_dir: [PREFIX, "Music"].iter().collect(), + played_thresh: 0.6, + poll_interval_ms: 5000, + commands_chan: String::from("unwoundstack.com:commands"), + } + } +} + +#[derive(Debug)] +pub enum Error { + /// Failure to parse + ParseFail { err: Box<dyn std::error::Error> }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::ParseFail { err } => write!(f, "Parse failure: {}", err), + _ => write!(f, "Unknown configuration error"), + } + } +} + +pub type Result<T> = std::result::Result<T, Error>; + +pub fn from_str(text: &str) -> Result<Config> { + let cfg: Config = match serde_json::from_str(text) { + Ok(cfg) => cfg, + Err(err_outer) => { + return Err(Error::ParseFail { + err: Box::new(err_outer), + }); + } + }; + Ok(cfg) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[ignore = "We changed the config format to json"] + fn test_from_str() { + let cfg = Config::default(); + assert_eq!(cfg.commands_chan, String::from("unwoundstack.com:commands")); + + assert_eq!( + serde_json::to_string(&cfg).unwrap(), + format!( + r#"((version . "1") (log . "{}/log/mppopmd.log") (conn TCP (host . "localhost") (port . 6600)) (local_music_dir . "{}/Music") (playcount_sticker . "unwoundstack.com:playcount") (lastplayed_sticker . "unwoundstack.com:lastplayed") (played_thresh . 0.6) (poll_interval_ms . 5000) (commands_chan . "unwoundstack.com:commands") (playcount_command . "") (playcount_command_args) (rating_sticker . "unwoundstack.com:rating") (ratings_command . "") (ratings_command_args) (gen_cmds))"#, + LOCALSTATEDIR, PREFIX + ) + ); + + let cfg: Config = serde_json::from_str( + r#" +((version . "1") + (log . "/usr/local/var/log/mppopmd.log") + (conn TCP (host . "localhost") (port . 6600)) + (local_music_dir . "/usr/local/Music") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (commands_chan . "unwoundstack.com:commands") + (playcount_command . "") + (playcount_command_args) + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "") + (ratings_command_args) + (gen_cmds)) +"#, + ) + .unwrap(); + assert_eq!(cfg._version, String::from("1")); + + let cfg: Config = serde_json::from_str( + r#" +((version . "1") + (log . "/usr/local/var/log/mppopmd.log") + (conn Local (path . "/home/mgh/var/run/mpd/mpd.sock")) + (local_music_dir . "/usr/local/Music") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (commands_chan . "unwoundstack.com:commands") + (playcount_command . "") + (playcount_command_args) + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "") + (ratings_command_args) + (gen_cmds)) +"#, + ) + .unwrap(); + assert_eq!(cfg._version, String::from("1")); + assert_eq!( + cfg.conn, + Connection::Local { + path: PathBuf::from("/home/mgh/var/run/mpd/mpd.sock") + } + ); + + // Test fallback to "v0" of the config struct + let cfg = from_str(r#" +((log . "/home/mgh/var/log/mppopmd.log") + (host . "192.168.1.14") + (port . 6600) + (local_music_dir . "/space/mp3") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (playcount_command . "/usr/local/bin/scribbu") + (playcount_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-C" "%playcount" "%full-file")) + (commands_chan . "unwoundstack.com:commands") + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "/usr/local/bin/scribbu") + (ratings_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-r" "%rating" "%full-file")) + (gen_cmds . + (((name . "set-genre") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("genre" "-a" "-C" "-g" "%1" "%full-file")) + (update . TrackOnly)) + ((name . "set-xtag") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("xtag" "-A" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file")) + (update . TrackOnly)) + ((name . "merge-xtag") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("xtag" "-m" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file")) + (update . TrackOnly))))) +"#).unwrap(); + assert_eq!(cfg.log, PathBuf::from("/home/mgh/var/log/mppopmd.log")); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop new file mode 100644 index 00000000..a591a3ba --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop @@ -0,0 +1,143 @@ +// Copyright (C) 2020-2025 Michael Herstine <sp1ff@pobox.com> -*- mode: rust; rust-format-on-save: nil -*- +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +use lalrpop_util::ParseError; + +use crate::filters_ast::{Conjunction, Disjunction, Expression, OpCode, Selector, Term, Value, + expect_quoted, parse_iso_8601}; + +grammar; + +pub ExprOp: OpCode = { + "==" => OpCode::Equality, + "!=" => OpCode::Inequality, + "contains" => OpCode::Contains, + "=~" => OpCode::RegexMatch, + "!~" => OpCode::RegexExclude, + ">" => OpCode::GreaterThan, + "<" => OpCode::LessThan, + ">=" => OpCode::GreaterThanEqual, + "<=" => OpCode::LessThanEqual, +}; + +pub ExprSel: Selector = { + r"(?i)artist" => Selector::Artist, + r"(?i)album" => Selector::Album, + r"(?i)albumartist" => Selector::AlbumArtist, + r"(?i)titile" => Selector::Title, + r"(?i)track" => Selector::Track, + r"(?i)name" => Selector::Name, + r"(?i)genre" => Selector::Genre, + r"(?i)date" => Selector::Date, + r"(?i)originaldate" => Selector::OriginalDate, + r"(?i)composer" => Selector::Composer, + r"(?i)performer" => Selector::Performer, + r"(?i)conductor" => Selector::Conductor, + r"(?i)work" => Selector::Work, + r"(?i)grouping" => Selector::Grouping, + r"(?i)comment" => Selector::Comment, + r"(?i)disc" => Selector::Disc, + r"(?i)label" => Selector::Label, + r"(?i)musicbrainz_aristid" => Selector::MusicbrainzAristID, + r"(?i)musicbrainz_albumid" => Selector::MusicbrainzAlbumID, + r"(?i)musicbrainz_albumartistid" => Selector::MusicbrainzAlbumArtistID, + r"(?i)musicbrainz_trackid" => Selector::MusicbrainzTrackID, + r"(?i)musicbrainz_releasetrackid" => Selector::MusicbrainzReleaseTrackID, + r"(?i)musicbrainz_workid" => Selector::MusicbrainzWorkID, + r"(?i)file" => Selector::File, + r"(?i)base" => Selector::Base, + r"(?i)modified-since" => Selector::ModifiedSince, + r"(?i)audioformat" => Selector::AudioFormat, + r"(?i)rating" => Selector::Rating, + r"(?i)playcount" => Selector::PlayCount, + r"(?i)lastplayed" => Selector::LastPlayed, +}; + +pub Token: Value = { + <s:r"[0-9]+"> =>? { + eprintln!("matched token: ``{}''.", s); + // We need to yield a Result<Value, ParseError> + match s.parse::<usize>() { + Ok(n) => Ok(Value::Uint(n)), + Err(_) => Err(ParseError::User { + error: "Internal parse error while parsing unsigned int" }) + } + }, + <s:r#""([ \t'a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\"|\\')+""#> => { + eprintln!("matched token: ``{}''.", s); + let s = expect_quoted(s).unwrap(); + match parse_iso_8601(&mut s.as_bytes()) { + Ok(x) => Value::UnixEpoch(x), + Err(_) => Value::Text(s), + } + }, + <s:r#"'([ \t"a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\'|\\")+'"#> => { + eprintln!("matched token: ``{}''.", s); + let s = expect_quoted(s).unwrap(); + match parse_iso_8601(&mut s.as_bytes()) { + Ok(x) => Value::UnixEpoch(x), + Err(_) => Value::Text(s), + } + }, +}; + +pub Term: Box<Term> = { + <t:ExprSel> <u:Token> => { + eprintln!("matched unary condition: ``({}, {:#?})''", t, u); + Box::new(Term::UnaryCondition(t, u)) + }, + <t:ExprSel> <o:ExprOp> <u:Token> => { + eprintln!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u); + Box::new(Term::BinaryCondition(t, o, u)) + }, +} + +pub Conjunction: Box<Conjunction> = { + <e1:Expression> "AND" <e2:Expression> => { + eprintln!("matched conjunction: ``({:#?}, {:#?})''", e1, e2); + Box::new(Conjunction::Simple(e1, e2)) + }, + <c:Conjunction> "AND" <e:Expression> => { + eprintln!("matched conjunction: ``({:#?}, {:#?})''", c, e); + Box::new(Conjunction::Compound(c, e)) + }, +} + +pub Disjunction: Box<Disjunction> = { + <e1:Expression> "OR" <e2:Expression> => { + eprintln!("matched disjunction: ``({:#?}, {:#?})''", e1, e2); + Box::new(Disjunction::Simple(e1, e2)) + }, + <c:Disjunction> "OR" <e:Expression> => { + eprintln!("matched disjunction: ``({:#?}, {:#?})''", c, e); + Box::new(Disjunction::Compound(c, e)) + }, +} + +pub Expression: Box<Expression> = { + "(" <t:Term> ")" => { + eprintln!("matched parenthesized term: ``({:#?})''", t); + Box::new(Expression::Simple(t)) + }, + "(" "!" <e:Expression> ")" => Box::new(Expression::Negation(e)), + "(" <c:Conjunction> ")" => { + eprintln!("matched parenthesized conjunction: ``({:#?})''", c); + Box::new(Expression::Conjunction(c)) + }, + "(" <c:Disjunction> ")" => { + eprintln!("matched parenthesized disjunction: ``({:#?})''", c); + Box::new(Expression::Disjunction(c)) + }, +} diff --git a/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs new file mode 100644 index 00000000..7d30739d --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs @@ -0,0 +1,1172 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! Types for building the Abstract Syntax Tree when parsing filters +//! +//! This module provides support for our [lalrpop](https://github.com/lalrpop/lalrpop) grammar. + +use crate::clients::Client; +use crate::storage::{last_played, play_count, rating_count}; + +use backtrace::Backtrace; +use boolinator::Boolinator; +use chrono::prelude::*; +use tracing::debug; + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// The operations that can appear in a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum OpCode { + Equality, + Inequality, + Contains, + RegexMatch, + RegexExclude, + GreaterThan, + LessThan, + GreaterThanEqual, + LessThanEqual, +} + +impl std::fmt::Display for OpCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OpCode::Equality => "==", + OpCode::Inequality => "!=", + OpCode::Contains => "contains", + OpCode::RegexMatch => "=~", + OpCode::RegexExclude => "!~", + OpCode::GreaterThan => ">", + OpCode::LessThan => "<", + OpCode::GreaterThanEqual => ">=", + OpCode::LessThanEqual => "<=", + } + ) + } +} + +/// The song attributes that can appear on the LHS of a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Selector { + Artist, + Album, + AlbumArtist, + Title, + Track, + Name, + Genre, + Date, + OriginalDate, + Composer, + Performer, + Conductor, + Work, + Grouping, + Comment, + Disc, + Label, + MusicbrainzAristID, + MusicbrainzAlbumID, + MusicbrainzAlbumArtistID, + MusicbrainzTrackID, + MusicbrainzReleaseTrackID, + MusicbrainzWorkID, + File, + Base, + ModifiedSince, + AudioFormat, + Rating, + PlayCount, + LastPlayed, +} + +impl std::fmt::Display for Selector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Selector::Artist => "artist", + Selector::Album => "album", + Selector::AlbumArtist => "albumartist", + Selector::Title => "title", + Selector::Track => "track", + Selector::Name => "name", + Selector::Genre => "genre", + Selector::Date => "date", + Selector::OriginalDate => "originaldate", + Selector::Composer => "composer", + Selector::Performer => "performer", + Selector::Conductor => "conductor", + Selector::Work => "work", + Selector::Grouping => "grouping", + Selector::Comment => "comment", + Selector::Disc => "disc", + Selector::Label => "label", + Selector::MusicbrainzAristID => "musicbrainz_aristid", + Selector::MusicbrainzAlbumID => "musicbrainz_albumid", + Selector::MusicbrainzAlbumArtistID => "musicbrainz_albumartistid", + Selector::MusicbrainzTrackID => "musicbrainz_trackid", + Selector::MusicbrainzReleaseTrackID => "musicbrainz_releasetrackid", + Selector::MusicbrainzWorkID => "musicbrainz_workid", + Selector::File => "file", + Selector::Base => "base", + Selector::ModifiedSince => "modified-since", + Selector::AudioFormat => "AudioFormat", + Selector::Rating => "rating", + Selector::PlayCount => "playcount", + Selector::LastPlayed => "lastplayed", + } + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Text(String), + UnixEpoch(i64), + Uint(usize), +} + +fn quote_value(x: &Value) -> String { + match x { + Value::Text(s) => { + let mut ret = String::new(); + + ret.push('"'); + for c in s.chars() { + if c == '"' || c == '\\' { + ret.push('\\'); + } + ret.push(c); + } + ret.push('"'); + ret + } + Value::UnixEpoch(n) => { + format!("'{}'", n) + } + Value::Uint(n) => { + format!("'{}'", n) + } + } +} + +#[derive(Clone, Debug)] +pub enum Term { + UnaryCondition(Selector, Value), + BinaryCondition(Selector, OpCode, Value), +} + +#[derive(Clone, Debug)] +pub enum Conjunction { + Simple(Box<Expression>, Box<Expression>), + Compound(Box<Conjunction>, Box<Expression>), +} + +#[derive(Clone, Debug)] +pub enum Disjunction { + Simple(Box<Expression>, Box<Expression>), + Compound(Box<Disjunction>, Box<Expression>), +} + +#[derive(Clone, Debug)] +pub enum Expression { + Simple(Box<Term>), + Negation(Box<Expression>), + Conjunction(Box<Conjunction>), + Disjunction(Box<Disjunction>), +} + +#[cfg(test)] +mod smoke_tests { + + use super::*; + use crate::filters::*; + + #[test] + fn test_opcodes() { + assert!(ExprOpParser::new().parse("==").unwrap() == OpCode::Equality); + assert!(ExprOpParser::new().parse("!=").unwrap() == OpCode::Inequality); + assert!(ExprOpParser::new().parse("contains").unwrap() == OpCode::Contains); + assert!(ExprOpParser::new().parse("=~").unwrap() == OpCode::RegexMatch); + assert!(ExprOpParser::new().parse("!~").unwrap() == OpCode::RegexExclude); + assert!(ExprOpParser::new().parse(">").unwrap() == OpCode::GreaterThan); + assert!(ExprOpParser::new().parse("<").unwrap() == OpCode::LessThan); + assert!(ExprOpParser::new().parse(">=").unwrap() == OpCode::GreaterThanEqual); + assert!(ExprOpParser::new().parse("<=").unwrap() == OpCode::LessThanEqual); + } + + #[test] + fn test_conditions() { + assert!(TermParser::new().parse("base 'foo'").is_ok()); + assert!(TermParser::new().parse("artist == 'foo'").is_ok()); + assert!( + TermParser::new() + .parse(r#"artist =~ "foo bar \"splat\"!""#) + .is_ok() + ); + assert!(TermParser::new().parse("artist =~ 'Pogues'").is_ok()); + + match *TermParser::new() + .parse(r#"base "/Users/me/My Music""#) + .unwrap() + { + Term::UnaryCondition(a, b) => { + assert!(a == Selector::Base); + assert!(b == Value::Text(String::from(r#"/Users/me/My Music"#))); + } + _ => { + unreachable!(); + } + } + + match *TermParser::new() + .parse(r#"artist =~ "foo bar \"splat\"!""#) + .unwrap() + { + Term::BinaryCondition(t, op, s) => { + assert!(t == Selector::Artist); + assert!(op == OpCode::RegexMatch); + assert!(s == Value::Text(String::from(r#"foo bar "splat"!"#))); + } + _ => { + unreachable!(); + } + } + } + + #[test] + fn test_expressions() { + assert!(ExpressionParser::new().parse("( base 'foo' )").is_ok()); + assert!(ExpressionParser::new().parse("(base \"foo\")").is_ok()); + assert!( + ExpressionParser::new() + .parse("(!(artist == 'value'))") + .is_ok() + ); + assert!( + ExpressionParser::new() + .parse(r#"((!(artist == "foo bar")) AND (base "/My Music"))"#) + .is_ok() + ); + } + + #[test] + fn test_quoted_expr() { + eprintln!("test_quoted_expr"); + assert!( + ExpressionParser::new() + .parse(r#"(artist =~ "foo\\bar\"")"#) + .is_ok() + ); + } + + #[test] + fn test_real_expression() { + let result = ExpressionParser::new() + .parse(r#"(((Artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues')) AND ((rating > 128) OR (rating == 0)))"#); + eprintln!("{:#?}", result); + assert!(result.is_ok()); + } + + #[test] + fn test_conjunction() { + assert!(ExpressionParser::new() + .parse( + r#"((base "foo") AND (artist == "foo bar") AND (!(file == '/net/mp3/A/a.mp3')))"# + ) + .is_ok()); + + eprintln!("=============================================================================="); + eprintln!("{:#?}", ExpressionParser::new() + .parse( + r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == "/net/mp3/A/a.mp3")) OR (file == "/pub/mp3/A/a.mp3")))"# + )); + assert!(ExpressionParser::new() + .parse( + r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == '/net/mp3/A/a.mp3')) OR (file == '/pub/mp3/A/a.mp3')))"# + ) + .is_ok()); + } + + #[test] + fn test_disjunction() { + assert!(ExpressionParser::new(). + parse(r#"((artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues'))"#) + .is_ok()); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// evaluation logic // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum EvalOp { + And, + Or, + Not, +} + +impl std::fmt::Display for EvalOp { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + EvalOp::And => write!(f, "And"), + EvalOp::Or => write!(f, "Or"), + EvalOp::Not => write!(f, "Not"), + } + } +} + +#[derive(Debug)] +pub enum Error { + BadISO8601String { + text: Vec<u8>, + back: Backtrace, + }, + ExpectQuoted { + text: String, + back: Backtrace, + }, + FilterTypeErr { + text: String, + back: Backtrace, + }, + InvalidOperand { + op: OpCode, + back: Backtrace, + }, + OperatorOnStack { + op: EvalOp, + back: Backtrace, + }, + RatingOverflow { + rating: usize, + back: Backtrace, + }, + TooManyOperands { + num_ops: usize, + back: Backtrace, + }, + NumericParse { + sticker: String, + source: std::num::ParseIntError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadISO8601String { text, back: _ } => { + write!(f, "Bad ISO8601 timestamp: ``{:?}''", text) + } + Error::ExpectQuoted { text, back: _ } => write!(f, "Expected quote: ``{}''", text), + Error::FilterTypeErr { text, back: _ } => { + write!(f, "Un-expected type in filter ``{}''", text) + } + Error::InvalidOperand { op, back: _ } => write!(f, "Invalid operand {}", op), + Error::OperatorOnStack { op, back: _ } => { + write!(f, "Operator {} left on parse stack", op) + } + Error::RatingOverflow { rating, back: _ } => write!(f, "Rating {} overflows", rating), + Error::TooManyOperands { num_ops, back: _ } => { + write!(f, "Too many operands ({})", num_ops) + } + Error::NumericParse { + sticker, + source, + back: _, + } => write!(f, "While parsing sticker {}, got {}", sticker, source), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::NumericParse { + sticker: _, + source, + back: _, + } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +pub type Result<T> = std::result::Result<T, Error>; + +fn peek(buf: &[u8]) -> Option<char> { + match buf.len() { + 0 => None, + _ => Some(buf[0] as char), + } +} + +// advancing a slice by `i` indicies can *not* be this difficult +/// Pop a single byte off of `buf` +fn take1(buf: &mut &[u8], i: usize) -> Result<()> { + if i > buf.len() { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let (_first, second) = buf.split_at(i); + *buf = second; + Ok(()) +} + +/// Pop `i` bytes off of `buf` & parse them as a T +fn take2<T>(buf: &mut &[u8], i: usize) -> Result<T> +where + T: FromStr, +{ + // 1. check len + if i > buf.len() { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let (first, second) = buf.split_at(i); + *buf = second; + // 2. convert to a string + let s = std::str::from_utf8(first).map_err(|_| Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })?; + // 3. parse as a T + s.parse::<T>().map_err(|_err| Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }) // Parse*Error => Error +} + +/// Parse a timestamp in ISO 8601 format to a chrono DateTime instance +/// +/// Surprisingly, I was unable to find an ISO 8601 parser in Rust. I *did* find a crate named +/// iso-8601 that promised to do this, but it seemed derelict & I couldn't see what to do with the +/// parse output in any event. The ISO 8601 format is simple enough that I've chosen to simply +/// hand-parse it. +pub fn parse_iso_8601(buf: &mut &[u8]) -> Result<i64> { + // I wonder if `nom` would be a better choice? + + // The first four characters must be the year (expanded year representation is not supported by + // this parser). + + let year: i32 = take2(buf, 4)?; + + // Now at this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // - day & month := 0, consume the 'T', move on to parsing the time + // 3. we may have a month in extended format (i.e. peek(buf) => Some('-') + // - consume the '-', parse the month & move on to parsing the day + // 4. we may have a month in basic format (take(buf, 2) => Some('\d\d') + // - parse the month & move on to parsing the day + let mut month = 1; + let mut day = 1; + let mut hour = 0; + let mut minute = 0; + let mut second = 0; + if !buf.is_empty() { + let next = peek(buf); + if next != Some('T') { + let mut ext_fmt = false; + if next == Some('-') { + take1(buf, 1)?; + ext_fmt = true; + } + month = take2(buf, 2)?; + + // At this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // 3. we may have the day (in basic or extended format) + if !buf.is_empty() && peek(buf) != Some('T') { + if ext_fmt { + take1(buf, 1)?; + } + day = take2(buf, 2)?; + } + } + + // Parse time: at this point, buf will either be empty or begin with 'T' + if !buf.is_empty() { + take1(buf, 1)?; + // If there's a T, there must at least be an hour + hour = take2(buf, 2)?; + if !buf.is_empty() { + let mut ext_fmt = false; + if peek(buf) == Some(':') { + take1(buf, 1)?; + ext_fmt = true; + } + minute = take2(buf, 2)?; + if !buf.is_empty() { + if ext_fmt { + take1(buf, 1)?; + } + second = take2(buf, 2)?; + } + } + } + + // At this point, there may be a timezone + if !buf.is_empty() { + if peek(buf) == Some('Z') { + return Ok(Utc + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } else { + let next = peek(buf); + if next != Some('-') && next != Some('+') { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let west = next == Some('-'); + take1(buf, 1)?; + + let hours: i32 = take2(buf, 2)?; + let mut minutes = 0; + + if !buf.is_empty() { + if peek(buf) == Some(':') { + take1(buf, 1)?; + } + minutes = take2(buf, 2)?; + } + + if west { + return Ok(FixedOffset::west_opt(hours * 3600 + minutes * 60) + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } else { + return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60) + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } + } + } + } + Ok(Local + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()) +} + +#[cfg(test)] +mod iso_8601_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let mut b = "19700101T00:00:00Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 0); + + let mut b = "19700101T00:00:01Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 1); + + let mut b = "20210327T02:26:53Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616812013); + + let mut b = "20210327T07:29:05-07:00".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616855345); + + let mut b = "2021".as_bytes(); + // Should resolve to midnight, Jan 1 2021 in local time; don't want to test against the + // timestamp; just make sure it parses + parse_iso_8601(&mut b).unwrap(); + } +} + +/// "Un-quote" a token +/// +/// Textual tokens must be quoted, and double-quote & backslashes within backslash-escaped. If the +/// string is quoted with single-quotes, then any single-quotes inside the string will also need +/// to be escaped. +/// +/// In fact, *any* characters within may be harmlessly backslash escaped; the MPD implementation +/// walks the the string, skipping backslashes as it goes, so this implementation will do the same. +/// I have named this method in imitation of the corresponding MPD function. +pub fn expect_quoted(qtext: &str) -> Result<String> { + let mut iter = qtext.chars(); + let quote = iter.next(); + if quote.is_none() { + return Ok(String::new()); + } + + if quote != Some('\'') && quote != Some('"') { + return Err(Error::ExpectQuoted { + text: String::from(qtext), + back: Backtrace::new(), + }); + } + + let mut ret = String::new(); + + // Walk qtext[1..]; copying characters to `ret'. If a '\' is found, skip to the next character + // (even if that is a '\'). The last character in qtext should be the closing quote. + let mut this = iter.next(); + while this != quote { + if this == Some('\\') { + this = iter.next(); + } + match this { + Some(c) => ret.push(c), + None => { + return Err(Error::ExpectQuoted { + text: String::from(qtext), + back: Backtrace::new(), + }); + } + } + this = iter.next(); + } + + Ok(ret) +} + +#[cfg(test)] +mod quoted_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let b = r#""foo bar \"splat!\"""#; + let s = expect_quoted(b).unwrap(); + assert!(s == r#"foo bar "splat!""#); + } +} + +/// Create a closure that will carry out an operator on its argument +/// +/// Call this function with an [OpCode] and a value of type `T`. `T` must be [PartialEq], +/// [`PartialOrd`] and [`Copy`]-- an integral type will do. It will return a closure that will carry +/// out the given [OpCode] against the given value. For instance, +/// `make_numeric_closure::<u8>(OpCode::Equality, 11)` will return a closure that takes a `u8` & +/// will return true if its argument is 11 (and false otherwise). +/// +/// If [OpCode] is not pertinent to a numeric type, then this function will return Err. +fn make_numeric_closure<'a, T: 'a + PartialEq + PartialOrd + Copy>( + op: OpCode, + val: T, +) -> Result<impl Fn(T) -> bool + 'a> { + // Rust closures each have their own type, so this was the only way I could find to + // return them from match arms. This seems ugly; perhaps there's something I'm + // missing. + // + // I have no idea why I have to make these `move` closures; T is constrained to by Copy-able, + // so I would have expected the closure to just take a copy. + match op { + OpCode::Equality => Ok(Box::new(move |x: T| x == val) as Box<dyn Fn(T) -> bool>), + OpCode::Inequality => Ok(Box::new(move |x: T| x != val) as Box<dyn Fn(T) -> bool>), + OpCode::GreaterThan => Ok(Box::new(move |x: T| x > val) as Box<dyn Fn(T) -> bool>), + OpCode::LessThan => Ok(Box::new(move |x: T| x < val) as Box<dyn Fn(T) -> bool>), + OpCode::GreaterThanEqual => Ok(Box::new(move |x: T| x >= val) as Box<dyn Fn(T) -> bool>), + OpCode::LessThanEqual => Ok(Box::new(move |x: T| x <= val) as Box<dyn Fn(T) -> bool>), + _ => Err(Error::InvalidOperand { + op, + back: Backtrace::new(), + }), + } +} + +async fn eval_numeric_sticker_term< + // The `FromStr' trait bound is really weird, but if I don't constrain the associated + // Err type to be `ParseIntError' the compiler complains about not being able to convert + // it to type `Error'. I'm probably still "thinking in C++" and imagining the compiler + // instantiating this function for each type (u8, usize, &c) instead of realizing that the Rust + // compiler is processing this as a first-class function. + // + // For instance, I can do the conversion manually, so long as I constrain the Err type + // to implement std::error::Error. I should probably be doing that, but it clutters the + // code. I'll figure it out when I need to extend this function to handle non-integral types + // :) + T: PartialEq + PartialOrd + Copy + FromStr<Err = std::num::ParseIntError> + std::fmt::Display, +>( + sticker: &str, + client: &mut Client, + op: OpCode, + numeric_val: T, + default_val: T, +) -> Result<HashSet<String>> { + let cmp = make_numeric_closure(op, numeric_val)?; + // It would be better to idle on the sticker DB & just update our collection on change, but for + // a first impl. this will do. + // + // Call `get_stickers'; this will return a HashMap from song URIs to ratings expressed as text + // (as all stickers are). This stanza will drain that collection into a new one with the ratings + // expressed as T. + // + // The point is that conversion from text to rating, lastplayed, or whatever can fail; the + // invocation of `collect' will call `from_iter' to convert a collection of Result-s to a Result + // of a collection. + let mut m = client + .get_stickers(sticker) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain() + .map(|(k, v)| v.parse::<T>().map(|x| (k, x))) + .collect::<std::result::Result<HashMap<String, T>, _>>() + .map_err(|err| Error::NumericParse { + sticker: String::from(sticker), + source: err, + back: Backtrace::new(), + })?; + // `m' is now a map of song URI to rating/playcount/wathever (expressed as a T)... for all songs + // that have the salient sticker. + // + // This seems horribly inefficient, but I'm going to fetch all the song URIs in the music DB, + // and augment `m' with entries of `default_val' for any that are not already there. + client + .get_all_songs() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .for_each(|song| { + m.entry(song).or_insert(default_val); + }); + // Now that we don't have to worry about operations that can fail, we can use + // `filter_map'. + Ok(m.drain() + .filter_map(|(k, v)| cmp(v).as_some(k)) + .collect::<HashSet<String>>()) +} + +/// Convenience struct collecting the names for assorted stickers on which one may search +/// +/// While the search terms 'rating', 'playcount' &c are fixed & part of the filter grammar offered +/// by mpdpopm, the precise names of the corresponding stickers are configurable & hence must be +/// passed in. Three references to str is already unweildy IMO, and since I expect the number of +/// stickers on which one can search to grow further, I decided to wrap 'em up in a struct. The +/// lifetime is there to support the caller just using a reference to an existing string rather than +/// making a copy. +pub struct FilterStickerNames<'a> { + rating: &'a str, + playcount: &'a str, + lastplayed: &'a str, +} + +impl FilterStickerNames<'static> { + pub fn new() -> FilterStickerNames<'static> { + Self::default() + } +} + +impl Default for FilterStickerNames<'static> { + fn default() -> Self { + Self { + rating: rating_count::STICKER, + playcount: play_count::STICKER, + lastplayed: last_played::STICKER, + } + } +} + +/// Evaluate a Term +/// +/// Take a Term from the Abstract Syntax tree & resolve it to a collection of song URIs. Set `case` +/// to `true` to search case-sensitively & `false` to make the search case-insensitive. +async fn eval_term<'a>( + term: &Term, + case: bool, + client: &mut Client, + stickers: &FilterStickerNames<'a>, +) -> Result<HashSet<String>> { + match term { + Term::UnaryCondition(op, val) => Ok(client + .find1(&format!("{}", op), "e_value(val), case) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .collect()), + Term::BinaryCondition(attr, op, val) => { + if *attr == Selector::Rating { + match val { + Value::Uint(n) => { + if *n > 255 { + return Err(Error::RatingOverflow { + rating: *n, + back: Backtrace::new(), + }); + } + Ok( + eval_numeric_sticker_term(stickers.rating, client, *op, *n as u8, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else if *attr == Selector::PlayCount { + match val { + Value::Uint(n) => { + Ok( + eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else if *attr == Selector::LastPlayed { + match val { + Value::UnixEpoch(t) => { + Ok( + eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else { + Ok(client + .find2( + &format!("{}", attr), + &format!("{}", op), + "e_value(val), + case, + ) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .collect()) + } + } + } +} + +/// The evaluation stack contains logical operators & sets of song URIs +#[derive(Debug)] +enum EvalStackNode { + Op(EvalOp), + Result(HashSet<String>), +} + +async fn negate_result( + res: &HashSet<String>, + client: &mut Client, +) -> std::result::Result<HashSet<String>, Error> { + Ok(client + .get_all_songs() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .filter_map(|song| { + // Some(thing) adds thing, None elides it + if !res.contains(&song) { + Some(song) + } else { + None + } + }) + .collect::<HashSet<String>>()) +} + +/// Reduce the evaluation stack as far as possible. +/// +/// We can pop the stack in two cases: +/// +/// 1. S.len() > 2 and S[-3] is either And or Or, and both S[-1] & S[-2] are Result-s +/// 2. S.len() > 1, S[-2] is Not, and S[-1] is a Result +async fn reduce(stack: &mut Vec<EvalStackNode>, client: &mut Client) -> Result<()> { + loop { + let mut reduced = false; + let n = stack.len(); + if n > 1 { + // Take care to compute the reduction *before* popping the stack-- thank you, borrow + // checker! + let reduction = if let (EvalStackNode::Op(EvalOp::Not), EvalStackNode::Result(r)) = + (&stack[n - 2], &stack[n - 1]) + { + Some(negate_result(r, client).await?) + } else { + None + }; + + if let Some(res) = reduction { + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + let n = stack.len(); + if n > 2 { + // Take care to compute the reduction *before* popping the stack-- thank you, borrow + // checker! + let and_reduction = if let ( + EvalStackNode::Op(EvalOp::And), + EvalStackNode::Result(r1), + EvalStackNode::Result(r2), + ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1]) + { + Some(r1.intersection(r2).cloned().collect()) + } else { + None + }; + + if let Some(res) = and_reduction { + stack.pop(); + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + let n = stack.len(); + if n > 2 { + let or_reduction = if let ( + EvalStackNode::Op(EvalOp::Or), + EvalStackNode::Result(r1), + EvalStackNode::Result(r2), + ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1]) + { + Some(r1.union(r2).cloned().collect()) + } else { + None + }; + + if let Some(res) = or_reduction { + stack.pop(); + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + + if !reduced { + break; + } + } + + Ok(()) +} + +/// Evaluate an abstract syntax tree (AST) +pub async fn evaluate<'a>( + expr: &Expression, + case: bool, + client: &mut Client, + stickers: &FilterStickerNames<'a>, +) -> Result<HashSet<String>> { + // We maintain *two* stacks, one for parsing & one for evaluation. Let sp (for "stack(parse)") + // be a stack of references to nodes in the parse tree. + let mut sp = Vec::new(); + // Initialize it with the root; as we walk the tree, we'll pop the "most recent" node, and push + // children. + sp.push(expr); + + // Let se (for "stack(eval)") be a stack of operators & URIs. + let mut se = Vec::new(); + + // Simple DFS traversal of the AST: + while let Some(node) = sp.pop() { + // and dispatch based on what we've got: + match node { + // 1. we have a simple term: this can be immediately resolved to a set of song URIs. Do + // so & push the resulting set onto the evaluation stack. + Expression::Simple(bt) => se.push(EvalStackNode::Result( + eval_term(bt, case, client, stickers).await?, + )), + // 2. we have a negation: push the "not" operator onto the evaluation stack & the child + // onto the parse stack. + Expression::Negation(be) => { + se.push(EvalStackNode::Op(EvalOp::Not)); + sp.push(be); + } + // 3. conjunction-- push the "and" operator onto the evaluation stack & the children + // onto the parse stack (be sure to push the right-hand child first, so it will be + // popped second) + // bc is &Box<Conjunction<'a>>, so &**bc is &Conjunction<'a> + Expression::Conjunction(bc) => { + let mut conj = &**bc; + loop { + match conj { + Conjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**ber); + sp.push(&**bel); + break; + } + Conjunction::Compound(bc, be) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**be); + conj = bc; + } + } + } + } + Expression::Disjunction(bt) => { + let mut disj = &**bt; + loop { + match disj { + Disjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(ber); + sp.push(bel); + break; + } + Disjunction::Compound(bd, be) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(&**be); + disj = bd; + } + } + } + } + } + + reduce(&mut se, client).await?; + } + + // At this point, sp is empty, but there had better be something on se. Keep reducing the stack + // until either we can't any further (in which case we error) or there is only one element left + // (in which case we return that). + reduce(&mut se, client).await?; + + // Now, se had better have one element, and that element had better be a Result. + if 1 != se.len() { + debug!("Too many ({}) operands left on stack:", se.len()); + se.iter() + .enumerate() + .for_each(|(i, x)| debug!(" {}: {:#?}", i, x)); + return Err(Error::TooManyOperands { + num_ops: se.len(), + back: Backtrace::new(), + }); + } + + let ret = se.pop().unwrap(); + match ret { + EvalStackNode::Result(result) => Ok(result), + EvalStackNode::Op(op) => { + debug!("Operator left on stack (!?): {:#?}", op); + Err(Error::OperatorOnStack { + op, + back: Backtrace::new(), + }) + } + } +} + +#[cfg(test)] +mod evaluation_tests { + + use super::*; + use crate::filters::*; + + use crate::clients::Client; + use crate::clients::test_mock::Mock; + + #[tokio::test] + async fn smoke() { + let mock = Box::new(Mock::new(&[( + r#"find "(base \"foo\")""#, + "file: foo/a.mp3 +Artist: The Foobars +file: foo/b.mp3 +Title: b! +OK", + )])); + let mut cli = Client::new(mock).unwrap(); + + let stickers = FilterStickerNames::new(); + + let expr = ExpressionParser::new().parse(r#"(base "foo")"#).unwrap(); + let result = evaluate(&expr, true, &mut cli, &stickers).await; + assert!(result.is_ok()); + + let g: HashSet<String> = ["foo/a.mp3", "foo/b.mp3"] + .iter() + .map(|x| x.to_string()) + .collect(); + assert!(result.unwrap() == g); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs new file mode 100644 index 00000000..e4579db2 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs @@ -0,0 +1,273 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mpdpopm +//! +//! Maintain ratings & playcounts for your mpd server. +//! +//! # Introduction +//! +//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). +//! +//! # Commands +//! +//! I'm currently sending all commands over one (configurable) channel. +//! + +#![recursion_limit = "512"] // for the `select!' macro + +pub mod clients; +pub mod config; +pub mod filters_ast; +pub mod messages; +pub mod playcounts; +pub mod storage; +pub mod vars; + +#[rustfmt::skip] +#[allow(clippy::extra_unused_lifetimes)] +#[allow(clippy::needless_lifetimes)] +#[allow(clippy::let_unit_value)] +#[allow(clippy::just_underscores_and_digits)] +pub mod filters { + include!(concat!(env!("OUT_DIR"), "/src/filters.rs")); +} + +use clients::{Client, IdleClient, IdleSubSystem}; +use config::Config; +use config::Connection; +use filters_ast::FilterStickerNames; +use messages::MessageProcessor; +use playcounts::PlayState; + +use backtrace::Backtrace; +use futures::{future::FutureExt, pin_mut, select}; +use tokio::{ + signal, + signal::unix::{SignalKind, signal}, + time::{Duration, sleep}, +}; +use tracing::{debug, error, info}; + +use std::path::PathBuf; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + BadPath { + pth: PathBuf, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, + Playcounts { + source: crate::playcounts::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +pub type Result<T> = std::result::Result<T, Error>; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Core `mppopmd' logic +pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> { + info!("mpdpopm {} beginning.", vars::VERSION); + + let filter_stickers = FilterStickerNames::new(); + + let mut client = match cfg.conn { + Connection::Local { ref path } => { + Client::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + let mut state = PlayState::new(&mut client, cfg.played_thresh) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + let mut idle_client = match cfg.conn { + Connection::Local { ref path } => { + IdleClient::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + idle_client + .subscribe(&cfg.commands_chan) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + let mut hup = signal(SignalKind::hangup()).unwrap(); + let mut kill = signal(SignalKind::terminate()).unwrap(); + let ctrl_c = signal::ctrl_c().fuse(); + + let sighup = hup.recv().fuse(); + let sigkill = kill.recv().fuse(); + + let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse(); + pin_mut!(ctrl_c, sighup, sigkill, tick); + + let mproc = MessageProcessor::new(); + + let mut done = false; + while !done { + debug!("selecting..."); + let mut msg_check_needed = false; + { + // `idle_client' mutably borrowed here + let mut idle = Box::pin(idle_client.idle().fuse()); + loop { + select! { + _ = ctrl_c => { + info!("got ctrl-C"); + done = true; + break; + }, + _ = sighup => { + info!("got SIGHUP"); + done = true; + break; + }, + _ = sigkill => { + info!("got SIGKILL"); + done = true; + break; + }, + _ = tick => { + tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse()); + state.update(&mut client) + .await + .map_err(|err| Error::Playcounts { + source: err, + back: Backtrace::new() + })? + }, + // next = cmds.next() => match next { + // Some(out) => { + // debug!("output status is {:#?}", out.out); + // match out.upd { + // Some(uri) => { + // debug!("{} needs to be updated", uri); + // client.update(&uri).await.map_err(|err| Error::Client { + // source: err, + // back: Backtrace::new(), + // })?; + // }, + // None => debug!("No database update needed"), + // } + // }, + // None => { + // debug!("No more commands to process."); + // } + // }, + res = idle => match res { + Ok(subsys) => { + debug!("subsystem {} changed", subsys); + if subsys == IdleSubSystem::Player { + state.update(&mut client) + .await + .map_err(|err| Error::Playcounts { + source: err, + back: Backtrace::new() + })? + } else if subsys == IdleSubSystem::Message { + msg_check_needed = true; + } + break; + }, + Err(err) => { + debug!("error {err:#?} on idle"); + done = true; + break; + } + } + } + } + } + + if msg_check_needed { + // Check for any messages that have come in; if there's an error there's not a lot we + // can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it + // & move on. + if let Err(err) = mproc + .check_messages( + &mut client, + &mut idle_client, + state.last_status(), + &cfg.commands_chan, + &filter_stickers, + ) + .await + { + error!("Error while processing messages: {err:#?}"); + } + } + } + + info!("mpdpopm exiting."); + + Ok(()) +} diff --git a/pkgs/by-name/mp/mpdpopm/src/messages.rs b/pkgs/by-name/mp/mpdpopm/src/messages.rs new file mode 100644 index 00000000..c7c295c8 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/messages.rs @@ -0,0 +1,536 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # messages +//! +//! Process incoming messages to the [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon. +//! +//! # Introduction +//! +//! The [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon accepts commands over a dedicated +//! [channel](https://www.musicpd.org/doc/html/protocol.html#client-to-client). It also provides for +//! a generalized framework in which the [mpdpopm](https://github.com/sp1ff/mpdpopm) administrator +//! can define new commands backed by arbitrary command execution server-side. +//! +//! # Commands +//! +//! The following commands are built-in: +//! +//! - set rating: `rate RATING( TRACK)?` +//! - set playcount: `setpc PC( TRACK)?` +//! - set lastplayed: `setlp TIMESTAMP( TRACK)?` +//! +//! There is no need to provide corresponding accessors since this functionality is already provided +//! via "sticker get". Dedicated accessors could provide the same functionality with slightly more +//! convenience since the sticker name would not have to be specified (as with "sticker get") & may +//! be added at a later date. +//! +//! I'm expanding the MPD filter functionality to include attributes tracked by mpdpopm: +//! +//! - findadd replacement: `findadd FILTER [sort TYPE] [window START:END]` +//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database)) +//! +//! - searchadd replacement: `searchadd FILTER [sort TYPE] [window START:END]` +//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database)) +//! +//! Additional commands may be added through the +//! [generalized commands](crate::commands#the-generalized-command-framework) feature. + +use crate::{ + clients::{Client, IdleClient, PlayerStatus}, + filters::ExpressionParser, + filters_ast::{FilterStickerNames, evaluate}, +}; + +use backtrace::Backtrace; +use boolinator::Boolinator; +use tracing::debug; + +use std::collections::VecDeque; +use std::path::PathBuf; + +#[derive(Debug)] +pub enum Error { + BadPath { + pth: PathBuf, + }, + FilterParseError { + msg: String, + }, + InvalidChar { + c: u8, + }, + NoClosingQuotes, + NoCommand, + NotImplemented { + feature: String, + }, + PlayerStopped, + TrailingBackslash, + UnknownChannel { + chan: String, + back: Backtrace, + }, + UnknownCommand { + name: String, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, + Ratings { + source: crate::storage::Error, + back: Backtrace, + }, + Playcount { + source: crate::storage::Error, + back: Backtrace, + }, + Filter { + source: crate::filters_ast::Error, + back: Backtrace, + }, + Utf8 { + source: std::str::Utf8Error, + buf: Vec<u8>, + back: Backtrace, + }, + ExpectedInt { + source: std::num::ParseIntError, + text: String, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::FilterParseError { msg } => write!(f, "Parse error: ``{}''", msg), + Error::InvalidChar { c } => write!(f, "Invalid unquoted character {}", c), + Error::NoClosingQuotes => write!(f, "Missing closing quotes"), + Error::NoCommand => write!(f, "No command specified"), + Error::NotImplemented { feature } => write!(f, "`{}' not implemented, yet", feature), + Error::PlayerStopped => write!( + f, + "Can't operate on the current track when the player is stopped" + ), + Error::TrailingBackslash => write!(f, "Trailing backslash"), + Error::UnknownChannel { chan, back: _ } => write!( + f, + "We received messages for an unknown channel `{}'; this is likely a bug; please consider filing a report to sp1ff@pobox.com", + chan + ), + Error::UnknownCommand { name, back: _ } => { + write!(f, "We received an unknown message ``{}''", name) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Ratings { source, back: _ } => write!(f, "Ratings eror: {}", source), + Error::Playcount { source, back: _ } => write!(f, "Playcount error: {}", source), + Error::Filter { source, back: _ } => write!(f, "Filter error: {}", source), + Error::Utf8 { + source, + buf, + back: _, + } => write!(f, "UTF8 error {} ({:#?})", source, buf), + Error::ExpectedInt { + source, + text, + back: _, + } => write!(f, "``{}''L {}", source, text), + } + } +} + +pub type Result<T> = std::result::Result<T, Error>; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Break `buf` up into individual tokens while removing MPD-style quoting. +/// +/// When a client sends a command to [mpdpopm](crate), it will look like this on the wire: +/// +/// ```text +/// sendmessage ${CHANNEL} "some-command \"with space\" simple \"'with single' and \\\\\"" +/// ``` +/// +/// In other words, the MPD "sendmessage" command takes two parameters: the channel and the +/// message. The recipient (i.e. us) is responsible for breaking up the message into its constituent +/// parts (a command name & its arguments in our case). +/// +/// The message will perforce be quoted according ot the MPD rules: +/// +/// 1. an un-quoted token may contain any printable ASCII character except space, tab, ' & " +/// +/// 2. to include spaces, tabs, '-s or "-s, the token must be enclosed in "-s, and any "-s or \\-s +/// therein must be backslash escaped +/// +/// When the messages is delivered to us, it has already been un-escaped; i.e. we will see the +/// string: +/// +/// ```text +/// some-command "with space" simple "'with single' and \\" +/// ``` +/// +/// This function will break that string up into individual tokens with one more level +/// of escaping removed; i.e. it will return an iterator that will yield the four tokens: +/// +/// 1. some-command +/// 2. with space +/// 3. simple +/// 4. 'with single' and \\ +/// +/// [MPD](https://github.com/MusicPlayerDaemon/MPD) has a nice +/// [implementation](https://github.com/MusicPlayerDaemon/MPD/blob/master/src/util/Tokenizer.cxx#L170) +/// that modifies the string in place by copying subsequent characters on top of escape characters +/// in the same buffer, inserting nulls in between the resulting tokens,and then working in terms of +/// pointers to the resulting null-terminated strings. +/// +/// Once I realized that I could split slices I saw how to implement an Iterator that do the same +/// thing (an idiomatic interface to the tokenization backed by a zero-copy implementation). I was +/// inspired by [My Favorite Rust Function +/// Signature](<https://www.brandonsmith.ninja/blog/favorite-rust-function>). +/// +/// NB. This method works in terms of a slice of [`u8`] because we can't index into Strings in +/// Rust, and MPD deals only in terms of ASCII at any rate. +pub fn tokenize(buf: &mut [u8]) -> impl Iterator<Item = Result<&[u8]>> { + TokenIterator::new(buf) +} + +struct TokenIterator<'a> { + /// The slice on which we operate; modified in-place as we yield tokens + slice: &'a mut [u8], + /// Index into [`slice`] of the first non-whitespace character + input: usize, +} + +impl<'a> TokenIterator<'a> { + pub fn new(slice: &'a mut [u8]) -> TokenIterator<'a> { + let input = match slice.iter().position(|&x| x > 0x20) { + Some(n) => n, + None => slice.len(), + }; + TokenIterator { slice, input } + } +} + +impl<'a> Iterator for TokenIterator<'a> { + type Item = Result<&'a [u8]>; + + fn next(&mut self) -> Option<Self::Item> { + let nslice = self.slice.len(); + if self.slice.is_empty() || self.input == nslice { + None + } else if '"' == self.slice[self.input] as char { + // This is NextString in MPD: walk self.slice, un-escaping characters, until we find + // a closing ". Note that we un-escape by moving characters forward in the slice. + let mut inp = self.input + 1; + let mut out = self.input; + while self.slice[inp] as char != '"' { + if '\\' == self.slice[inp] as char { + inp += 1; + if inp == nslice { + return Some(Err(Error::TrailingBackslash)); + } + } + self.slice[out] = self.slice[inp]; + out += 1; + inp += 1; + if inp == nslice { + return Some(Err(Error::NoClosingQuotes)); + } + } + // The next token is in self.slice[self.input..out] and self.slice[inp] is " + let tmp = std::mem::take(&mut self.slice); + let (_, tmp) = tmp.split_at_mut(self.input); + let (result, new_slice) = tmp.split_at_mut(out - self.input); + self.slice = new_slice; + // strip any leading whitespace + self.input = inp - out + 1; // +1 to skip the closing " + while self.input < self.slice.len() && self.slice[self.input] as char == ' ' { + self.input += 1; + } + Some(Ok(result)) + } else { + // This is NextUnquoted in MPD; walk self.slice, validating characters until the end + // or the next whitespace + let mut i = self.input; + while i < nslice { + if 0x20 >= self.slice[i] { + break; + } + if self.slice[i] as char == '"' || self.slice[i] as char == '\'' { + return Some(Err(Error::InvalidChar { c: self.slice[i] })); + } + i += 1; + } + // The next token is in self.slice[self.input..i] & self.slice[i] is either one- + // past-the end or whitespace. + let tmp = std::mem::take(&mut self.slice); + let (_, tmp) = tmp.split_at_mut(self.input); + let (result, new_slice) = tmp.split_at_mut(i - self.input); + self.slice = new_slice; + // strip any leading whitespace + self.input = match self.slice.iter().position(|&x| x > 0x20) { + Some(n) => n, + None => self.slice.len(), + }; + Some(Ok(result)) + } + } +} + +/// Collective state needed for processing messages, both built-in & generalized +#[derive(Default)] +pub struct MessageProcessor {} + +impl MessageProcessor { + /// Whip up a new instance; other than cloning the iterators, should just hold references in the + /// enclosing scope + pub fn new() -> MessageProcessor { + Self::default() + } + + /// Read messages off the commands channel & dispatch 'em + pub async fn check_messages<'a>( + &self, + client: &mut Client, + idle_client: &mut IdleClient, + state: PlayerStatus, + command_chan: &str, + stickers: &FilterStickerNames<'a>, + ) -> Result<()> { + let m = idle_client + .get_messages() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + for (chan, msgs) in m { + // Only supporting a single channel, ATM + (chan == command_chan).ok_or_else(|| Error::UnknownChannel { + chan, + back: Backtrace::new(), + })?; + for msg in msgs { + self.process(msg, client, &state, stickers).await?; + } + } + + Ok(()) + } + + /// Process a single command + pub async fn process<'a>( + &self, + msg: String, + client: &mut Client, + state: &PlayerStatus, + stickers: &FilterStickerNames<'a>, + ) -> Result<()> { + if let Some(stripped) = msg.strip_prefix("findadd ") { + self.findadd(stripped.to_string(), client, stickers, state) + .await + } else if let Some(stripped) = msg.strip_prefix("searchadd ") { + self.searchadd(stripped.to_string(), client, stickers, state) + .await + } else { + unreachable!("Unkonwn command") + } + } + + /// Handle `findadd': "FILTER [sort TYPE] [window START:END]" + async fn findadd<'a>( + &self, + msg: String, + client: &mut Client, + stickers: &FilterStickerNames<'a>, + _state: &PlayerStatus, + ) -> Result<()> { + let mut buf = msg.into_bytes(); + let args: VecDeque<&str> = tokenize(&mut buf) + .map(|r| match r { + Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 { + source: err, + buf: buf.to_vec(), + back: Backtrace::new(), + })?), + Err(err) => Err(err), + }) + .collect::<Result<VecDeque<&str>>>()?; + + debug!("findadd arguments: {:#?}", args); + + // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet. + + // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the + // offending token, which has the same lifetime as our input, which makes it tough to + // capture. Nor is there a convenient way in which to treat all variants other than the + // Error Trait. + let ast = match ExpressionParser::new().parse(args[0]) { + Ok(ast) => ast, + Err(err) => { + return Err(Error::FilterParseError { + msg: format!("{}", err), + }); + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, true, client, stickers) + .await + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::<std::result::Result<Vec<()>, crate::clients::Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(Error::Client { + source: err, + back: Backtrace::new(), + }), + } + } + + /// Handle `searchadd': "FILTER [sort TYPE] [window START:END]" + async fn searchadd<'a>( + &self, + msg: String, + client: &mut Client, + stickers: &FilterStickerNames<'a>, + _state: &PlayerStatus, + ) -> Result<()> { + // Tokenize the message + let mut buf = msg.into_bytes(); + let args: VecDeque<&str> = tokenize(&mut buf) + .map(|r| match r { + Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 { + source: err, + buf: buf.to_vec(), + back: Backtrace::new(), + })?), + Err(err) => Err(err), + }) + .collect::<Result<VecDeque<&str>>>()?; + + debug!("searchadd arguments: {:#?}", args); + + // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet. + + // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the + // offending token, which has the same lifetime as our input, which makes it tough to + // capture. Nor is there a convenient way in which to treat all variants other than the + // Error Trait. + let ast = match ExpressionParser::new().parse(args[0]) { + Ok(ast) => ast, + Err(err) => { + return Err(Error::FilterParseError { + msg: format!("{}", err), + }); + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, false, client, stickers) + .await + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::<std::result::Result<Vec<()>, crate::clients::Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(Error::Client { + source: err, + back: Backtrace::new(), + }), + } + } +} + +#[cfg(test)] +mod tokenize_tests { + use super::Result; + use super::tokenize; + + #[test] + fn tokenize_smoke() { + let mut buf1 = String::from("some-command").into_bytes(); + let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x1[0], b"some-command"); + + let mut buf2 = String::from("a b").into_bytes(); + let x2: Vec<&[u8]> = tokenize(&mut buf2).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x2[0], b"a"); + assert_eq!(x2[1], b"b"); + + let mut buf3 = String::from("a \"b c\"").into_bytes(); + let x3: Vec<&[u8]> = tokenize(&mut buf3).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x3[0], b"a"); + assert_eq!(x3[1], b"b c"); + + let mut buf4 = String::from("a \"b c\" d").into_bytes(); + let x4: Vec<&[u8]> = tokenize(&mut buf4).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x4[0], b"a"); + assert_eq!(x4[1], b"b c"); + assert_eq!(x4[2], b"d"); + + let mut buf5 = String::from("simple-command \"with space\" \"with '\"").into_bytes(); + let x5: Vec<&[u8]> = tokenize(&mut buf5).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x5[0], b"simple-command"); + assert_eq!(x5[1], b"with space"); + assert_eq!(x5[2], b"with '"); + + let mut buf6 = String::from("cmd \"with\\\\slash and space\"").into_bytes(); + let x6: Vec<&[u8]> = tokenize(&mut buf6).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x6[0], b"cmd"); + assert_eq!(x6[1], b"with\\slash and space"); + + let mut buf7 = String::from(" cmd \"with\\\\slash and space\" ").into_bytes(); + let x7: Vec<&[u8]> = tokenize(&mut buf7).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(x7[0], b"cmd"); + assert_eq!(x7[1], b"with\\slash and space"); + } + + #[test] + fn tokenize_filter() { + let mut buf1 = String::from(r#""(artist =~ \"foo\\\\bar\\\"\")""#).into_bytes(); + let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap(); + assert_eq!(1, x1.len()); + eprintln!("x1[0] is ``{}''", std::str::from_utf8(x1[0]).unwrap()); + assert_eq!( + std::str::from_utf8(x1[0]).unwrap(), + r#"(artist =~ "foo\\bar\"")"# + ); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs new file mode 100644 index 00000000..6ae8f903 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs @@ -0,0 +1,367 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! playcounts -- managing play counts & lastplayed times +//! +//! # Introduction +//! +//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called +//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be +//! set explicitly: +//! +//! - `setpc PLAYCOUNT( TRACK)?` +//! - `setlp LASTPLAYED( TRACK)?` +//! + +use crate::clients::{Client, PlayerStatus}; +use crate::storage::{self, last_played, play_count, skipped}; + +use backtrace::Backtrace; +use tracing::{debug, info}; + +use std::path::PathBuf; +use std::time::SystemTime; + +#[derive(Debug)] +pub enum Error { + PlayerStopped, + BadPath { + pth: PathBuf, + }, + SystemTime { + source: std::time::SystemTimeError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::PlayerStopped => write!(f, "The MPD player is stopped"), + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::SystemTime { source, back: _ } => { + write!(f, "Couldn't get system time: {}", source) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::SystemTime { source, back: _ } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +impl From<storage::Error> for Error { + fn from(value: storage::Error) -> Self { + match value { + storage::Error::PlayerStopped => Self::PlayerStopped, + storage::Error::BadPath { pth } => Self::BadPath { pth }, + storage::Error::SystemTime { source, back } => Self::SystemTime { source, back }, + storage::Error::Client { source, back } => Self::Client { source, back }, + _ => unreachable!(), + } + } +} + +type Result<T> = std::result::Result<T, Error>; + +/// Current server state in terms of the play status (stopped/paused/playing, current track, elapsed +/// time in current track, &c) +#[derive(Debug)] +pub struct PlayState { + /// Last known server status + last_server_stat: PlayerStatus, + + /// true if we have already incremented the last known track's playcount + have_incr_play_count: bool, + + /// Percentage threshold, expressed as a number between zero & one, for considering a song to + /// have been played + played_thresh: f64, + last_song_was_skipped: bool, +} + +impl PlayState { + /// Create a new PlayState instance; async because it will reach out to the mpd server + /// to get current status. + pub async fn new( + client: &mut Client, + played_thresh: f64, + ) -> std::result::Result<PlayState, crate::clients::Error> { + Ok(PlayState { + last_server_stat: client.status().await?, + have_incr_play_count: false, + last_song_was_skipped: false, + played_thresh, + }) + } + + /// Retrieve a copy of the last known player status + pub fn last_status(&self) -> PlayerStatus { + self.last_server_stat.clone() + } + + /// Poll the server-- update our status; maybe increment the current track's play count; the + /// caller must arrange to have this method invoked periodically to keep our state fresh + pub async fn update(&mut self, client: &mut Client) -> Result<()> { + let new_stat = client.status().await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match (&self.last_server_stat, &new_stat) { + (PlayerStatus::Play(last), PlayerStatus::Play(curr)) + | (PlayerStatus::Pause(last), PlayerStatus::Play(curr)) + | (PlayerStatus::Play(last), PlayerStatus::Pause(curr)) + | (PlayerStatus::Pause(last), PlayerStatus::Pause(curr)) => { + // Last we knew, we were playing, and we're playing now. + if last.songid != curr.songid { + debug!("New songid-- resetting PC incremented flag."); + + if !self.have_incr_play_count { + // We didn't mark the previous song as played. + // As such, the user must have skipped it :( + self.last_song_was_skipped = true; + } + + self.have_incr_play_count = false; + } else if last.elapsed > curr.elapsed + && self.have_incr_play_count + && curr.elapsed / curr.duration <= 0.1 + { + debug!("Re-play-- resetting PC incremented flag."); + self.have_incr_play_count = false; + } + } + (PlayerStatus::Stopped, PlayerStatus::Play(_)) + | (PlayerStatus::Stopped, PlayerStatus::Pause(_)) + | (PlayerStatus::Pause(_), PlayerStatus::Stopped) + | (PlayerStatus::Play(_), PlayerStatus::Stopped) => { + self.have_incr_play_count = false; + } + (PlayerStatus::Stopped, PlayerStatus::Stopped) => (), + } + + match &new_stat { + PlayerStatus::Play(curr) => { + let pct = curr.played_pct(); + debug!("Updating status: {:.3}% complete.", 100.0 * pct); + if !self.have_incr_play_count && pct >= self.played_thresh { + info!( + "Increment play count for '{}' (songid: {}) at {} played.", + curr.file.display(), + curr.songid, + curr.elapsed / curr.duration + ); + + let file = curr.file.to_str().ok_or_else(|| Error::BadPath { + pth: curr.file.clone(), + })?; + + let curr_pc = play_count::get(client, file).await?.unwrap_or_default(); + + debug!("Current PC is {}.", curr_pc); + + last_played::set( + client, + file, + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|err| Error::SystemTime { + source: err, + back: Backtrace::new(), + })? + .as_secs(), + ) + .await?; + self.have_incr_play_count = true; + + play_count::set(client, file, curr_pc + 1).await?; + } else if self.last_song_was_skipped { + self.last_song_was_skipped = false; + let last = self + .last_server_stat + .current_song() + .expect("To exist, as it was skipped"); + + info!( + "Marking '{}' (songid: {}) as skipped at {}.", + last.file.display(), + last.songid, + last.elapsed / last.duration + ); + + let file = last.file.to_str().ok_or_else(|| Error::BadPath { + pth: last.file.clone(), + })?; + + let skip_count = skipped::get(client, file).await?.unwrap_or_default(); + skipped::set(client, file, skip_count + 1).await?; + } + } + PlayerStatus::Pause(_) | PlayerStatus::Stopped => (), + }; + + self.last_server_stat = new_stat; + Ok(()) // No need to update the DB + } +} + +#[cfg(test)] +mod player_state_tests { + use super::*; + use crate::clients::test_mock::Mock; + + /// "Smoke" tests for player state + #[tokio::test] + async fn player_state_smoke() { + let mock = Box::new(Mock::new(&[ + ( + "status", + "repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: stop +xfade: 5 +song: 51 +songid: 52 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "status", + "volume: 100 +repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: play +xfade: 5 +song: 51 +songid: 52 +time: 5:228 +elapsed: 5.337 +bitrate: 192 +duration: 227.637 +audio: 44100:24:2 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "playlistid 52", + "file: E/Enya - Wild Child.mp3 +Last-Modified: 2008-11-09T00:06:30Z +Artist: Enya +Title: Wild Child +Album: A Day Without Rain (Japanese Retail) +Date: 2000 +Genre: Celtic +Time: 228 +duration: 227.637 +Pos: 51 +Id: 52 +OK +", + ), + ( + "status", + "volume: 100 +repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: play +xfade: 5 +song: 51 +songid: 52 +time: 5:228 +elapsed: 200 +bitrate: 192 +duration: 227.637 +audio: 44100:24:2 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "playlistid 52", + "file: E/Enya - Wild Child.mp3 +Last-Modified: 2008-11-09T00:06:30Z +Artist: Enya +Title: Wild Child +Album: A Day Without Rain (Japanese Retail) +Date: 2000 +Genre: Celtic +Time: 228 +duration: 227.637 +Pos: 51 +Id: 52 +OK +", + ), + ( + "sticker get song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount", + "sticker: unwoundstack.com:playcount=11\nOK\n", + ), + ( + &format!( + "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:lastplayed {}", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + "OK\n", + ), + ("sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount 12", "OK\n"), + ])); + + let mut cli = Client::new(mock).unwrap(); + let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap(); + let check = match ps.last_status() { + PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false, + PlayerStatus::Stopped => true, + }; + assert!(check); + + ps.update(&mut cli).await.unwrap(); + ps.update(&mut cli).await.unwrap() + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs new file mode 100644 index 00000000..d64f17c1 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs @@ -0,0 +1,210 @@ +use std::path::PathBuf; + +use backtrace::Backtrace; + +#[derive(Debug)] +pub enum Error { + PlayerStopped, + BadPath { + pth: PathBuf, + }, + SystemTime { + source: std::time::SystemTimeError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::PlayerStopped => write!(f, "The MPD player is stopped"), + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::SystemTime { source, back: _ } => { + write!(f, "Couldn't get system time: {}", source) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::SystemTime { source, back: _ } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +type Result<T> = std::result::Result<T, Error>; + +pub mod play_count { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:playcount"; + + /// Retrieve the play count for a track + pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> { + match client + .get_sticker::<usize>(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the play count for a track-- this will run the associated command, if any + pub async fn set(client: &mut Client, file: &str, play_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", play_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + #[cfg(test)] + mod pc_lp_tests { + use super::*; + use crate::{clients::test_mock::Mock, storage::play_count}; + + /// "Smoke" tests for play counts & last played times + #[tokio::test] + async fn pc_smoke() { + let mock = Box::new(Mock::new(&[ + ( + "sticker get song a unwoundstack.com:playcount", + "sticker: unwoundstack.com:playcount=11\nOK\n", + ), + ( + "sticker get song a unwoundstack.com:playcount", + "ACK [50@0] {sticker} no such sticker\n", + ), + ("sticker get song a unwoundstack.com:playcount", "splat!"), + ])); + let mut cli = Client::new(mock).unwrap(); + + assert_eq!(play_count::get(&mut cli, "a").await.unwrap().unwrap(), 11); + let val = play_count::get(&mut cli, "a").await.unwrap(); + assert!(val.is_none()); + play_count::get(&mut cli, "a").await.unwrap_err(); + } + } +} + +pub mod skipped { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + const STICKER: &str = "unwoundstack.com:skipped_count"; + + /// Retrieve the skip count for a track + pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> { + match client + .get_sticker::<usize>(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the skip count for a track + pub async fn set(client: &mut Client, file: &str, skip_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", skip_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } +} + +pub mod last_played { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:lastplayed"; + + /// Retrieve the last played timestamp for a track (seconds since Unix epoch) + pub async fn get(client: &mut Client, file: &str) -> Result<Option<u64>> { + client + .get_sticker::<u64>(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } + + /// Set the last played for a track + pub async fn set(client: &mut Client, file: &str, last_played: u64) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", last_played)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) + } +} + +pub mod rating_count { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:ratings_count"; + + /// Retrieve the rating count for a track + pub async fn get(client: &mut Client, file: &str) -> Result<Option<i8>> { + client + .get_sticker::<i8>(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } + + /// Set the rating count for a track + pub async fn set(client: &mut Client, file: &str, rating_count: i8) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", rating_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/vars.rs b/pkgs/by-name/mp/mpdpopm/src/vars.rs new file mode 100644 index 00000000..29b9610d --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs @@ -0,0 +1,5 @@ +pub static VERSION: &str = env!("CARGO_PKG_VERSION"); +pub static AUTHOR: &str = env!("CARGO_PKG_AUTHORS"); +pub static LOCALSTATEDIR: &str = "/home/soispha/.local/state"; +pub static PREFIX: &str = "/home/soispha/.local/share/mpdpopm"; + diff --git a/pkgs/by-name/mp/mpdpopm/update.sh b/pkgs/by-name/mp/mpdpopm/update.sh new file mode 100755 index 00000000..cf04b837 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/update.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +# Mpdpopm - A mpd rating tracker +# +# Copyright (C) 2026 Benedikt Peetz, Michael Herstine <sp1ff@pobox.com> <benedikt.peetz@b-peetz.de, sp1ff@pobox.com> +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of Mpdpopm. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/agpl.txt>. + +nix flake update + +[ "$1" = "upgrade" ] && cargo upgrade +cargo update |
