From 6d386dfe9a171580ca6f0182314598a4641f79ab Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Fri, 9 Oct 2026 16:52:03 +0200 Subject: [PATCH] Add source code --- Cargo.lock | 1358 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 + src/app.rs | 1458 ++++++++++++++++++++++++++++++++++++++++++++++++ src/command.rs | 199 +++++++ src/config.rs | 204 +++++++ src/main.rs | 151 +++++ src/model.rs | 129 +++++ src/player.rs | 268 +++++++++ src/session.rs | 98 ++++ src/store.rs | 92 +++ src/ui.rs | 602 ++++++++++++++++++++ src/ytmusic.rs | 320 +++++++++++ 12 files changed, 4900 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/app.rs create mode 100644 src/command.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/model.rs create mode 100644 src/player.rs create mode 100644 src/session.rs create mode 100644 src/store.rs create mode 100644 src/ui.rs create mode 100644 src/ytmusic.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2eaed14 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1358 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "drift" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "libc", + "ratatui", + "serde", + "serde_json", + "toml", + "ureq", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6cec4bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "drift" +version = "0.1.0" +edition = "2021" +description = "A Vim-style terminal music player for YouTube Music (no login required)" +license = "MIT" + +[dependencies] +ratatui = "0.29" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +ureq = { version = "2", features = ["json"] } +dirs = "5" +libc = "0.2" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..f426f71 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1458 @@ +use std::collections::HashSet; +use std::sync::mpsc::Sender; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::widgets::TableState; + +use crate::command::{self, Amount, Command, COMMAND_NAMES}; +use crate::config::{self, Config}; +use crate::model::{split_artists, RepeatMode, Track}; +use crate::player::{Player, PlayerEvent}; +use crate::session::Session; +use crate::store::Store; +use crate::ytmusic; + +pub enum AppEvent { + Player(PlayerEvent), + Results { + id: u64, + title: String, + result: Result, String>, + }, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum View { + Results, + Queue, + Playlists, +} + +impl View { + pub fn idx(self) -> usize { + match self { + View::Results => 0, + View::Queue => 1, + View::Playlists => 2, + } + } + fn from_idx(i: usize) -> View { + [View::Results, View::Queue, View::Playlists][i % 3] + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Normal, + Command, + ArtistPick, + Help, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PlayState { + Stopped, + Loading, + Playing, + Paused, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Level { + Info, + Error, +} + +pub struct Status { + pub text: String, + pub level: Level, + pub at: Instant, +} + +pub struct App { + pub view: View, + pub mode: Mode, + pub results: Vec, + pub results_title: String, + pub queue: Vec, + pub current: Option, + pub store: Store, + pub states: [TableState; 3], + pub open: Option, + pub pl_state: TableState, + + pub cmd_buf: String, + pub artist_opts: Vec, + pub artist_sel: usize, + pub help_scroll: usize, + history: Vec, + hist_pos: Option, + pub count: Option, + pub pending: Option, + + pub play_state: PlayState, + pub position: f64, + pub duration: f64, + pub volume: u8, + pub shuffle: bool, + pub repeat: RepeatMode, + played: HashSet, + fail_streak: u8, + autoplay: bool, + seek_step: f64, + volume_step: f64, + resume: Option<(String, f64)>, + saved: String, + save_failed: bool, + + pub status: Option, + pub busy: Option, + pub tick: usize, + pub should_quit: bool, + + search_id: u64, + player: Option, + tx: Sender, + rng: u64, +} + +impl App { + pub fn new(tx: Sender) -> App { + let (store, store_warning) = Store::load(); + config::migrate_legacy(); + let (cfg, cfg_warning) = Config::load(); + let p = cfg.playback; + let session = if p.restore_session { Session::load() } else { Session::default() }; + let warning = cfg_warning.or(store_warning); + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15) + | 1; + let mut app = App { + view: View::Results, + mode: Mode::Normal, + results: Vec::new(), + results_title: "Results".into(), + queue: session.queue, + current: session.current, + store, + states: Default::default(), + open: None, + pl_state: TableState::default(), + cmd_buf: String::new(), + artist_opts: Vec::new(), + artist_sel: 0, + help_scroll: 0, + history: Vec::new(), + hist_pos: None, + count: None, + pending: None, + play_state: PlayState::Stopped, + position: 0.0, + duration: 0.0, + volume: p.volume, + shuffle: p.shuffle, + repeat: p.repeat, + played: HashSet::new(), + fail_streak: 0, + autoplay: p.autoplay, + seek_step: p.seek_step, + volume_step: p.volume_step, + resume: None, + saved: String::new(), + save_failed: false, + status: None, + busy: None, + tick: 0, + should_quit: false, + search_id: 0, + player: None, + tx, + rng: seed, + }; + match warning { + Some(w) => app.error(w), + None => app.info("Welcome! Press / to search, ? for help."), + } + if let Some(t) = app.now_track().cloned() { + app.position = session.position; + app.duration = t.duration.map_or(0.0, f64::from); + app.resume = Some((t.id, session.position)); + app.states[View::Queue.idx()].select(app.current); + if app.autoplay { + app.play_default(); + } + } + app + } + + pub fn shutdown(&mut self) { + if let Err(e) = self.save_session(true) { + eprintln!("drift: could not save session: {e:#}"); + } + self.player = None; + } + + fn save_session(&mut self, force: bool) -> Result<()> { + let session = Session { + queue: self.queue.clone(), + current: self.current, + position: self.position, + }; + let text = session.render()?; + if !force && text == self.saved { + return Ok(()); + } + Session::write(&text)?; + self.saved = text; + Ok(()) + } + + pub fn info(&mut self, msg: impl Into) { + self.set_status(msg.into(), Level::Info); + } + + pub fn error(&mut self, msg: impl Into) { + self.set_status(msg.into(), Level::Error); + } + + fn set_status(&mut self, text: String, level: Level) { + self.status = Some(Status { text, level, at: Instant::now() }); + } + + pub fn visible_status(&self) -> Option<&Status> { + self.status.as_ref().filter(|s| { + s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 } + }) + } + + pub fn on_tick(&mut self) { + self.tick = self.tick.wrapping_add(1); + if self.tick % 50 == 0 { + match self.save_session(false) { + Ok(()) => self.save_failed = false, + Err(e) if !self.save_failed => { + self.save_failed = true; + self.error(format!("Could not save session: {e:#}")); + } + Err(_) => {} + } + } + } + + pub fn len_of(&self, v: View) -> usize { + match v { + View::Results => self.results.len(), + View::Queue => self.queue.len(), + View::Playlists => self.store.playlists.len(), + } + } + + pub fn in_playlist(&self) -> bool { + self.view == View::Playlists + && self.open.as_ref().is_some_and(|n| self.store.playlists.contains_key(n)) + } + + pub fn open_len(&self) -> usize { + self.open.as_ref().and_then(|n| self.store.playlists.get(n)).map_or(0, |t| t.len()) + } + + fn cur_len(&self) -> usize { + if self.in_playlist() { + self.open_len() + } else { + self.len_of(self.view) + } + } + + pub fn selected(&self) -> usize { + if self.in_playlist() { + return self.pl_state.selected().unwrap_or(0); + } + self.states[self.view.idx()].selected().unwrap_or(0) + } + + fn set_selected(&mut self, i: usize) { + let len = self.cur_len(); + let new = if len == 0 { None } else { Some(i.min(len - 1)) }; + if self.in_playlist() { + self.pl_state.select(new); + } else { + let idx = self.view.idx(); + self.states[idx].select(new); + } + } + + fn move_sel(&mut self, delta: isize) { + let len = self.cur_len(); + if len == 0 { + return; + } + let new = (self.selected() as isize + delta).clamp(0, len as isize - 1); + self.set_selected(new as usize); + } + + fn set_view(&mut self, v: View) { + if v != View::Playlists { + self.open = None; + } + self.view = v; + let sel = self.selected(); + self.set_selected(sel); + } + + pub fn playlist_name(&self, i: usize) -> Option { + self.store.playlists.keys().nth(i).cloned() + } + + pub fn now_track(&self) -> Option<&Track> { + self.current.and_then(|i| self.queue.get(i)) + } + + pub fn on_key(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Release { + return; + } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.should_quit = true; + return; + } + match self.mode { + Mode::Help => self.key_help(key), + Mode::Command => self.key_command(key), + Mode::ArtistPick => self.key_artist_pick(key), + Mode::Normal => self.key_normal(key), + } + } + + fn key_normal(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + if let Some(p) = self.pending.take() { + let n = self.count.take(); + if let KeyCode::Char(c) = key.code { + match (p, c) { + ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + ('g', 'r') => self.set_view(View::Results), + ('g', 'q') => self.set_view(View::Queue), + ('g', 'p') => self.set_view(View::Playlists), + ('d', 'd') => self.delete_selected(), + ('g', 'a') => self.go_to_artist(), + _ => {} + } + } + return; + } + + if let KeyCode::Char(c @ '0'..='9') = key.code { + if !ctrl && !(c == '0' && self.count.is_none()) { + let d = c as usize - '0' as usize; + self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); + return; + } + } + let n = self.count.take(); + let times = n.unwrap_or(1); + + if self.view == View::Playlists { + match key.code { + KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { + return self.open_selected_playlist(); + } + KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc + if self.in_playlist() && !ctrl => + { + return self.close_playlist(); + } + _ => {} + } + } + + match key.code { + KeyCode::Char('d') if ctrl => self.move_sel(10), + KeyCode::Char('u') if ctrl => self.move_sel(-10), + KeyCode::Char('f') if ctrl => self.move_sel(20), + KeyCode::Char('b') if ctrl => self.move_sel(-20), + KeyCode::PageDown => self.move_sel(20), + KeyCode::PageUp => self.move_sel(-20), + KeyCode::Home => self.set_selected(0), + KeyCode::End => self.set_selected(usize::MAX), + + KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize), + KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)), + KeyCode::Char('g') => { + self.pending = Some('g'); + self.count = n; + } + KeyCode::Char('G') => self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))), + KeyCode::Char('d') => { + if matches!(self.view, View::Queue | View::Playlists) { + self.pending = Some('d'); + } + } + KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), + + KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), + KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), + + KeyCode::Enter => self.activate(), + KeyCode::Char(' ') => self.toggle_pause(), + KeyCode::Char('n') | KeyCode::Char('>') => self.next(), + KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), + KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step * times as f64)), + KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step * times as f64)), + KeyCode::Char('+') | KeyCode::Char('=') => { + self.set_volume(Amount::Delta(self.volume_step * times as f64)) + } + KeyCode::Char('-') | KeyCode::Char('_') => { + self.set_volume(Amount::Delta(-self.volume_step * times as f64)) + } + + KeyCode::Char('a') => self.add_selected(), + KeyCode::Char('A') => self.add_all_results(), + KeyCode::Char('P') => self.play_all_results(), + KeyCode::Char('J') => self.move_item(1), + KeyCode::Char('K') => self.move_item(-1), + KeyCode::Char('s') => self.toggle_shuffle(), + KeyCode::Char('r') => self.cycle_repeat(), + + KeyCode::Char('/') => { + self.cmd_buf = "search ".into(); + self.enter_command_mode(); + } + KeyCode::Char(':') => { + self.cmd_buf.clear(); + self.enter_command_mode(); + } + KeyCode::Char('?') => self.open_help(), + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Esc => self.status = None, + _ => {} + } + } + + fn open_help(&mut self) { + self.help_scroll = 0; + self.mode = Mode::Help; + } + + fn key_help(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let s = self.help_scroll; + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => { + self.mode = Mode::Normal + } + KeyCode::Char('j') | KeyCode::Down => self.help_scroll = s.saturating_add(1), + KeyCode::Char('k') | KeyCode::Up => self.help_scroll = s.saturating_sub(1), + KeyCode::Char('d') if ctrl => self.help_scroll = s.saturating_add(10), + KeyCode::Char('u') if ctrl => self.help_scroll = s.saturating_sub(10), + KeyCode::PageDown | KeyCode::Char(' ') => self.help_scroll = s.saturating_add(10), + KeyCode::PageUp => self.help_scroll = s.saturating_sub(10), + KeyCode::Char('g') | KeyCode::Home => self.help_scroll = 0, + KeyCode::Char('G') | KeyCode::End => self.help_scroll = usize::MAX, + _ => {} + } + } + + fn enter_command_mode(&mut self) { + self.mode = Mode::Command; + self.hist_pos = None; + } + + fn key_command(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.cmd_buf.clear(); + } + KeyCode::Enter => { + let line = std::mem::take(&mut self.cmd_buf); + self.mode = Mode::Normal; + let trimmed = line.trim(); + if !trimmed.is_empty() { + if self.history.last().map(String::as_str) != Some(trimmed) { + self.history.push(trimmed.to_string()); + } + self.run_command(trimmed); + } + } + KeyCode::Backspace => { + if self.cmd_buf.is_empty() { + self.mode = Mode::Normal; + } else { + self.cmd_buf.pop(); + } + } + KeyCode::Char('u') if ctrl => self.cmd_buf.clear(), + KeyCode::Char('w') if ctrl => { + let trimmed = self.cmd_buf.trim_end().len(); + let cut = self.cmd_buf[..trimmed].rfind(' ').map_or(0, |i| i + 1); + self.cmd_buf.truncate(cut); + } + KeyCode::Up => self.history_step(-1), + KeyCode::Down => self.history_step(1), + KeyCode::Tab => self.complete(), + KeyCode::Char(c) if !ctrl => self.cmd_buf.push(c), + _ => {} + } + } + + fn history_step(&mut self, dir: isize) { + if self.history.is_empty() { + return; + } + let len = self.history.len() as isize; + let pos = match (self.hist_pos, dir) { + (None, -1) => len - 1, + (None, _) => return, + (Some(p), d) => p as isize + d, + }; + if pos >= len { + self.hist_pos = None; + self.cmd_buf.clear(); + } else { + let pos = pos.max(0) as usize; + self.hist_pos = Some(pos); + self.cmd_buf = self.history[pos].clone(); + } + } + + fn complete(&mut self) { + if self.cmd_buf.contains(' ') { + return; + } + let matches: Vec<&&str> = COMMAND_NAMES + .iter() + .filter(|c| c.starts_with(self.cmd_buf.as_str())) + .collect(); + match matches.len() { + 0 => {} + 1 => self.cmd_buf = format!("{} ", matches[0]), + _ => { + let list = matches.iter().map(|s| **s).collect::>().join(" "); + self.info(list); + } + } + } + + pub fn run_command(&mut self, line: &str) { + match command::parse(line) { + Ok(Some(cmd)) => self.exec(cmd), + Ok(None) => {} + Err(e) => self.error(e), + } + } + + fn exec(&mut self, cmd: Command) { + match cmd { + Command::Search(q) => self.start_search(q), + Command::Play(None) => self.play_default(), + Command::Play(Some(n)) => { + let len = self.len_of(self.view); + if n > len { + self.error(format!("no row {n} (only {len} here)")); + } else { + let v = self.view; + self.activate_at(v, n - 1); + } + } + Command::Pause => self.set_pause(true), + Command::Resume => self.set_pause(false), + Command::Toggle => self.toggle_pause(), + Command::Stop => self.stop_playback(), + Command::Next => self.next(), + Command::Prev => self.prev(), + Command::Quit => self.should_quit = true, + Command::Add(None) => self.add_selected(), + Command::Add(Some(n)) => match self.results.get(n - 1).cloned() { + Some(t) => self.enqueue(t), + None => self.error(format!("no result #{n}")), + }, + Command::AddAll => self.add_all_results(), + Command::PlayAll => self.play_all_results(), + Command::Remove(n) => { + let i = n.map_or(self.selected(), |n| n - 1); + self.remove_from_queue(i); + } + Command::Clear => self.clear_queue(), + Command::ShowResults => self.set_view(View::Results), + Command::ShowQueue => self.set_view(View::Queue), + Command::ShowPlaylists => self.set_view(View::Playlists), + Command::Volume(a) => self.set_volume(a), + Command::Seek(a) => self.seek(a), + Command::Shuffle => self.toggle_shuffle(), + Command::Repeat(m) => { + self.repeat = m.unwrap_or_else(|| self.repeat.cycle()); + self.info(format!("Repeat: {}", self.repeat.label())); + } + Command::Save(name) => self.save_playlist(&name), + Command::Load(name) => match self.store.resolve(&name) { + Some(n) => self.load_playlist(&n, true), + None => self.error(format!("no playlist named '{name}'")), + }, + Command::Append(name) => match self.store.resolve(&name) { + Some(n) => self.load_playlist(&n, false), + None => self.error(format!("no playlist named '{name}'")), + }, + Command::DeletePlaylist(name) => match self.store.resolve(&name) { + Some(n) => self.delete_playlist(&n), + None => self.error(format!("no playlist named '{name}'")), + }, + Command::AddTo(name) => self.add_to_playlist(&name), + Command::Import(url) => self.start_import(url), + Command::Artist => self.go_to_artist(), + Command::Help => self.open_help(), + } + } + + fn artist_target(&self) -> Option { + let sel = self.selected(); + let highlighted = match self.view { + View::Results => self.results.get(sel), + View::Queue => self.queue.get(sel), + View::Playlists if self.in_playlist() => self + .open + .as_ref() + .and_then(|n| self.store.playlists.get(n)) + .and_then(|t| t.get(sel)), + View::Playlists => None, + }; + highlighted.or_else(|| self.now_track()).cloned() + } + + fn go_to_artist(&mut self) { + let Some(track) = self.artist_target() else { + return self.error("No song selected"); + }; + let names = split_artists(&track.artist); + if names.is_empty() || track.artist == "Unknown artist" { + return self.error("No artist known for this song"); + } + if names.len() == 1 { + return self.search_artist(names[0].clone()); + } + let mut opts = names; + opts.push(track.artist.trim().to_string()); + self.artist_opts = opts; + self.artist_sel = 0; + self.mode = Mode::ArtistPick; + } + + fn key_artist_pick(&mut self, key: KeyEvent) { + let len = self.artist_opts.len(); + if len == 0 { + self.mode = Mode::Normal; + return; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.close_artist_pick(), + KeyCode::Char('j') | KeyCode::Down => self.artist_sel = (self.artist_sel + 1).min(len - 1), + KeyCode::Char('k') | KeyCode::Up => self.artist_sel = self.artist_sel.saturating_sub(1), + KeyCode::Char(c @ '1'..='9') => { + let i = c as usize - '1' as usize; + if i < len { + self.artist_sel = i; + self.confirm_artist(); + } + } + KeyCode::Enter => self.confirm_artist(), + _ => {} + } + } + + fn close_artist_pick(&mut self) { + self.mode = Mode::Normal; + self.artist_opts.clear(); + self.artist_sel = 0; + } + + fn confirm_artist(&mut self) { + let name = self.artist_opts.get(self.artist_sel).cloned(); + self.close_artist_pick(); + if let Some(name) = name { + self.search_artist(name); + } + } + + fn search_artist(&mut self, name: String) { + let busy = format!("Loading songs by \u{201c}{name}\u{201d}"); + let title = format!("Artist: {name}"); + self.spawn_loader(title, busy, move || ytmusic::search(&name)); + } + + fn start_search(&mut self, query: String) { + self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || { + ytmusic::search(&query) + }); + } + + fn start_import(&mut self, url: String) { + self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || { + ytmusic::import(&url) + }); + } + + fn spawn_loader(&mut self, title: String, busy: String, work: F) + where + F: FnOnce() -> Result, String> + Send + 'static, + { + self.search_id += 1; + let id = self.search_id; + self.busy = Some(busy); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = work(); + let _ = tx.send(AppEvent::Results { id, title, result }); + }); + } + + pub fn on_event(&mut self, ev: AppEvent) { + match ev { + AppEvent::Results { id, title, result } => { + if id != self.search_id { + return; + } + self.busy = None; + match result { + Ok(tracks) if tracks.is_empty() => self.info("No results found."), + Ok(tracks) => { + let n = tracks.len(); + self.results = tracks; + self.results_title = title; + self.states[View::Results.idx()].select(Some(0)); + self.set_view(View::Results); + self.info(format!("{n} results — Enter plays, a queues, ? for help")); + } + Err(e) => self.error(e), + } + } + AppEvent::Player(pe) => self.on_player(pe), + } + } + + fn ensure_player(&mut self) -> Result<&mut Player> { + if let Some(p) = self.player.as_mut() { + if !p.is_alive() { + self.player = None; + } + } + if self.player.is_none() { + let tx = self.tx.clone(); + let p = Player::spawn(self.volume, move |ev| { + let _ = tx.send(AppEvent::Player(ev)); + })?; + self.player = Some(p); + } + Ok(self.player.as_mut().expect("player was just created")) + } + + fn with_player(&mut self, f: impl Fn(&mut Player) -> Result<()>) -> Result<()> { + match self.ensure_player().and_then(|p| f(p)) { + Ok(()) => Ok(()), + Err(_) => { + self.player = None; + self.ensure_player().and_then(|p| f(p)) + } + } + } + + fn on_player(&mut self, ev: PlayerEvent) { + match ev { + PlayerEvent::Position(Some(p)) => self.position = p, + PlayerEvent::Position(None) => {} + PlayerEvent::Duration(Some(d)) => self.duration = d, + PlayerEvent::Duration(None) => {} + PlayerEvent::Paused(p) => match (self.play_state, p) { + (PlayState::Playing, true) => self.play_state = PlayState::Paused, + (PlayState::Paused, false) => self.play_state = PlayState::Playing, + _ => {} + }, + PlayerEvent::Volume(v) => self.volume = v.round().clamp(0.0, 130.0) as u8, + PlayerEvent::FileLoaded => { + self.play_state = PlayState::Playing; + self.fail_streak = 0; + if let Some((id, pos)) = self.resume.take() { + let ok = self.now_track().is_some_and(|t| { + t.id == id && t.duration.map_or(true, |d| pos < f64::from(d) - 1.0) + }); + if ok && pos > 1.0 { + let _ = self.with_player(|p| p.seek_absolute(pos)); + self.position = pos; + } + } + } + PlayerEvent::EndOfFile => { + if self.play_state != PlayState::Stopped { + self.advance(true); + } + } + PlayerEvent::LoadError(msg) => { + let name = self.now_track().map(Track::label).unwrap_or_default(); + self.fail_streak += 1; + if self.fail_streak >= 3 { + self.stop_playback(); + self.error(format!( + "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" + )); + } else { + self.error(format!("Can't play \u{201c}{name}\u{201d} ({msg}) — skipping")); + self.advance(true); + } + } + PlayerEvent::Died => { + self.player = None; + if self.play_state != PlayState::Stopped { + self.play_state = PlayState::Stopped; + self.error("mpv exited unexpectedly; it will restart on next play"); + } + } + } + } + + fn play_index(&mut self, i: usize) { + self.start_track(i, false); + } + + fn start_track(&mut self, i: usize, keep_resume: bool) { + let Some(track) = self.queue.get(i).cloned() else { return }; + if !keep_resume { + self.resume = None; + } + self.current = Some(i); + self.played.insert(i); + self.play_state = PlayState::Loading; + self.position = match &self.resume { + Some((id, pos)) if *id == track.id => *pos, + _ => 0.0, + }; + self.duration = track.duration.map_or(0.0, f64::from); + let url = track.url(); + match self.with_player(|p| p.load(&url)) { + Ok(()) => self.info(format!("♪ {}", track.label())), + Err(e) => { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } + } + + fn play_default(&mut self) { + match self.play_state { + PlayState::Paused => return self.set_pause(false), + PlayState::Playing | PlayState::Loading => { + return self.info("Already playing (use :next, :pause, or :play N)"); + } + PlayState::Stopped => {} + } + if self.queue.is_empty() { + match self.view { + View::Results if !self.results.is_empty() => { + let t = self.results[self.selected().min(self.results.len() - 1)].clone(); + self.play_now(t); + } + View::Playlists if !self.store.playlists.is_empty() => { + let i = self.selected(); + self.activate_at(View::Playlists, i); + } + _ => self.error("Nothing to play — search first with :search \"query\""), + } + return; + } + let i = if self.view == View::Queue { + self.selected() + } else { + self.current.unwrap_or(0) + }; + self.start_track(i.min(self.queue.len() - 1), true); + } + + fn activate(&mut self) { + let (v, i) = (self.view, self.selected()); + self.activate_at(v, i); + } + + fn activate_at(&mut self, view: View, i: usize) { + match view { + View::Results => match self.results.get(i).cloned() { + Some(t) => self.play_now(t), + None => {} + }, + View::Queue => { + if i < self.queue.len() { + self.play_index(i); + } + } + View::Playlists => { + if self.in_playlist() { + if let Some(name) = self.open.clone() { + self.play_playlist_from(&name, i); + } + } else if let Some(name) = self.playlist_name(i) { + self.load_playlist(&name, true); + } + } + } + } + + fn play_now(&mut self, t: Track) { + if let Some(k) = self.queue.iter().position(|q| q.id == t.id) { + return self.play_index(k); + } + let at = self.current.map_or(self.queue.len(), |c| (c + 1).min(self.queue.len())); + self.queue.insert(at, t); + self.played.clear(); + self.play_index(at); + } + + fn set_pause(&mut self, pause: bool) { + match self.play_state { + PlayState::Stopped => return self.error("Nothing is playing"), + PlayState::Loading => return, + _ => {} + } + match self.with_player(|p| p.set_pause(pause)) { + Ok(()) => { + self.play_state = if pause { PlayState::Paused } else { PlayState::Playing }; + } + Err(e) => self.error(format!("{e:#}")), + } + } + + fn toggle_pause(&mut self) { + match self.play_state { + PlayState::Playing => self.set_pause(true), + PlayState::Paused => self.set_pause(false), + PlayState::Loading => {} + PlayState::Stopped => self.play_default(), + } + } + + fn stop_playback(&mut self) { + if let Some(p) = self.player.as_mut() { + let _ = p.stop(); + } + self.play_state = PlayState::Stopped; + self.position = 0.0; + } + + fn next(&mut self) { + if self.queue.is_empty() { + return self.error("Queue is empty"); + } + self.advance(false); + } + + fn prev(&mut self) { + let Some(c) = self.current else { + return self.error("Nothing is playing"); + }; + if self.position > 3.0 { + return self.seek(Amount::Abs(0.0)); + } + if c > 0 { + self.play_index(c - 1); + } else if self.repeat == RepeatMode::All && !self.queue.is_empty() { + self.play_index(self.queue.len() - 1); + } else { + self.seek(Amount::Abs(0.0)); + } + } + + fn rand_below(&mut self, n: usize) -> usize { + self.rng ^= self.rng >> 12; + self.rng ^= self.rng << 25; + self.rng ^= self.rng >> 27; + (self.rng.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 33) as usize % n.max(1) + } + + fn advance(&mut self, auto: bool) { + let len = self.queue.len(); + if len == 0 { + return self.stop_playback(); + } + if auto && self.repeat == RepeatMode::One { + if let Some(c) = self.current { + return self.play_index(c); + } + } + let next = if self.shuffle { + let mut pool: Vec = (0..len).filter(|i| !self.played.contains(i)).collect(); + if pool.is_empty() && self.repeat == RepeatMode::All { + self.played.clear(); + pool = (0..len).filter(|i| Some(*i) != self.current).collect(); + if pool.is_empty() { + pool = vec![0]; + } + } + if pool.is_empty() { + None + } else { + let k = self.rand_below(pool.len()); + Some(pool[k]) + } + } else { + match self.current { + Some(c) if c + 1 < len => Some(c + 1), + Some(_) if self.repeat == RepeatMode::All => Some(0), + Some(_) => None, + None => Some(0), + } + }; + let next = next.or_else(|| self.next_from_results()); + match next { + Some(i) => self.play_index(i), + None if auto => { + self.stop_playback(); + self.info("Queue finished"); + } + None => self.info("End of queue"), + } + } + + fn next_from_results(&mut self) -> Option { + let cur_id = self.now_track()?.id.clone(); + let r = self.results.iter().position(|t| t.id == cur_id)?; + let next = self.results[r + 1..] + .iter() + .find(|t| !self.queue.iter().any(|q| q.id == t.id))? + .clone(); + self.queue.push(next); + Some(self.queue.len() - 1) + } + + fn seek(&mut self, amount: Amount) { + if !matches!(self.play_state, PlayState::Playing | PlayState::Paused) { + return self.error("Nothing is playing"); + } + let r = match amount { + Amount::Delta(d) => self.with_player(|p| p.seek_relative(d)), + Amount::Abs(a) => self.with_player(|p| p.seek_absolute(a)), + }; + match r { + Ok(()) => { + self.position = match amount { + Amount::Delta(d) => (self.position + d).max(0.0), + Amount::Abs(a) => a, + }; + } + Err(e) => self.error(format!("{e:#}")), + } + } + + fn set_volume(&mut self, amount: Amount) { + let v = match amount { + Amount::Abs(a) => a, + Amount::Delta(d) => self.volume as f64 + d, + } + .round() + .clamp(0.0, 100.0) as u8; + self.volume = v; + if let Some(p) = self.player.as_mut() { + if let Err(e) = p.set_volume(v) { + return self.error(format!("{e:#}")); + } + } + self.info(format!("Volume: {v}%")); + } + + fn toggle_shuffle(&mut self) { + self.shuffle = !self.shuffle; + self.played.clear(); + if let Some(c) = self.current { + self.played.insert(c); + } + self.info(format!("Shuffle: {}", if self.shuffle { "on" } else { "off" })); + } + + fn cycle_repeat(&mut self) { + self.repeat = self.repeat.cycle(); + self.info(format!("Repeat: {}", self.repeat.label())); + } + + fn enqueue(&mut self, t: Track) { + let label = t.label(); + self.queue.push(t); + self.info(format!("Queued: {label}")); + } + + fn add_selected(&mut self) { + let i = self.selected(); + match self.view { + View::Results => match self.results.get(i).cloned() { + Some(t) => self.enqueue(t), + None => self.error("No result selected"), + }, + View::Playlists if self.in_playlist() => { + let track = self + .open + .as_ref() + .and_then(|n| self.store.playlists.get(n)) + .and_then(|l| l.get(i)) + .cloned(); + match track { + Some(t) => self.enqueue(t), + None => self.error("No track selected"), + } + } + View::Playlists => match self.playlist_name(i) { + Some(n) => self.load_playlist(&n, false), + None => self.error("No playlist selected"), + }, + View::Queue => self.info("Already in the queue"), + } + } + + fn add_all_results(&mut self) { + if self.results.is_empty() { + return self.error("No results to add"); + } + let n = self.results.len(); + self.queue.extend(self.results.iter().cloned()); + self.info(format!("Queued {n} tracks")); + } + + fn play_all_results(&mut self) { + if self.results.is_empty() { + return self.error("No results to play"); + } + let start = if self.view == View::Results { self.selected() } else { 0 }; + self.queue = self.results.clone(); + self.played.clear(); + self.current = None; + self.play_index(start.min(self.queue.len() - 1)); + } + + fn clear_queue(&mut self) { + self.queue.clear(); + self.current = None; + self.played.clear(); + self.stop_playback(); + self.states[View::Queue.idx()].select(None); + self.info("Queue cleared"); + } + + fn remove_from_queue(&mut self, i: usize) { + if i >= self.queue.len() { + return self.error("No such queue entry"); + } + let removed = self.queue.remove(i); + self.played.clear(); + let active = self.play_state != PlayState::Stopped; + match self.current { + Some(c) if c == i => { + if self.queue.is_empty() { + self.current = None; + self.stop_playback(); + } else if active { + self.play_index(i.min(self.queue.len() - 1)); + } else { + self.current = Some(i.min(self.queue.len() - 1)); + } + } + Some(c) if c > i => self.current = Some(c - 1), + _ => {} + } + let sel = self.states[View::Queue.idx()].selected().unwrap_or(0); + let len = self.queue.len(); + self.states[View::Queue.idx()].select(if len == 0 { None } else { Some(sel.min(len - 1)) }); + self.info(format!("Removed: {}", removed.label())); + } + + fn move_queue_item(&mut self, dir: isize) { + if self.view != View::Queue { + return; + } + let i = self.selected(); + let j = i as isize + dir; + if i >= self.queue.len() || j < 0 || j as usize >= self.queue.len() { + return; + } + let j = j as usize; + self.queue.swap(i, j); + self.played.clear(); + if self.current == Some(i) { + self.current = Some(j); + } else if self.current == Some(j) { + self.current = Some(i); + } + self.set_selected(j); + } + + fn delete_selected(&mut self) { + let i = self.selected(); + match self.view { + View::Queue => self.remove_from_queue(i), + View::Playlists if self.in_playlist() => self.remove_playlist_track(i), + View::Playlists => match self.playlist_name(i) { + Some(n) => self.delete_playlist(&n), + None => {} + }, + View::Results => self.info("Results can't be deleted — they are replaced by the next search"), + } + } + + fn persist(&mut self) -> bool { + match self.store.save() { + Ok(()) => true, + Err(e) => { + self.error(format!("Could not save playlists: {e:#}")); + false + } + } + } + + fn save_playlist(&mut self, name: &str) { + if self.queue.is_empty() { + return self.error("Queue is empty — nothing to save"); + } + let n = self.queue.len(); + self.store.playlists.insert(name.to_string(), self.queue.clone()); + if self.persist() { + self.info(format!("Saved {n} tracks to playlist \u{201c}{name}\u{201d}")); + } + } + + fn add_to_playlist(&mut self, name: &str) { + let i = self.selected(); + let track = match self.view { + View::Results => self.results.get(i).cloned(), + View::Queue => self.queue.get(i).cloned(), + View::Playlists => self + .open + .as_ref() + .and_then(|n| self.store.playlists.get(n)) + .and_then(|l| l.get(i)) + .cloned(), + }; + let Some(track) = track else { + return self.error("Select a track in Results or Queue first"); + }; + let key = self.store.resolve(name).unwrap_or_else(|| name.to_string()); + let list = self.store.playlists.entry(key.clone()).or_default(); + if list.iter().any(|t| t.id == track.id) { + return self.info(format!("Already in \u{201c}{key}\u{201d}")); + } + let label = track.label(); + list.push(track); + if self.persist() { + self.info(format!("Added {label} to \u{201c}{key}\u{201d}")); + } + } + + fn load_playlist(&mut self, name: &str, replace: bool) { + let Some(tracks) = self.store.playlists.get(name).cloned() else { + return self.error(format!("no playlist named '{name}'")); + }; + if tracks.is_empty() { + return self.error(format!("Playlist \u{201c}{name}\u{201d} is empty")); + } + let n = tracks.len(); + if replace { + self.queue = tracks; + self.played.clear(); + self.current = None; + self.states[View::Queue.idx()].select(Some(0)); + self.play_index(0); + self.info(format!("Playing playlist \u{201c}{name}\u{201d} ({n} tracks)")); + } else { + self.queue.extend(tracks); + self.info(format!("Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue")); + } + } + + fn open_selected_playlist(&mut self) { + let Some(name) = self.playlist_name(self.selected()) else { + return; + }; + let len = self.store.playlists.get(&name).map_or(0, |l| l.len()); + self.open = Some(name); + self.pl_state.select(if len == 0 { None } else { Some(0) }); + } + + fn close_playlist(&mut self) { + self.open = None; + } + + fn play_playlist_from(&mut self, name: &str, i: usize) { + let Some(tracks) = self.store.playlists.get(name).cloned() else { + return self.error(format!("no playlist named '{name}'")); + }; + if tracks.is_empty() { + return self.error(format!("Playlist \u{201c}{name}\u{201d} is empty")); + } + let i = i.min(tracks.len() - 1); + self.queue = tracks; + self.played.clear(); + self.current = None; + self.states[View::Queue.idx()].select(Some(i)); + self.play_index(i); + } + + fn remove_playlist_track(&mut self, i: usize) { + let Some(name) = self.open.clone() else { return }; + let removed = { + let Some(list) = self.store.playlists.get_mut(&name) else { return }; + if i >= list.len() { + None + } else { + let t = list.remove(i); + Some((t, list.len())) + } + }; + let Some((track, len)) = removed else { + return self.error("No track selected"); + }; + self.pl_state.select(if len == 0 { None } else { Some(i.min(len - 1)) }); + if self.persist() { + self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label())); + } + } + + fn move_item(&mut self, dir: isize) { + if self.in_playlist() { + return self.move_playlist_track(dir); + } + self.move_queue_item(dir); + } + + fn move_playlist_track(&mut self, dir: isize) { + let Some(name) = self.open.clone() else { return }; + let i = self.selected(); + let j = i as isize + dir; + let moved = match self.store.playlists.get_mut(&name) { + Some(list) if j >= 0 && (j as usize) < list.len() && i < list.len() => { + list.swap(i, j as usize); + true + } + _ => false, + }; + if moved { + self.set_selected(j as usize); + self.persist(); + } + } + + fn delete_playlist(&mut self, name: &str) { + if self.store.playlists.remove(name).is_some() && self.persist() { + if self.open.as_deref() == Some(name) { + self.open = None; + } + self.info(format!("Deleted playlist \u{201c}{name}\u{201d}")); + let sel = self.selected(); + self.set_selected(sel); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + fn track(id: &str, title: &str) -> Track { + Track { + id: id.into(), + title: title.into(), + artist: "Artist".into(), + album: None, + duration: Some(100), + } + } + + fn press(app: &mut App, code: KeyCode) { + app.on_key(KeyEvent::new(code, KeyModifiers::NONE)); + } + + fn check_artist_popup(app: &mut App) { + let mut t = track("zzzzzzzzzzz", "Duet"); + t.artist = "A & B".into(); + app.results = vec![t]; + app.states[View::Results.idx()].select(Some(0)); + app.view = View::Results; + + app.go_to_artist(); + assert!(app.mode == Mode::ArtistPick); + assert_eq!(app.artist_opts, ["A", "B", "A & B"]); + press(app, KeyCode::Down); + assert_eq!(app.artist_sel, 1); + press(app, KeyCode::Esc); + assert!(app.mode == Mode::Normal); + assert!(app.artist_opts.is_empty()); + } + + #[test] + fn playlist_drill_in() { + let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::set_var("XDG_STATE_HOME", &dir); + + let (tx, _rx) = mpsc::channel(); + let mut app = App::new(tx); + app.store.playlists.insert( + "mix".into(), + vec![track("aaaaaaaaaaa", "A"), track("bbbbbbbbbbb", "B"), track("ccccccccccc", "C")], + ); + app.store.playlists.insert("other".into(), vec![track("ddddddddddd", "D")]); + + press(&mut app, KeyCode::Tab); + press(&mut app, KeyCode::Tab); + assert_eq!(app.view, View::Playlists); + assert!(!app.in_playlist()); + + press(&mut app, KeyCode::Right); + assert!(app.in_playlist()); + assert_eq!(app.open.as_deref(), Some("mix")); + assert_eq!(app.selected(), 0); + + press(&mut app, KeyCode::Down); + press(&mut app, KeyCode::Down); + assert_eq!(app.selected(), 2); + press(&mut app, KeyCode::Down); + assert_eq!(app.selected(), 2); + + press(&mut app, KeyCode::Char('K')); + let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, ["aaaaaaaaaaa", "ccccccccccc", "bbbbbbbbbbb"]); + assert_eq!(app.selected(), 1); + + press(&mut app, KeyCode::Char('x')); + let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, ["aaaaaaaaaaa", "bbbbbbbbbbb"]); + assert_eq!(app.selected(), 1); + + press(&mut app, KeyCode::Char('d')); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.store.playlists["mix"].len(), 1); + assert_eq!(app.selected(), 0); + + assert_eq!(app.store.playlists.len(), 2); + let saved = std::fs::read_to_string(dir.join("drift").join("playlists.json")).unwrap(); + assert!(saved.contains("aaaaaaaaaaa") && !saved.contains("bbbbbbbbbbb")); + + press(&mut app, KeyCode::Char('a')); + assert_eq!(app.queue.len(), 1); + press(&mut app, KeyCode::Left); + assert!(!app.in_playlist()); + assert_eq!(app.open, None); + assert_eq!(app.store.playlists.len(), 2); + + press(&mut app, KeyCode::Right); + assert!(app.in_playlist()); + press(&mut app, KeyCode::Esc); + assert!(!app.in_playlist()); + + press(&mut app, KeyCode::Right); + press(&mut app, KeyCode::Tab); + assert_eq!(app.open, None); + + check_artist_popup(&mut app); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..ac98901 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,199 @@ +use crate::model::{parse_clock, RepeatMode}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Amount { + Abs(f64), + Delta(f64), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + Search(String), + Play(Option), + Pause, + Resume, + Toggle, + Stop, + Next, + Prev, + Quit, + Add(Option), + AddAll, + PlayAll, + Remove(Option), + Clear, + ShowResults, + ShowQueue, + ShowPlaylists, + Volume(Amount), + Seek(Amount), + Shuffle, + Repeat(Option), + Save(String), + Load(String), + Append(String), + DeletePlaylist(String), + AddTo(String), + Import(String), + Artist, + Help, +} + +pub const COMMAND_NAMES: &[&str] = &[ + "search", "play", "pause", "resume", "toggle", "stop", "next", "prev", "quit", "add", + "addall", "playall", "remove", "clear", "results", "queue", "playlists", "volume", "seek", + "shuffle", "repeat", "save", "load", "append", "delpl", "addto", "import", "artist", "help", +]; + +fn unquote(s: &str) -> &str { + let s = s.trim(); + let b = s.as_bytes(); + if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] { + &s[1..s.len() - 1] + } else { + s + } +} + +fn index_arg(arg: &str, cmd: &str) -> Result, String> { + if arg.is_empty() { + return Ok(None); + } + match arg.parse::() { + Ok(n) if n >= 1 => Ok(Some(n)), + _ => Err(format!(":{cmd} expects a row number (1, 2, 3 …), got '{arg}'")), + } +} + +fn name_arg(arg: &str, cmd: &str) -> Result { + let a = unquote(arg); + if a.is_empty() { + Err(format!("usage: :{cmd} ")) + } else { + Ok(a.to_string()) + } +} + +fn parse_amount(arg: &str, cmd: &str) -> Result { + let a = arg.trim().trim_end_matches('%'); + if a.is_empty() { + return Err(format!("usage: :{cmd} (e.g. 50, +10, -10)")); + } + let err = || format!(":{cmd}: cannot understand '{arg}'"); + if let Some(rest) = a.strip_prefix('+') { + Ok(Amount::Delta(parse_clock(rest).ok_or_else(err)?)) + } else if let Some(rest) = a.strip_prefix('-') { + Ok(Amount::Delta(-parse_clock(rest).ok_or_else(err)?)) + } else { + Ok(Amount::Abs(parse_clock(a).ok_or_else(err)?)) + } +} + +pub fn parse(line: &str) -> Result, String> { + let line = line.trim().trim_start_matches(':').trim(); + if line.is_empty() { + return Ok(None); + } + let end = line + .find(|c: char| !(c.is_ascii_alphabetic() || c == '!')) + .unwrap_or(line.len()); + let (name, rest) = line.split_at(end); + let rest = rest.trim(); + let arg = unquote(rest); + + use Command::*; + let cmd = match name.to_ascii_lowercase().as_str() { + "search" | "s" | "find" => { + if arg.is_empty() { + return Err("usage: :search \"your query\"".into()); + } + Search(arg.to_string()) + } + "play" => Play(index_arg(arg, "play")?), + "pause" => Pause, + "resume" | "unpause" => Resume, + "toggle" => Toggle, + "stop" => Stop, + "next" | "n" => Next, + "prev" | "previous" => Prev, + "quit" | "q" | "exit" | "q!" | "qa" | "wq" => Quit, + "add" | "a" => Add(index_arg(arg, "add")?), + "addall" => AddAll, + "playall" => PlayAll, + "remove" | "rm" | "del" => Remove(index_arg(arg, "remove")?), + "clear" => Clear, + "results" | "res" => ShowResults, + "queue" => ShowQueue, + "playlists" | "pls" => ShowPlaylists, + "volume" | "vol" | "v" => Volume(parse_amount(arg, "volume")?), + "seek" => Seek(parse_amount(arg, "seek")?), + "shuffle" => Shuffle, + "repeat" => match arg.to_ascii_lowercase().as_str() { + "" => Repeat(None), + "off" | "none" | "no" => Repeat(Some(RepeatMode::Off)), + "all" | "queue" => Repeat(Some(RepeatMode::All)), + "one" | "track" | "1" => Repeat(Some(RepeatMode::One)), + other => return Err(format!(":repeat expects off|all|one, got '{other}'")), + }, + "save" | "w" => Save(name_arg(arg, "save")?), + "load" | "e" => Load(name_arg(arg, "load")?), + "append" => Append(name_arg(arg, "append")?), + "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), + "addto" => AddTo(name_arg(arg, "addto")?), + "import" => { + if arg.is_empty() { + return Err("usage: :import ".into()); + } + Import(arg.to_string()) + } + "artist" | "ar" => Artist, + "help" | "h" => Help, + other => return Err(format!("unknown command: :{other} (try :help)")), + }; + Ok(Some(cmd)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_variants() { + assert_eq!(parse(r#"search "daft punk""#), Ok(Some(Command::Search("daft punk".into())))); + assert_eq!(parse(":search daft punk"), Ok(Some(Command::Search("daft punk".into())))); + assert_eq!(parse(r#"s"lofi beats""#), Ok(Some(Command::Search("lofi beats".into())))); + assert!(parse("search").is_err()); + } + + #[test] + fn simple_commands() { + assert_eq!(parse("play"), Ok(Some(Command::Play(None)))); + assert_eq!(parse("play 3"), Ok(Some(Command::Play(Some(3))))); + assert!(parse("play 0").is_err()); + assert_eq!(parse("q!"), Ok(Some(Command::Quit))); + assert_eq!(parse("pause"), Ok(Some(Command::Pause))); + assert_eq!(parse(" "), Ok(None)); + assert!(parse("bogus").is_err()); + } + + #[test] + fn amounts() { + assert_eq!(parse("vol 50"), Ok(Some(Command::Volume(Amount::Abs(50.0))))); + assert_eq!(parse("vol -10"), Ok(Some(Command::Volume(Amount::Delta(-10.0))))); + assert_eq!(parse("seek 1:30"), Ok(Some(Command::Seek(Amount::Abs(90.0))))); + assert_eq!(parse("seek +15"), Ok(Some(Command::Seek(Amount::Delta(15.0))))); + assert!(parse("vol loud").is_err()); + } + + #[test] + fn artist_command() { + assert_eq!(parse("artist"), Ok(Some(Command::Artist))); + assert_eq!(parse(":ar"), Ok(Some(Command::Artist))); + } + + #[test] + fn playlists() { + assert_eq!(parse(r#"save "my mix""#), Ok(Some(Command::Save("my mix".into())))); + assert!(parse("save").is_err()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c47db6c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,204 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::model::{RepeatMode, Track}; +use crate::session::Session; +use crate::store::Store; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(default)] +pub struct Playback { + pub volume: u8, + pub shuffle: bool, + pub repeat: RepeatMode, + pub autoplay: bool, + pub restore_session: bool, + pub seek_step: f64, + pub volume_step: f64, +} + +impl Default for Playback { + fn default() -> Self { + Playback { + volume: 80, + shuffle: false, + repeat: RepeatMode::Off, + autoplay: false, + restore_session: true, + seek_step: 5.0, + volume_step: 5.0, + } + } +} + +impl Playback { + fn sanitize(&mut self) { + self.volume = self.volume.min(100); + let ok = |v: f64| v.is_finite() && v > 0.0; + if !ok(self.seek_step) { + self.seek_step = 5.0; + } + if !ok(self.volume_step) { + self.volume_step = 5.0; + } + self.volume_step = self.volume_step.min(100.0); + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Config { + pub playback: Playback, +} + +impl Config { + fn path() -> Result { + Ok(Store::path()?.with_file_name("config.toml")) + } + + pub fn render(p: &Playback) -> String { + format!( + "\ +# Drift settings. Drift only read this file and never rewrites it, so your +# comments are safe. Restart drift after editing + +[playback] +volume = {volume} +shuffle = {shuffle} +repeat = \"{repeat}\" +autoplay = {autoplay} +restore_session = {restore} +seek_step = {seek:?} +volume_step = {vstep:?} +", + volume = p.volume, + shuffle = p.shuffle, + repeat = p.repeat.label(), + autoplay = p.autoplay, + restore = p.restore_session, + seek = p.seek_step, + vstep = p.volume_step, + ) + } + + fn write(text: &str) -> Result<()> { + let path = Self::path()?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let tmp = path.with_extension("toml.tmp"); + fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) + } + + pub fn load() -> (Config, Option) { + let Ok(path) = Self::path() else { + return (Config::default(), None); + }; + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let _ = Self::write(&Self::render(&Playback::default())); + return (Config::default(), None); + } + Err(e) => { + return (Config::default(), Some(format!("cannot read {}: {e}", path.display()))) + } + }; + match toml::from_str::(&text) { + Ok(mut c) => { + c.playback.sanitize(); + (c, None) + } + Err(e) => { + let bak = path.with_extension("toml.bak"); + let _ = fs::rename(&path, &bak); + ( + Config::default(), + Some(format!( + "config.toml was invalid ({}); moved to {}", + e.message(), + bak.display() + )), + ) + } + } + } +} + +pub fn migrate_legacy() { + #[derive(Deserialize, Default)] + #[serde(default)] + struct Legacy { + volume: Option, + shuffle: Option, + repeat: Option, + autoplay: Option, + current: Option, + position: f64, + queue: Vec, + } + + let Ok(path) = Config::path() else { return }; + let Ok(text) = fs::read_to_string(&path) else { return }; + let Ok(table) = text.parse::() else { return }; + if table.contains_key("playback") || !(table.contains_key("queue") || table.contains_key("position")) { + return; + } + let Ok(old) = toml::from_str::(&text) else { return }; + + let d = Playback::default(); + let mut p = Playback { + volume: old.volume.unwrap_or(d.volume), + shuffle: old.shuffle.unwrap_or(d.shuffle), + repeat: old.repeat.unwrap_or(d.repeat), + autoplay: old.autoplay.unwrap_or(d.autoplay), + ..d + }; + p.sanitize(); + let session = Session { queue: old.queue, current: old.current, position: old.position }; + if let Ok(s) = session.render() { + if !Session::exists() { + let _ = Session::write(&s); + } + } + let _ = Config::write(&Config::render(&p)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_file_parses_back() { + let text = Config::render(&Playback::default()); + let c: Config = toml::from_str(&text).unwrap(); + let d = Playback::default(); + assert_eq!(c.playback.volume, d.volume); + assert_eq!(c.playback.repeat, d.repeat); + assert_eq!(c.playback.seek_step, 5.0); + assert!(c.playback.restore_session); + } + + #[test] + fn partial_and_empty_files() { + let c: Config = toml::from_str("").unwrap(); + assert_eq!(c.playback.volume, 80); + let c: Config = + toml::from_str("[playback]\nvolume = 10\nrepeat = \"one\"\nseek_step = 10").unwrap(); + assert_eq!((c.playback.volume, c.playback.repeat), (10, RepeatMode::One)); + assert_eq!(c.playback.seek_step, 10.0); + assert!(toml::from_str::("[playback]\nrepeat = \"sometimes\"").is_err()); + } + + #[test] + fn sanitize_bad_values() { + let mut p = Playback { volume: 250, seek_step: -3.0, volume_step: 900.0, ..Playback::default() }; + p.sanitize(); + assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d675fac --- /dev/null +++ b/src/main.rs @@ -0,0 +1,151 @@ +mod app; +mod command; +mod config; +mod model; +mod player; +mod session; +mod store; +mod ui; +mod ytmusic; + +use std::process::{Command, ExitCode, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::Result; +use ratatui::crossterm::event::{self, Event}; +use ratatui::DefaultTerminal; + +use app::{App, AppEvent}; + +const USAGE: &str = "\ +drift — Vim-style terminal player for YouTube Music (no login needed) + +USAGE: + drift [QUERY...] start the player, optionally running a search first + drift --check verify that mpv and yt-dlp are installed + drift --help show this help + drift --version + +Inside the player press ? for key bindings and :help for commands."; + +fn tool_version(name: &str) -> Option { + let out = Command::new(name) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + out.status.success().then(|| { + String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string() + }) +} + +fn missing_dependencies(verbose: bool) -> Vec { + let mut missing = Vec::new(); + for (tool, hint) in [ + ("mpv", "sudo apt install mpv | brew install mpv | sudo pacman -S mpv"), + ("yt-dlp", "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp"), + ] { + match tool_version(tool) { + Some(v) => { + if verbose { + println!("ok {tool}: {v}"); + } + } + None => { + if verbose { + println!("MISSING {tool}"); + } + missing.push(format!(" {tool:<7} not found. Install: {hint}")); + } + } + } + missing +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("-h") | Some("--help") => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + Some("-V") | Some("--version") => { + println!("drift {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + Some("--check") => { + let missing = missing_dependencies(true); + return if missing.is_empty() { + println!("All dependencies found."); + ExitCode::SUCCESS + } else { + for m in &missing { + eprintln!("{m}"); + } + ExitCode::FAILURE + }; + } + _ => {} + } + + let missing = missing_dependencies(false); + if !missing.is_empty() { + eprintln!("drift needs these programs to search and play music:\n"); + for m in &missing { + eprintln!("{m}"); + } + eprintln!("\nSee the README for details."); + return ExitCode::FAILURE; + } + + let initial = (!args.is_empty()).then(|| args.join(" ")); + match run(initial) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e:#}"); + ExitCode::FAILURE + } + } +} + +fn run(initial_query: Option) -> Result<()> { + let (tx, rx) = mpsc::channel::(); + let mut app = App::new(tx); + if let Some(q) = initial_query { + app.run_command(&format!("search {q}")); + } + + let mut terminal = ratatui::init(); + let result = event_loop(&mut terminal, &mut app, &rx); + ratatui::restore(); + app.shutdown(); + result +} + +fn event_loop( + terminal: &mut DefaultTerminal, + app: &mut App, + rx: &mpsc::Receiver, +) -> Result<()> { + while !app.should_quit { + terminal.draw(|f| ui::draw(f, app))?; + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + app.on_key(key); + } + } + while let Ok(ev) = rx.try_recv() { + app.on_event(ev); + } + app.on_tick(); + } + Ok(()) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..799803a --- /dev/null +++ b/src/model.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Track { + pub id: String, + pub title: String, + pub artist: String, + #[serde(default)] + pub album: Option, + #[serde(default)] + pub duration: Option, +} + +impl Track { + pub fn url(&self) -> String { + format!("https://music.youtube.com/watch?v={}", self.id) + } + + pub fn label(&self) -> String { + format!("{} — {}", self.title, self.artist) + } +} + +pub fn split_artists(credit: &str) -> Vec { + const MARK: char = '\u{1}'; + let mut s = credit.to_string(); + for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] { + s = s.replace(sep, &MARK.to_string()); + } + let mut out: Vec = Vec::new(); + for part in s.split(MARK) { + let name = part.trim(); + if !name.is_empty() && !out.iter().any(|o| o == name) { + out.push(name.to_string()); + } + } + out +} + +pub fn is_valid_id(id: &str) -> bool { + id.len() == 11 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +pub fn parse_clock(s: &str) -> Option { + let s = s.trim().trim_end_matches('s'); + if s.is_empty() { + return None; + } + let mut total = 0.0; + for part in s.split(':') { + let v: f64 = part.trim().parse().ok()?; + if v < 0.0 { + return None; + } + total = total * 60.0 + v; + } + Some(total) +} + +pub fn fmt_time(secs: f64) -> String { + let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 }; + let (h, m, s) = (s / 3600, (s % 3600) / 60, s % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RepeatMode { + Off, + All, + One, +} + +impl RepeatMode { + pub fn cycle(self) -> Self { + match self { + RepeatMode::Off => RepeatMode::All, + RepeatMode::All => RepeatMode::One, + RepeatMode::One => RepeatMode::Off, + } + } + + pub fn label(self) -> &'static str { + match self { + RepeatMode::Off => "off", + RepeatMode::All => "all", + RepeatMode::One => "one", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_parsing() { + assert_eq!(parse_clock("3:45"), Some(225.0)); + assert_eq!(parse_clock("1:02:03"), Some(3723.0)); + assert_eq!(parse_clock("90"), Some(5400.0 / 60.0)); + assert_eq!(parse_clock("abc"), None); + assert_eq!(fmt_time(225.0), "3:45"); + assert_eq!(fmt_time(3723.0), "1:02:03"); + } + + #[test] + fn artist_splitting() { + assert_eq!(split_artists("Daft Punk"), ["Daft Punk"]); + assert_eq!(split_artists("A & B"), ["A", "B"]); + assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); + assert_eq!(split_artists("A feat. B"), ["A", "B"]); + assert_eq!(split_artists("A, A"), ["A"]); + assert!(split_artists(" ").is_empty()); + } + + #[test] + fn ids() { + assert!(is_valid_id("dQw4w9WgXcQ")); + assert!(!is_valid_id("short")); + assert!(!is_valid_id("dQw4w9WgXc&")); + } +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..ac631c9 --- /dev/null +++ b/src/player.rs @@ -0,0 +1,268 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub enum PlayerEvent { + Position(Option), + Duration(Option), + Paused(bool), + Volume(f64), + FileLoaded, + EndOfFile, + LoadError(String), + Died, +} + +pub struct Player { + child: Child, + writer: UnixStream, + sock_path: PathBuf, +} + +impl Player { + pub fn spawn( + volume: u8, + on_event: impl Fn(PlayerEvent) + Send + 'static, + ) -> Result { + let sock_path = + std::env::temp_dir().join(format!("drift-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&sock_path); + + let mut cmd = Command::new("mpv"); + cmd.args([ + "--no-config", + "--idle=yes", + "--no-video", + "--no-terminal", + "--really-quiet", + "--audio-display=no", + "--force-window=no", + "--ytdl=yes", + "--ytdl-format=bestaudio/best", + "--cache=yes", + "--demuxer-max-bytes=32MiB", + ]) + .arg(format!("--volume={volume}")) + .arg(format!("--input-ipc-server={}", sock_path.display())); + + if let Ok(extra) = std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) { + cmd.args(extra.split_whitespace()); + } + cmd + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(target_os = "linux")] + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + Ok(()) + }); + } + + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow!("mpv not found — install it (see README)") + } else { + anyhow!("failed to start mpv: {e}") + } + })?; + + let start = Instant::now(); + let stream = loop { + match UnixStream::connect(&sock_path) { + Ok(s) => break s, + Err(e) => { + if let Ok(Some(status)) = child.try_wait() { + bail!("mpv exited immediately ({status})"); + } + if start.elapsed() > Duration::from_secs(6) { + let _ = child.kill(); + let _ = child.wait(); + bail!("timed out connecting to mpv IPC socket: {e}"); + } + std::thread::sleep(Duration::from_millis(40)); + } + } + }; + + let reader = stream.try_clone().context("cloning mpv socket")?; + let mut player = Player { + child, + writer: stream, + sock_path, + }; + + for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] { + player.send(json!(["observe_property", id, prop]))?; + } + + std::thread::spawn(move || { + for line in BufReader::new(reader).lines() { + let Ok(line) = line else { break }; + let Ok(v) = serde_json::from_str::(&line) else { continue }; + if let Some(ev) = parse_event(&v) { + on_event(ev); + } + } + on_event(PlayerEvent::Died); + }); + + Ok(player) + } + + fn send(&mut self, command: Value) -> Result<()> { + let mut line = serde_json::to_string(&json!({ "command": command }))?; + line.push('\n'); + self.writer + .write_all(line.as_bytes()) + .and_then(|_| self.writer.flush()) + .map_err(|e| anyhow!("lost connection to mpv: {e}")) + } + + pub fn is_alive(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + pub fn load(&mut self, url: &str) -> Result<()> { + self.send(json!(["set_property", "pause", false]))?; + self.send(json!(["loadfile", url, "replace"])) + } + + pub fn set_pause(&mut self, pause: bool) -> Result<()> { + self.send(json!(["set_property", "pause", pause])) + } + + pub fn set_volume(&mut self, vol: u8) -> Result<()> { + self.send(json!(["set_property", "volume", vol])) + } + + pub fn seek_relative(&mut self, secs: f64) -> Result<()> { + self.send(json!(["seek", secs, "relative"])) + } + + pub fn seek_absolute(&mut self, secs: f64) -> Result<()> { + self.send(json!(["seek", secs, "absolute"])) + } + + pub fn stop(&mut self) -> Result<()> { + self.send(json!(["stop"])) + } +} + +impl Drop for Player { + fn drop(&mut self) { + let _ = self.send(json!(["quit"])); + for _ in 0..10 { + if matches!(self.child.try_wait(), Ok(Some(_))) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.sock_path); + } +} + +fn parse_event(v: &Value) -> Option { + match v.get("event")?.as_str()? { + "property-change" => { + let data = v.get("data"); + match v.get("name")?.as_str()? { + "time-pos" => Some(PlayerEvent::Position(data.and_then(Value::as_f64))), + "duration" => Some(PlayerEvent::Duration(data.and_then(Value::as_f64))), + "pause" => Some(PlayerEvent::Paused( + data.and_then(Value::as_bool).unwrap_or(false), + )), + "volume" => data.and_then(Value::as_f64).map(PlayerEvent::Volume), + _ => None, + } + } + "file-loaded" => Some(PlayerEvent::FileLoaded), + "end-file" => match v.get("reason").and_then(Value::as_str) { + Some("eof") => Some(PlayerEvent::EndOfFile), + Some("error") => Some(PlayerEvent::LoadError( + v.get("file_error") + .and_then(Value::as_str) + .unwrap_or("playback failed") + .to_string(), + )), + _ => None, + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] + fn mpv_roundtrip() { + use std::sync::mpsc; + let wav = std::env::temp_dir().join("drift-test.wav"); + let samples: Vec = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect(); + let mut data = Vec::new(); + data.extend_from_slice(b"RIFF"); + data.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes()); + data.extend_from_slice(b"WAVEfmt "); + data.extend_from_slice(&16u32.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&8000u32.to_le_bytes()); + data.extend_from_slice(&8000u32.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&8u16.to_le_bytes()); + data.extend_from_slice(b"data"); + data.extend_from_slice(&(samples.len() as u32).to_le_bytes()); + data.extend_from_slice(&samples); + std::fs::write(&wav, data).unwrap(); + + std::env::set_var("DRIFT_MPV_ARGS", "--ao=null"); + let (tx, rx) = mpsc::channel(); + let mut p = Player::spawn(50, move |e| { + let _ = tx.send(e); + }) + .expect("spawn mpv"); + p.load(wav.to_str().unwrap()).unwrap(); + let (mut loaded, mut eof) = (false, false); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && !eof { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(PlayerEvent::FileLoaded) => loaded = true, + Ok(PlayerEvent::EndOfFile) => eof = true, + _ => {} + } + } + assert!(loaded, "file-loaded not seen"); + assert!(eof, "end-of-file not seen"); + } + + #[test] + fn event_parsing() { + let v: Value = serde_json::from_str( + r#"{"event":"property-change","id":1,"name":"time-pos","data":12.5}"#, + ) + .unwrap(); + assert!(matches!(parse_event(&v), Some(PlayerEvent::Position(Some(p))) if p == 12.5)); + + let v: Value = serde_json::from_str(r#"{"event":"end-file","reason":"eof"}"#).unwrap(); + assert!(matches!(parse_event(&v), Some(PlayerEvent::EndOfFile))); + + let v: Value = serde_json::from_str(r#"{"event":"end-file","reason":"stop"}"#).unwrap(); + assert!(parse_event(&v).is_none()); + + let v: Value = serde_json::from_str(r#"{"error":"success","request_id":0}"#).unwrap(); + assert!(parse_event(&v).is_none()); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..4eb6a74 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,98 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::model::{is_valid_id, Track}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Session { + pub queue: Vec, + pub current: Option, + pub position: f64, +} + +impl Session { + fn path() -> Result { + let dir = dirs::state_dir() + .or_else(dirs::data_local_dir) + .ok_or_else(|| anyhow!("cannot determine state directory"))?; + Ok(dir.join("drift").join("session.json")) + } + + pub fn exists() -> bool { + Self::path().is_ok_and(|p| p.exists()) + } + + pub fn load() -> Session { + let Ok(path) = Self::path() else { return Session::default() }; + let Ok(text) = fs::read_to_string(&path) else { return Session::default() }; + match serde_json::from_str::(&text) { + Ok(mut s) => { + s.sanitize(); + s + } + Err(_) => { + let _ = fs::rename(&path, path.with_extension("json.bak")); + Session::default() + } + } + } + + fn sanitize(&mut self) { + self.queue.retain(|t| is_valid_id(&t.id)); + if !self.position.is_finite() || self.position < 0.0 { + self.position = 0.0; + } + match self.current { + Some(_) if self.queue.is_empty() => self.current = None, + Some(c) if c >= self.queue.len() => self.current = Some(self.queue.len() - 1), + _ => {} + } + } + + pub fn render(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + pub fn write(text: &str) -> Result<()> { + let path = Self::path()?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(id: &str) -> Track { + Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) } + } + + #[test] + fn roundtrip_and_sanitize() { + let s = Session { + queue: vec![track("aaaaaaaaaaa"), track("bad")], + current: Some(7), + position: f64::NAN, + }; + let mut back: Session = serde_json::from_str(&s.render().unwrap().replace("NaN", "null")).unwrap_or_default(); + back.queue = s.queue.clone(); + back.current = s.current; + back.position = s.position; + back.sanitize(); + assert_eq!((back.queue.len(), back.current, back.position), (1, Some(0), 0.0)); + + let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 }; + let again: Session = serde_json::from_str(&ok.render().unwrap()).unwrap(); + assert_eq!((again.current, again.position, again.queue), (Some(0), 42.5, ok.queue)); + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..2184222 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,92 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::model::Track; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Store { + pub playlists: BTreeMap>, +} + +impl Store { + pub fn path() -> Result { + let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?; + let new_dir = dir.join("drift"); + let old_dir = dir.join("ytm-tui"); + if !new_dir.exists() && old_dir.exists() { + let _ = fs::rename(&old_dir, &new_dir); + } + Ok(new_dir.join("playlists.json")) + } + + pub fn load() -> (Store, Option) { + let path = match Self::path() { + Ok(p) => p, + Err(e) => return (Store::default(), Some(format!("playlists disabled: {e}"))), + }; + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Store::default(), None), + Err(e) => { + return ( + Store::default(), + Some(format!("cannot read {}: {e}", path.display())), + ) + } + }; + match serde_json::from_str::(&text) { + Ok(s) => (s, None), + Err(e) => { + let bak = path.with_extension("json.bak"); + let _ = fs::rename(&path, &bak); + ( + Store::default(), + Some(format!( + "playlists file was corrupt ({e}); moved to {}", + bak.display() + )), + ) + } + } + } + + pub fn save(&self) -> Result<()> { + let path = Self::path()?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(self)?; + fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) + } + + pub fn resolve(&self, name: &str) -> Option { + if self.playlists.contains_key(name) { + return Some(name.to_string()); + } + let lower = name.to_lowercase(); + let ci: Vec<&String> = self + .playlists + .keys() + .filter(|k| k.to_lowercase() == lower) + .collect(); + if ci.len() == 1 { + return Some(ci[0].clone()); + } + let pre: Vec<&String> = self + .playlists + .keys() + .filter(|k| k.to_lowercase().starts_with(&lower)) + .collect(); + if pre.len() == 1 { + return Some(pre[0].clone()); + } + None + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e14f78d --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,602 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Tabs}; +use ratatui::Frame; + +use crate::app::{App, Level, Mode, PlayState, View}; +use crate::model::{fmt_time, Track}; + +const ACCENT: Color = Color::Cyan; +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +fn selected_style() -> Style { + Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD) +} + +fn header_style() -> Style { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) +} + +fn dim() -> Style { + Style::new().fg(Color::DarkGray) +} + +pub fn draw(f: &mut Frame, app: &mut App) { + let area = f.area(); + let [top, body, playing, bottom] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(4), + Constraint::Length(1), + ]) + .areas(area); + + draw_tabs(f, app, top); + match app.view { + View::Results => draw_tracks(f, app, body, View::Results), + View::Queue => draw_tracks(f, app, body, View::Queue), + View::Playlists => draw_playlists(f, app, body), + } + draw_now_playing(f, app, playing); + draw_cmdline(f, app, bottom); + if app.mode == Mode::Help { + draw_help(f, app, area); + } + if app.mode == Mode::ArtistPick { + draw_artist_picker(f, app, area); + } +} + +fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { + let [name, tabs, right] = Layout::horizontal([ + Constraint::Length(9), + Constraint::Min(10), + Constraint::Length(34), + ]) + .areas(area); + + f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name); + + let titles = [ + format!(" Results {} ", app.results.len()), + format!(" Queue {} ", app.queue.len()), + format!(" Playlists {} ", app.store.playlists.len()), + ]; + f.render_widget( + Tabs::new(titles) + .select(app.view.idx()) + .style(Style::new().fg(Color::Gray)) + .highlight_style(Style::new().fg(Color::Black).bg(ACCENT).bold()) + .divider(" "), + tabs, + ); + + if let Some(busy) = &app.busy { + let spin = SPINNER[app.tick % SPINNER.len()]; + f.render_widget( + Paragraph::new(format!("{spin} {busy}… ")) + .alignment(Alignment::Right) + .style(Style::new().fg(Color::Yellow)), + right, + ); + } +} + +fn track_rows<'a>( + tracks: &'a [Track], + playing: Option<(&str, &'a str)>, + show_album: bool, +) -> Vec> { + tracks + .iter() + .enumerate() + .map(|(i, t)| { + let is_playing = playing.is_some_and(|(id, _)| id == t.id); + let marker = if is_playing { playing.map_or("", |p| p.1) } else { "" }; + let mut cells = vec![ + Cell::from(marker), + Cell::from(format!("{:>3}", i + 1)).style(dim()), + Cell::from(t.title.as_str()), + Cell::from(t.artist.as_str()), + ]; + if show_album { + cells.push(Cell::from(t.album.as_deref().unwrap_or(""))); + } + cells.push( + Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))) + .style(dim()), + ); + let row = Row::new(cells); + if is_playing { + row.style(Style::new().fg(Color::Green).add_modifier(Modifier::BOLD)) + } else { + row + } + }) + .collect() +} + +fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Table<'a> { + let mut header = vec!["", " #", "Title", "Artist"]; + let mut widths = vec![ + Constraint::Length(2), + Constraint::Length(4), + Constraint::Fill(3), + Constraint::Fill(2), + ]; + if show_album { + header.push("Album"); + widths.push(Constraint::Fill(2)); + } + header.push("Time"); + widths.push(Constraint::Length(7)); + + Table::new(rows, widths) + .header(Row::new(header).style(header_style())) + .block(block) + .column_spacing(1) + .row_highlight_style(selected_style()) +} + +fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { + let text: Vec = lines.iter().map(|l| Line::from(*l).style(dim())).collect(); + f.render_widget( + Paragraph::new(text).alignment(Alignment::Center).block(block), + area, + ); +} + +fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { + let show_album = area.width >= 76 && view == View::Results; + let marker = match app.play_state { + PlayState::Paused => "⏸", + PlayState::Loading => "…", + _ => "▶", + }; + let now_id = app + .now_track() + .filter(|_| app.play_state != PlayState::Stopped) + .map(|t| t.id.clone()); + let playing = now_id.as_deref().map(|id| (id, marker)); + + let (tracks, title) = match view { + View::Queue => (&app.queue, " Queue ".to_string()), + _ => (&app.results, format!(" {} ", app.results_title)), + }; + let block = Block::bordered() + .title(format!("{title}({}) ", tracks.len())) + .border_style(Style::new().fg(ACCENT)); + + if tracks.is_empty() { + let hint: &[&str] = if view == View::Queue { + &["", "The queue is empty.", "Press Enter on a search result, or `a` to add it."] + } else { + &["", "No results yet.", "Press / and type a query, or use :search \"daft punk\""] + }; + return empty_hint(f, block, area, hint); + } + let rows = track_rows(tracks, playing, show_album); + let table = track_table(rows, show_album, block); + let idx = view.idx(); + f.render_stateful_widget(table, area, &mut app.states[idx]); +} + +fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { + let marker = match app.play_state { + PlayState::Paused => "⏸", + PlayState::Loading => "…", + _ => "▶", + }; + let now_id = app + .now_track() + .filter(|_| app.play_state != PlayState::Stopped) + .map(|t| t.id.clone()); + let playing = now_id.as_deref().map(|id| (id, marker)); + + let Some(tracks) = app.store.playlists.get(name) else { return }; + let block = Block::bordered() + .title(format!(" {name} ({}) ", tracks.len())) + .border_style(Style::new().fg(ACCENT)); + if tracks.is_empty() { + return empty_hint( + f, + block, + area, + &["", "This playlist is empty.", "Add songs with :addto \"name\" from Results or Queue."], + ); + } + let show_album = area.width >= 76; + let rows = track_rows(tracks, playing, show_album); + let table = track_table(rows, show_album, block); + f.render_stateful_widget(table, area, &mut app.pl_state); +} + +fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { + if app.in_playlist() { + if let Some(name) = app.open.clone() { + return draw_open_playlist(f, app, area, &name); + } + } + let [left, right] = + Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area); + + let block = Block::bordered() + .title(format!(" Playlists ({}) ", app.store.playlists.len())) + .border_style(Style::new().fg(ACCENT)); + + if app.store.playlists.is_empty() { + empty_hint( + f, + block, + left, + &["", "No playlists yet.", "Queue some songs, then", ":save \"name\""], + ); + let b = Block::bordered().title(" Preview ").border_style(dim()); + f.render_widget(b, right); + return; + } + + let rows: Vec = app + .store + .playlists + .iter() + .enumerate() + .map(|(i, (name, tracks))| { + Row::new(vec![ + Cell::from(format!("{:>2}", i + 1)).style(dim()), + Cell::from(name.as_str()), + Cell::from(format!("{}", tracks.len())).style(dim()), + ]) + }) + .collect(); + let table = Table::new( + rows, + [Constraint::Length(3), Constraint::Fill(1), Constraint::Length(5)], + ) + .header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) + .block(block) + .row_highlight_style(selected_style()); + f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); + + let sel = app.selected(); + let preview_block = Block::bordered().title(" Preview ").border_style(dim()); + match app.playlist_name(sel).and_then(|n| app.store.playlists.get(&n)) { + Some(tracks) if !tracks.is_empty() => { + let show_album = right.width >= 70; + let rows = track_rows(tracks, None, show_album); + f.render_widget(track_table(rows, show_album, preview_block), right); + } + _ => f.render_widget(preview_block, right), + } +} + +fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { + let block = Block::bordered() + .title(" Now Playing ") + .border_style(Style::new().fg(ACCENT)); + let inner = block.inner(area); + f.render_widget(block, area); + let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); + + let mut flags = vec![format!("vol {}%", app.volume)]; + if app.shuffle { + flags.push("shuffle".into()); + } + if app.repeat != crate::model::RepeatMode::Off { + flags.push(format!("repeat:{}", app.repeat.label())); + } + let flags = flags.join(" · "); + let flags_w = flags.chars().count() as u16 + 1; + let [title_area, flags_area] = + Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1); + f.render_widget(Paragraph::new(flags).style(dim()).alignment(Alignment::Right), flags_area); + + let icon = match app.play_state { + PlayState::Playing => "▶".to_string(), + PlayState::Paused => "⏸".to_string(), + PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(), + PlayState::Stopped => "■".to_string(), + }; + let line = match (app.now_track(), app.play_state) { + (Some(t), s) if s != PlayState::Stopped => Line::from(vec![ + Span::styled(format!("{icon} "), Style::new().fg(Color::Green)), + Span::styled(t.title.clone(), Style::new().bold()), + Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)), + ]), + (Some(t), _) => Line::from(vec![ + Span::styled(format!("{icon} "), dim()), + Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()), + ]), + _ => Line::from(vec![ + Span::styled(format!("{icon} "), dim()), + Span::styled("Nothing playing", dim()), + ]), + }; + f.render_widget(Paragraph::new(line), title_area); + + let left = format!(" {} ", fmt_time(app.position)); + let right = format!(" {} ", if app.duration > 0.0 { fmt_time(app.duration) } else { "--:--".into() }); + let bar_w = (l2.width as usize).saturating_sub(left.chars().count() + right.chars().count()); + let ratio = if app.duration > 0.0 { + (app.position / app.duration).clamp(0.0, 1.0) + } else { + 0.0 + }; + let filled = (ratio * bar_w as f64).round() as usize; + let line = Line::from(vec![ + Span::raw(left), + Span::styled("━".repeat(filled), Style::new().fg(ACCENT)), + Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()), + Span::raw(right), + ]); + f.render_widget(Paragraph::new(line), l2); +} + +fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { + if app.mode == Mode::Command { + let text = format!(":{}", app.cmd_buf); + let x = area.x + text.chars().count() as u16; + f.render_widget(Paragraph::new(text), area); + f.set_cursor_position((x.min(area.right().saturating_sub(1)), area.y)); + return; + } + + let line = match app.visible_status() { + Some(s) => { + let style = match s.level { + Level::Error => Style::new().fg(Color::Red).bold(), + Level::Info => Style::new().fg(Color::Green), + }; + Line::styled(format!(" {}", s.text), style) + } + None => Line::default(), + }; + f.render_widget(Paragraph::new(line), area); + + let mut pending = String::new(); + if let Some(c) = app.count { + pending.push_str(&c.to_string()); + } + if let Some(p) = app.pending { + pending.push(p); + } + if !pending.is_empty() { + f.render_widget( + Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)), + area, + ); + } +} + +const HELP: &[(&str, &str)] = &[ + ("THIS WINDOW", "#"), + ("j k PgUp PgDn", "scroll"), + ("q Esc ?", "close"), + ("NAVIGATION", "#"), + ("j k / ↓ ↑", "move (counts work: 5j)"), + ("gg G", "top / bottom (5G → row 5)"), + ("Ctrl-d Ctrl-u", "half page down / up"), + ("Tab S-Tab", "cycle Results / Queue / Playlists"), + ("gr gq gp", "jump to Results / Queue / Playlists"), + ("PLAYBACK", "#"), + ("Enter", "play selected (on a playlist: load it; inside one: play from there)"), + ("Space", "pause / resume"), + ("n N (or > <)", "next / previous track"), + ("h l / ← →", "seek -5s / +5s (counts: 6l)"), + ("+ -", "volume up / down"), + ("s r", "toggle shuffle / cycle repeat"), + ("ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several"), + ("QUEUE", "#"), + ("a A P", "add selected / add all / play all results"), + ("dd x", "remove from queue / delete playlist / delete track in a playlist"), + ("J K", "move queue or playlist item down / up"), + ("PLAYLISTS", "#"), + ("→ or l", "open the highlighted playlist"), + ("← h Esc Backspace", "back to the playlist list"), + ("COMMANDS ( : or / )", "#"), + (":search \"q\"", "search YouTube Music (/ prefills it)"), + (":play [N]", "play or resume; N plays row N"), + (":pause :resume :stop", "playback state"), + (":next :prev", "skip"), + (":seek 90 | 1:30 | +10", "absolute or relative seek"), + (":vol 50 | +5", "set or change the volume"), + (":shuffle :repeat [m]", "modes: off, all or one"), + (":add [N] :addall", "queue a result / all results"), + (":playall :remove [N]", "play all results / remove from queue"), + (":clear", "empty the queue"), + (":save n :load n", "save the queue as a playlist / load one"), + (":append n :delpl n", "append a playlist to the queue / delete it"), + (":addto n", "add the selected track to playlist n"), + (":import URL", "load a YouTube / YT Music playlist"), + (":artist", "same as ga"), + (":quit (or q, Ctrl-c)", ""), +]; + +const HELP_KEY_W: usize = 24; + +fn wrap_words(text: &str, width: usize) -> Vec { + let width = width.max(1); + let mut out = Vec::new(); + let mut cur = String::new(); + for word in text.split_whitespace() { + let cur_len = cur.chars().count(); + if !cur.is_empty() && cur_len + 1 + word.chars().count() > width { + out.push(std::mem::take(&mut cur)); + } + if !cur.is_empty() { + cur.push(' '); + } + cur.push_str(word); + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +fn help_lines(inner_w: usize) -> Vec> { + let key_w = HELP_KEY_W.min(inner_w / 2).max(1); + let desc_w = inner_w.saturating_sub(key_w); + let key_style = Style::new().fg(ACCENT); + let mut lines: Vec> = Vec::new(); + + for (k, d) in HELP { + if *d == "#" { + if !lines.is_empty() { + lines.push(Line::raw("")); + } + lines.push(Line::styled(k.to_string(), header_style())); + continue; + } + let mut desc = wrap_words(d, desc_w).into_iter(); + if k.chars().count() < key_w { + let first = desc.next().unwrap_or_default(); + lines.push(Line::from(vec![ + Span::styled(format!("{k: = app + .artist_opts + .iter() + .enumerate() + .map(|(i, name)| { + let text = format!(" {} {name}", i + 1); + if i == app.artist_sel { + Line::styled(text, selected_style()) + } else { + Line::raw(text) + } + }) + .collect(); + lines.push(Line::styled(" Enter/1-9 select · Esc cancel", dim())); + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(lines).block( + Block::bordered() + .title(" Go to which artist? ") + .border_style(Style::new().fg(Color::Yellow)), + ), + popup, + ); +} + +fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { + let w = area.width.min(80); + let lines = help_lines(w.saturating_sub(2) as usize); + let h = (lines.len() as u16).saturating_add(2).min(area.height); + let popup = Rect { + x: area.x + (area.width - w) / 2, + y: area.y + (area.height - h) / 2, + width: w, + height: h, + }; + + let visible = h.saturating_sub(2) as usize; + let max_scroll = lines.len().saturating_sub(visible); + app.help_scroll = app.help_scroll.min(max_scroll); + let scroll = app.help_scroll; + + let mut block = Block::bordered() + .title(" Help — q or Esc to close ") + .border_style(Style::new().fg(Color::Yellow)); + if max_scroll > 0 { + let end = (scroll + visible).min(lines.len()); + block = block.title_bottom(format!(" j/k scroll · {}-{} of {} ", scroll + 1, end, lines.len())); + } + + let shown: Vec = lines.into_iter().skip(scroll).take(visible).collect(); + f.render_widget(Clear, popup); + f.render_widget(Paragraph::new(shown).block(block), popup); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::Terminal; + use std::sync::mpsc; + + fn text(term: &Terminal) -> String { + term.backend().buffer().content().iter().map(|c| c.symbol()).collect() + } + + #[test] + fn open_playlist_renders() { + let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + + let (tx, _rx) = mpsc::channel(); + let mut app = App::new(tx); + let t = |id: &str, title: &str| Track { + id: id.into(), + title: title.into(), + artist: "Someone".into(), + album: None, + duration: Some(90), + }; + app.store.playlists.insert("empty".into(), vec![]); + app.store.playlists.insert("mix".into(), vec![t("aaaaaaaaaaa", "First Song"), t("bbbbbbbbbbb", "Second Song")]); + let key = |app: &mut App, c| app.on_key(KeyEvent::new(c, KeyModifiers::NONE)); + + let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap(); + key(&mut app, KeyCode::Tab); + key(&mut app, KeyCode::Tab); + term.draw(|f| draw(f, &mut app)).unwrap(); + assert!(text(&term).contains("Playlists")); + + key(&mut app, KeyCode::Right); + term.draw(|f| draw(f, &mut app)).unwrap(); + assert!(text(&term).contains("This playlist is empty")); + key(&mut app, KeyCode::Left); + + key(&mut app, KeyCode::Down); + key(&mut app, KeyCode::Right); + term.draw(|f| draw(f, &mut app)).unwrap(); + let s = text(&term); + assert!(s.contains("First Song") && s.contains("Second Song") && s.contains("mix")); + assert!(!s.contains("Preview")); + + let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap(); + tiny.draw(|f| draw(f, &mut app)).unwrap(); + + key(&mut app, KeyCode::Char('?')); + term.draw(|f| draw(f, &mut app)).unwrap(); + let s = text(&term); + assert!(s.contains("THIS WINDOW") && !s.contains(":quit")); + key(&mut app, KeyCode::Char('G')); + term.draw(|f| draw(f, &mut app)).unwrap(); + assert!(text(&term).contains(":quit")); + tiny.draw(|f| draw(f, &mut app)).unwrap(); + let mut narrow = Terminal::new(TestBackend::new(12, 5)).unwrap(); + narrow.draw(|f| draw(f, &mut app)).unwrap(); + key(&mut app, KeyCode::Char('q')); + + } +} diff --git a/src/ytmusic.rs b/src/ytmusic.rs new file mode 100644 index 0000000..6215288 --- /dev/null +++ b/src/ytmusic.rs @@ -0,0 +1,320 @@ +use std::process::{Command, Stdio}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; + +use crate::model::{is_valid_id, parse_clock, Track}; + +const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; +const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF"; +const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; +const FALLBACK_COUNT: usize = 25; + +pub fn search(query: &str) -> Result, String> { + match search_innertube(query) { + Ok(tracks) if !tracks.is_empty() => Ok(tracks), + Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), + Err(primary) => ytdlp_search(query).map_err(|fallback| { + format!("YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})") + }), + } +} + +pub fn import(url: &str) -> Result, String> { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("import expects a full http(s) URL".into()); + } + ytdlp_entries(url, &[]).map_err(|e| format!("{e:#}")) +} + +fn client_version() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(1_750_000_000); + let days = (secs / 86_400) as i64 - 1; + let (y, m, d) = civil_from_days(days); + format!("1.{y:04}{m:02}{d:02}.01.00") +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn search_innertube(query: &str) -> Result> { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(12)) + .user_agent(UA) + .build(); + let body = json!({ + "context": { "client": { + "clientName": "WEB_REMIX", + "clientVersion": client_version(), + "hl": "en", + "gl": "US", + }}, + "query": query, + "params": SONGS_FILTER, + }); + let resp = agent + .post(ENDPOINT) + .set("Origin", "https://music.youtube.com") + .set("Referer", "https://music.youtube.com/") + .set("Cookie", "SOCS=CAI") + .send_json(body) + .map_err(|e| anyhow!("request failed: {e}"))?; + let v: Value = resp.into_json().context("invalid JSON from YouTube Music")?; + Ok(parse_search_response(&v)) +} + +fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) { + match v { + Value::Object(map) => { + if let Some(item) = map.get("musicResponsiveListItemRenderer") { + out.push(item); + } else { + for child in map.values() { + collect_items(child, out); + } + } + } + Value::Array(arr) => arr.iter().for_each(|c| collect_items(c, out)), + _ => {} + } +} + +fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + match v { + Value::Object(map) => { + if let Some(Value::String(s)) = map.get(key) { + return Some(s); + } + map.values().find_map(|c| find_str(c, key)) + } + Value::Array(arr) => arr.iter().find_map(|c| find_str(c, key)), + _ => None, + } +} + +fn column_segments(item: &Value, col: usize) -> Vec { + let runs = item + .get("flexColumns") + .and_then(|c| c.get(col)) + .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) + .and_then(Value::as_array); + let mut segments = Vec::new(); + let mut cur = String::new(); + for run in runs.into_iter().flatten() { + let Some(text) = run.get("text").and_then(Value::as_str) else { continue }; + if text.trim() == "•" { + segments.push(std::mem::take(&mut cur)); + } else { + cur.push_str(text); + } + } + segments.push(cur); + segments + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn looks_like_duration(s: &str) -> bool { + s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':') +} + +fn parse_item(item: &Value) -> Option { + let id = find_str(item, "videoId")?; + if !is_valid_id(id) { + return None; + } + let title = column_segments(item, 0).into_iter().next()?; + + let mut meta = column_segments(item, 1); + if matches!(meta.first().map(String::as_str), Some("Song" | "Video" | "Episode")) { + meta.remove(0); + } + let duration = match meta.last() { + Some(last) if looks_like_duration(last) => { + let d = parse_clock(last).map(|s| s as u32); + meta.pop(); + d + } + _ => None, + }; + let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into()); + let album = meta + .get(1) + .filter(|a| !a.contains(" views") && !a.contains(" plays")) + .cloned(); + + Some(Track { + id: id.to_string(), + title, + artist, + album, + duration, + }) +} + +pub fn parse_search_response(v: &Value) -> Vec { + let mut items = Vec::new(); + collect_items(v, &mut items); + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter_map(parse_item) + .filter(|t| seen.insert(t.id.clone())) + .collect() +} + +fn ytdlp_search(query: &str) -> Result> { + ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"), &[]) +} + +fn ytdlp_entries(target: &str, extra: &[&str]) -> Result> { + let out = Command::new("yt-dlp") + .args([ + "--flat-playlist", + "--dump-json", + "--no-warnings", + "--ignore-errors", + "--socket-timeout", + "15", + ]) + .args(extra) + .arg(target) + .stdin(Stdio::null()) + .output() + .map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; + + let tracks = parse_ytdlp_lines(&String::from_utf8_lossy(&out.stdout)); + if tracks.is_empty() && !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let last = stderr + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("unknown error"); + bail!("yt-dlp failed: {}", last.trim()); + } + Ok(tracks) +} + +pub fn parse_ytdlp_lines(text: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|v| { + let id = v.get("id")?.as_str()?; + if !is_valid_id(id) { + return None; + } + let title = v.get("title")?.as_str()?.trim().to_string(); + let artist = ["artist", "channel", "uploader"] + .iter() + .find_map(|k| v.get(*k).and_then(Value::as_str)) + .unwrap_or("Unknown artist") + .trim_end_matches(" - Topic") + .to_string(); + let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32); + Some(Track { + id: id.to_string(), + title, + artist, + album: None, + duration, + }) + }) + .filter(|t| seen.insert(t.id.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(text: &str) -> Value { + json!({"text": {"runs": [{"text": text}]}}) + } + + #[test] + fn parses_song_item() { + let resp = json!({"contents": {"shelf": [{"musicResponsiveListItemRenderer": { + "playlistItemData": {"videoId": "dQw4w9WgXcQ"}, + "flexColumns": [ + {"musicResponsiveListItemFlexColumnRenderer": run("Never Gonna Give You Up")}, + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [ + {"text": "Rick Astley"}, {"text": " • "}, + {"text": "Whenever You Need Somebody"}, {"text": " • "}, + {"text": "3:33"} + ]}}} + ] + }}]}}); + let tracks = parse_search_response(&resp); + assert_eq!(tracks.len(), 1); + let t = &tracks[0]; + assert_eq!(t.id, "dQw4w9WgXcQ"); + assert_eq!(t.title, "Never Gonna Give You Up"); + assert_eq!(t.artist, "Rick Astley"); + assert_eq!(t.album.as_deref(), Some("Whenever You Need Somebody")); + assert_eq!(t.duration, Some(213)); + } + + #[test] + fn parses_multi_artist_and_type_prefix() { + let resp = json!([{"musicResponsiveListItemRenderer": { + "overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}}, + "flexColumns": [ + {"musicResponsiveListItemFlexColumnRenderer": run("Song")}, + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [ + {"text": "Video"}, {"text": " • "}, + {"text": "A"}, {"text": " & "}, {"text": "B"}, {"text": " • "}, + {"text": "1.2M views"}, {"text": " • "}, {"text": "4:01"} + ]}}} + ] + }}]); + let t = &parse_search_response(&resp)[0]; + assert_eq!(t.artist, "A & B"); + assert_eq!(t.album, None); + assert_eq!(t.duration, Some(241)); + } + + #[test] + fn skips_items_without_video_id() { + let resp = json!([{"musicResponsiveListItemRenderer": { + "navigationEndpoint": {"browseId": "UC123"}, + "flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("Artist")}] + }}]); + assert!(parse_search_response(&resp).is_empty()); + } + + #[test] + fn parses_ytdlp_output() { + let text = r#"{"id":"dQw4w9WgXcQ","title":"Song","channel":"Rick - Topic","duration":213.0} +not json +{"id":"UCxxxxxxxxxxxxxxxxxxxxxx","title":"A channel"} +{"id":"dQw4w9WgXcQ","title":"dup"}"#; + let t = parse_ytdlp_lines(text); + assert_eq!(t.len(), 1); + assert_eq!(t[0].artist, "Rick"); + assert_eq!(t[0].duration, Some(213)); + } + + #[test] + fn date_math() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + } +}