From da5b76ab06fdd7ba6af6f0e1201dd046fb3c421d Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Tue, 20 Oct 2026 13:28:39 +0200 Subject: [PATCH 1/4] feat(mpris, similar search, playlist) - added feature to search for similar songs using YouTube API ; can go to similar songs by pressing (gs) - added mpris support (added support for playerctl) ; this means you can show the current playing song on waybar, shells, etc - added playlist search - added go to album --- Cargo.lock | 1271 ++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 + flake.lock | 27 - flake.nix | 2 +- src/app.rs | 318 +++++++++++- src/command.rs | 27 + src/main.rs | 7 + src/model.rs | 8 + src/mpris.rs | 162 ++++++ src/ui.rs | 68 ++- src/ytmusic.rs | 318 +++++++++++- 11 files changed, 2106 insertions(+), 105 deletions(-) delete mode 100644 flake.lock create mode 100644 src/mpris.rs diff --git a/Cargo.lock b/Cargo.lock index 2eaed14..412aff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -20,18 +29,239 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.5.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cassowary" version = "0.3.0" @@ -49,9 +279,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "shlex", @@ -63,6 +293,36 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "compact_str" version = "0.8.2" @@ -77,6 +337,64 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -86,17 +404,23 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -111,6 +435,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -131,7 +465,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -142,7 +476,28 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -166,6 +521,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "displaydoc" version = "0.2.7" @@ -174,7 +535,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -187,6 +548,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "souvlaki", "toml", "ureq", ] @@ -198,10 +560,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] -name = "equivalent" -version = "1.0.2" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -214,10 +597,62 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.14" +name = "event-listener" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -236,6 +671,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -245,6 +695,83 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.5.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -256,6 +783,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -279,6 +817,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "icu_collections" version = "2.3.0" @@ -401,9 +957,9 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" dependencies = [ "rustversion", ] @@ -418,7 +974,27 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", ] [[package]] @@ -438,9 +1014,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libredox" @@ -451,12 +1027,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -487,12 +1075,39 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -515,6 +1130,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -527,6 +1163,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -562,6 +1214,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.5.0", + "futures-io", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "potential_utf" version = "0.1.6" @@ -571,6 +1276,25 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -589,13 +1313,49 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cassowary", "compact_str", "crossterm", @@ -616,7 +1376,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -625,11 +1385,40 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -638,30 +1427,57 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] [[package]] -name = "rustls" -version = "0.23.45" +name = "rustix" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -737,14 +1553,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -753,6 +1569,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -762,6 +1589,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -806,10 +1644,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] -name = "smallvec" -version = "1.16.2" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics", + "dispatch", + "objc", + "pollster", + "thiserror", + "windows", + "zbus", + "zvariant", +] [[package]] name = "stable_deref_trait" @@ -857,6 +1730,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -870,9 +1754,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -887,7 +1771,20 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] @@ -929,7 +1826,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -941,6 +1838,17 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -952,7 +1860,7 @@ dependencies = [ "serde_spanned", "toml_datetime", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -962,10 +1870,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "unicode-ident" -version = "1.0.26" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -1038,6 +1994,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1050,14 +2018,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -1084,6 +2052,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1126,6 +2103,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -1157,6 +2149,12 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1169,6 +2167,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -1181,6 +2185,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -1199,6 +2209,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -1211,6 +2227,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -1223,6 +2245,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -1235,6 +2263,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -1247,6 +2281,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -1262,6 +2305,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.3" @@ -1281,10 +2334,96 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "once_cell", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1302,7 +2441,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -1342,7 +2481,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1356,3 +2495,41 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/Cargo.toml b/Cargo.toml index 582b230..971a155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ ureq = { version = "2", features = ["json"] } dirs = "5" libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } + [profile.release] lto = true codegen-units = 1 diff --git a/flake.lock b/flake.lock deleted file mode 100644 index b713556..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1792116957, - "narHash": "sha256-EjZqHKObBfIOeHZnMRVV87HB3pPq/WBkmRJWyFxmZu8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix index 2e60f5c..505bb0e 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,7 @@ in { default = pkgs.rustPlatform.buildRustPackage { pname = "drift"; - version = "0.1.0"; + version = "1.3"; src = ./.; cargoLock.lockFile = ./Cargo.lock; nativeBuildInputs = [ pkgs.makeWrapper ]; diff --git a/src/app.rs b/src/app.rs index 0d4f26d..2dae383 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,11 +9,12 @@ use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::config::{self, Config}; use crate::lyrics::{self, Lyrics}; -use crate::model::{split_artists, RepeatMode, Track}; +use crate::model::{split_artists, Collection, RepeatMode, Track}; +use crate::mpris::{Cmd, Mpris}; use crate::player::{Player, PlayerEvent}; use crate::session::Session; use crate::store::Store; -use crate::ytmusic::{self, Page}; +use crate::ytmusic::{self, Kind, Page}; pub enum AppEvent { Player(u64, PlayerEvent), @@ -30,6 +31,12 @@ pub enum AppEvent { id: String, result: Result, String>, }, + Collections { + id: u64, + title: String, + result: Result, String>, + }, + Media(Cmd), } const FIRST_PAGE: usize = 20; @@ -61,6 +68,7 @@ pub enum Mode { Normal, Command, ArtistPick, + CollectionPick, ConfirmDelete, Help, Menu, @@ -99,6 +107,8 @@ pub enum MenuAction { PlayNext, Queue, Artist, + Similar, + Album, PlaylistMenu, PickPlaylist(String), NewPlaylist, @@ -166,6 +176,9 @@ pub struct App { pub artist_opts: Vec, pub info_track: Option, pub artist_sel: usize, + pub picks: Vec, + pub pick_sel: usize, + pub pick_title: String, pub pending_delete: Option, pub help_scroll: usize, pub lyrics: LyricsState, @@ -185,6 +198,8 @@ pub struct App { pub repeat: RepeatMode, pub accent: ratatui::style::Color, played: HashSet, + radio_played: HashSet, + resume_radio: bool, fail_streak: u8, result_cursor: Option, buffered: Vec, @@ -193,6 +208,8 @@ pub struct App { results_gen: u64, radio: Vec, on_radio: bool, + similar_load: Option, + mpris: Option, seek_step: f64, volume_step: f64, autoplay: bool, @@ -225,6 +242,12 @@ impl App { .map(|d| d.as_nanos() as u64) .unwrap_or(0x9E37_79B9_7F4A_7C15) | 1; + let mpris = { + let t = tx.clone(); + Mpris::new(move |c| { + let _ = t.send(AppEvent::Media(c)); + }) + }; let mut app = App { view: View::Results, mode: Mode::Normal, @@ -248,6 +271,9 @@ impl App { artist_opts: Vec::new(), info_track: None, artist_sel: 0, + picks: Vec::new(), + pick_sel: 0, + pick_title: String::new(), pending_delete: None, help_scroll: 0, lyrics: LyricsState::Idle, @@ -266,6 +292,8 @@ impl App { repeat: p.repeat, accent: cfg.ui.accent, played: HashSet::new(), + radio_played: HashSet::new(), + resume_radio: false, fail_streak: 0, result_cursor: None, buffered: Vec::new(), @@ -274,6 +302,8 @@ impl App { results_gen: 0, radio: Vec::new(), on_radio: false, + similar_load: None, + mpris, seek_step: p.seek_step, volume_step: p.volume_step, autoplay: p.autoplay, @@ -315,6 +345,7 @@ impl App { eprintln!("drift: could not save session: {e:#}"); } self.player = None; + self.mpris = None; } fn save_session(&mut self, force: bool) -> Result<()> { @@ -358,6 +389,7 @@ impl App { self.tick = self.tick.wrapping_add(1); self.load_more(); self.sync_lyrics(); + self.sync_mpris(); if self.tick.is_multiple_of(50) { match self.save_session(false) { Ok(()) => self.save_failed = false, @@ -460,6 +492,7 @@ impl App { Mode::Help => self.key_help(key), Mode::Command => self.key_command(key), Mode::ArtistPick => self.key_artist_pick(key), + Mode::CollectionPick => self.key_collection_pick(key), Mode::ConfirmDelete => self.key_confirm_delete(key), Mode::Menu => self.key_menu(key), Mode::Info => self.key_info(key), @@ -484,6 +517,8 @@ impl App { ('g', 'p') => self.set_view(View::Playlists), ('d', 'd') => self.delete_selected(), ('g', 'a') => self.go_to_artist(), + ('g', 's') => self.go_similar(), + ('g', 'b') => self.go_album(), _ => {} } } @@ -626,6 +661,10 @@ impl App { items.push(menu_item('a', "Add to queue", MenuAction::Queue)); } items.push(menu_item('g', "Show artist", MenuAction::Artist)); + items.push(menu_item('s', "Similar songs", MenuAction::Similar)); + if track.album.is_some() { + items.push(menu_item('b', "Show album", MenuAction::Album)); + } items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); if in_queue { items.push(menu_item('x', "Remove from queue", MenuAction::Remove)); @@ -741,6 +780,8 @@ impl App { } MenuAction::Queue | MenuAction::AppendPlaylist => self.add_selected(), MenuAction::Artist => self.go_to_artist(), + MenuAction::Similar => self.go_similar(), + MenuAction::Album => self.go_album(), MenuAction::PlaylistMenu => {} MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::NewPlaylist => self.prefill_command("addto "), @@ -1078,6 +1119,10 @@ impl App { Command::AddTo(name) => self.add_to_playlist(&name), Command::Import(url) => self.start_import(url), Command::Artist => self.go_to_artist(), + Command::Similar => self.go_similar(), + Command::Album(Some(q)) => self.start_collection_search(q, Kind::Album), + Command::Album(None) => self.go_album(), + Command::PlaylistSearch(q) => self.start_collection_search(q, Kind::Playlist), Command::Lyrics => self.lyrics_panel = !self.lyrics_panel, Command::Help => self.open_help(), } @@ -1182,6 +1227,127 @@ impl App { }); } + fn go_album(&mut self) { + let Some(track) = self.artist_target() else { + return self.error("No song selected"); + }; + let Some(album) = track.album.clone() else { + return self.error("No album known for this song"); + }; + let artist = split_artists(&track.artist) + .into_iter() + .next() + .unwrap_or_default(); + let busy = format!("Loading album \u{201c}{album}\u{201d}"); + let title = format!("Album: {album}"); + self.spawn_loader(title, busy, move || { + let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; + let want = album.to_lowercase(); + let pick = found + .iter() + .find(|c| c.title.to_lowercase() == want) + .or(found.first()) + .ok_or_else(|| "album not found".to_string())?; + ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist).map(Page::from) + }); + } + + fn start_collection_search(&mut self, query: String, kind: Kind) { + let (what, title) = match kind { + Kind::Album => ("albums", format!("Albums: {query}")), + Kind::Playlist => ("playlists", format!("Playlists: {query}")), + }; + self.search_id += 1; + let id = self.search_id; + self.busy = Some(format!("Searching {what} for \u{201c}{query}\u{201d}")); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = ytmusic::search_collections(&query, kind); + let _ = tx.send(AppEvent::Collections { id, title, result }); + }); + } + + fn key_collection_pick(&mut self, key: KeyEvent) { + let len = self.picks.len(); + if len == 0 { + self.mode = Mode::Normal; + return; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), + KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), + KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), + KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, + KeyCode::Char('G') | KeyCode::End => self.pick_sel = len - 1, + KeyCode::Char(c @ '1'..='9') => { + let i = c as usize - '1' as usize; + if i < len { + self.pick_sel = i; + self.confirm_collection(); + } + } + KeyCode::Enter => self.confirm_collection(), + _ => {} + } + } + + fn close_collection_pick(&mut self) { + self.mode = Mode::Normal; + self.picks.clear(); + self.pick_sel = 0; + } + + fn confirm_collection(&mut self) { + let c = self.picks.get(self.pick_sel).cloned(); + self.close_collection_pick(); + let Some(c) = c else { return }; + let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; + let busy = format!("Loading \u{201c}{}\u{201d}", c.title); + let title = format!("{kind}: {}", c.title); + self.spawn_loader(title, busy, move || { + ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist).map(Page::from) + }); + } + + fn on_media(&mut self, cmd: Cmd) { + match cmd { + Cmd::Play => match self.play_state { + PlayState::Stopped => self.play_default(), + _ => self.set_pause(false), + }, + Cmd::Pause => { + if self.play_state != PlayState::Stopped { + self.set_pause(true); + } + } + Cmd::Toggle => self.toggle_pause(), + Cmd::Stop => self.stop_playback(), + Cmd::Next => self.next(), + Cmd::Prev => self.prev(), + Cmd::SeekBy(d) => self.seek(Amount::Delta(d)), + Cmd::SeekTo(p) => self.seek(Amount::Abs(p)), + Cmd::Quit => self.should_quit = true, + } + } + + fn sync_mpris(&mut self) { + if let Some(mut m) = self.mpris.take() { + m.update(self.now_track(), self.play_state, self.position); + self.mpris = Some(m); + } + } + + fn go_similar(&mut self) { + let Some(track) = self.artist_target() else { + return self.error("No song selected"); + }; + let busy = format!("Finding songs like \u{201c}{}\u{201d}", track.title); + let title = format!("Similar to {}", track.title); + let id = track.id; + self.spawn_loader(title, busy, move || ytmusic::radio(&id).map(Page::from)); + self.similar_load = Some(self.search_id); + } + fn start_search(&mut self, query: String) { self.spawn_loader( format!("Search: {query}"), @@ -1219,6 +1385,7 @@ impl App { return; } self.busy = None; + let autoplay = self.similar_load.take() == Some(id); match result { Ok(page) if page.tracks.is_empty() => self.info("No results found."), Ok(page) => { @@ -1236,6 +1403,9 @@ impl App { self.states[View::Results.idx()].select(Some(0)); self.set_view(View::Results); self.info(format!("{n} results — Enter plays, a queues, ? for help")); + if autoplay { + self.play_result(0); + } } Err(e) => self.error(e), } @@ -1268,6 +1438,23 @@ impl App { } } } + AppEvent::Collections { id, title, result } => { + if id != self.search_id { + return; + } + self.busy = None; + match result { + Ok(list) if list.is_empty() => self.info("Nothing found."), + Ok(list) => { + self.picks = list; + self.pick_sel = 0; + self.pick_title = title; + self.mode = Mode::CollectionPick; + } + Err(e) => self.error(e), + } + } + AppEvent::Media(cmd) => self.on_media(cmd), AppEvent::Lyrics { id, result } => { if self.lyrics_id.as_deref() != Some(id.as_str()) { return; @@ -1456,6 +1643,7 @@ impl App { } else { self.current.unwrap_or(0) }; + self.resume_radio = false; self.start_track(i.min(self.queue.len() - 1), true); } @@ -1473,6 +1661,7 @@ impl App { } View::Queue => { if i < self.queue.len() { + self.resume_radio = false; self.play_index(i); } } @@ -1482,7 +1671,7 @@ impl App { self.play_playlist_from(&name, i); } } else if let Some(name) = self.playlist_name(i) { - self.load_playlist(&name, true); + self.play_playlist_from(&name, 0); } } } @@ -1493,6 +1682,7 @@ impl App { return; } self.radio = self.results.clone(); + self.radio_played.clear(); self.play_radio(i); } @@ -1501,7 +1691,9 @@ impl App { return; }; self.on_radio = true; + self.resume_radio = true; self.result_cursor = Some(i); + self.radio_played.insert(i); self.resume = None; self.play_state = PlayState::Loading; self.position = 0.0; @@ -1604,11 +1796,12 @@ impl App { } let len = self.queue.len(); + let wrap = self.repeat == RepeatMode::All && !(self.on_radio || self.resume_radio); let next = if len == 0 { None } else if self.shuffle { let mut pool: Vec = (0..len).filter(|i| !self.played.contains(i)).collect(); - if pool.is_empty() && self.repeat == RepeatMode::All { + if pool.is_empty() && wrap { self.played.clear(); pool = (0..len).filter(|i| Some(*i) != self.current).collect(); if pool.is_empty() { @@ -1624,7 +1817,7 @@ impl App { } else { match self.current { Some(c) if c + 1 < len => Some(c + 1), - Some(_) if self.repeat == RepeatMode::All => Some(0), + Some(_) if wrap => Some(0), Some(_) => None, None => Some(0), } @@ -1636,21 +1829,45 @@ impl App { } fn advance_radio(&mut self, auto: bool) { - let next = self + let cursor = self .result_cursor - .filter(|_| self.on_radio || self.autoplay) - .map(|c| c + 1) - .filter(|&i| i < self.radio.len()); - match next { + .filter(|_| self.on_radio || self.resume_radio || self.autoplay); + match cursor.and_then(|c| self.next_in_radio(c)) { Some(i) => self.play_radio(i), None if auto => { self.stop_playback(); - self.info("Queue finished"); + self.info("Finished playing"); } - None => self.info("End of queue"), + None => self.info("End of the list"), } } + fn next_in_radio(&mut self, cursor: usize) -> Option { + let len = self.radio.len(); + if !self.shuffle { + return match cursor + 1 { + n if n < len => Some(n), + _ if self.repeat == RepeatMode::All && len > 0 => Some(0), + _ => None, + }; + } + let mut pool: Vec = (0..len) + .filter(|i| !self.radio_played.contains(i)) + .collect(); + if pool.is_empty() && self.repeat == RepeatMode::All { + self.radio_played.clear(); + pool = (0..len).filter(|&i| i != cursor).collect(); + if pool.is_empty() { + pool = vec![cursor]; + } + } + if pool.is_empty() { + return None; + } + let k = self.rand_below(pool.len()); + Some(pool[k]) + } + fn seek(&mut self, amount: Amount) { if !matches!(self.play_state, PlayState::Playing | PlayState::Paused) { return self.error("Nothing is playing"); @@ -1692,6 +1909,10 @@ impl App { if let Some(c) = self.current { self.played.insert(c); } + self.radio_played.clear(); + if let Some(c) = self.result_cursor { + self.radio_played.insert(c); + } self.info(format!( "Shuffle: {}", if self.shuffle { "on" } else { "off" } @@ -1754,11 +1975,7 @@ impl App { } else { 0 }; - self.queue = self.results.clone(); - self.played.clear(); - self.current = None; - self.result_cursor = None; - self.play_index(start.min(self.queue.len() - 1)); + self.play_result(start.min(self.results.len() - 1)); } fn clear_queue(&mut self) { @@ -1909,6 +2126,7 @@ impl App { self.played.clear(); self.current = None; self.result_cursor = None; + self.resume_radio = false; self.states[View::Queue.idx()].select(Some(0)); self.play_index(0); self.info(format!( @@ -1943,12 +2161,9 @@ impl App { 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.result_cursor = None; - self.states[View::Queue.idx()].select(Some(i)); - self.play_index(i); + self.radio = tracks; + self.radio_played.clear(); + self.play_radio(i); } fn remove_playlist_track(&mut self, i: usize) { @@ -2080,6 +2295,61 @@ mod tests { assert_eq!(app.results.len(), 25); } + fn now_title(app: &App) -> Option<&str> { + app.now_track().map(|t| t.title.as_str()) + } + + fn check_playlist_playback(app: &mut App) { + app.queue.clear(); + app.current = None; + app.played.clear(); + app.store.playlists.insert( + "run".into(), + vec![ + track("rrrrrrrrrr1", "R1"), + track("rrrrrrrrrr2", "R2"), + track("rrrrrrrrrr3", "R3"), + ], + ); + + app.set_view(View::Playlists); + app.open = Some("run".into()); + app.pl_state.select(Some(0)); + press(app, KeyCode::Enter); + assert!(app.queue.is_empty()); + assert_eq!(now_title(app), Some("R1")); + + app.enqueue(track("qqqqqqqqqq1", "Q1")); + app.advance(true); + assert_eq!(now_title(app), Some("Q1")); + app.advance(true); + assert_eq!(now_title(app), Some("R2")); + app.advance(true); + assert_eq!(now_title(app), Some("R3")); + assert_eq!(app.queue.len(), 1); + + app.repeat = RepeatMode::All; + app.advance(true); + assert_eq!(now_title(app), Some("R1")); + app.repeat = RepeatMode::Off; + + app.open = None; + let i = app.store.playlists.keys().position(|k| k == "run").unwrap(); + app.activate_at(View::Playlists, i); + assert_eq!(now_title(app), Some("R1")); + assert_eq!(app.queue.len(), 1); + + app.shuffle = true; + app.advance(true); + let first = app.result_cursor.unwrap(); + app.advance(true); + let second = app.result_cursor.unwrap(); + assert!(first != 0 && second != 0 && first != second); + app.advance(true); + assert_eq!(app.result_cursor, Some(second)); + app.shuffle = false; + } + #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); @@ -2174,6 +2444,8 @@ mod tests { app.run_command("remove"); assert_eq!(app.queue.len(), 1); + check_playlist_playback(&mut app); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index 9237286..815e1fc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -36,6 +36,9 @@ pub enum Command { AddTo(String), Import(String), Artist, + Similar, + Album(Option), + PlaylistSearch(String), Lyrics, Help, } @@ -69,6 +72,9 @@ pub const COMMAND_NAMES: &[&str] = &[ "addto", "import", "artist", + "similar", + "album", + "playlist", "lyrics", "help", ]; @@ -177,6 +183,14 @@ pub fn parse(line: &str) -> Result, String> { Import(arg.to_string()) } "artist" | "ar" => Artist, + "similar" | "radio" | "sim" => Similar, + "album" | "albums" | "al" => Album((!arg.is_empty()).then(|| arg.to_string())), + "playlist" => { + if arg.is_empty() { + return Err("usage: :playlist \"query\"".into()); + } + PlaylistSearch(arg.to_string()) + } "lyrics" | "ly" => Lyrics, "help" | "h" => Help, other => return Err(format!("unknown command: :{other} (try :help)")), @@ -242,6 +256,19 @@ mod tests { assert_eq!(parse("artist"), Ok(Some(Command::Artist))); assert_eq!(parse(":ar"), Ok(Some(Command::Artist))); assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics))); + assert_eq!(parse("similar"), Ok(Some(Command::Similar))); + assert_eq!(parse(":radio"), Ok(Some(Command::Similar))); + assert_eq!(parse("album"), Ok(Some(Command::Album(None)))); + assert_eq!( + parse("album daft punk"), + Ok(Some(Command::Album(Some("daft punk".into())))) + ); + assert_eq!( + parse("playlist lofi"), + Ok(Some(Command::PlaylistSearch("lofi".into()))) + ); + assert!(parse("playlist").is_err()); + assert_eq!(parse("playlists"), Ok(Some(Command::ShowPlaylists))); } #[test] diff --git a/src/main.rs b/src/main.rs index aca55f5..5a44a69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod command; mod config; mod lyrics; mod model; +mod mpris; mod player; mod session; mod store; @@ -128,6 +129,12 @@ fn run(initial_query: Option) -> Result<()> { } let mut terminal = ratatui::try_init()?; + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if std::thread::current().name() == Some("main") { + hook(info); + } + })); let result = event_loop(&mut terminal, &mut app, &rx); ratatui::restore(); app.shutdown(); diff --git a/src/model.rs b/src/model.rs index 685c42c..0f7a156 100644 --- a/src/model.rs +++ b/src/model.rs @@ -21,6 +21,14 @@ impl Track { } } +#[derive(Debug, Clone, PartialEq)] +pub struct Collection { + pub browse_id: String, + pub title: String, + pub subtitle: String, + pub artist: String, +} + pub fn split_artists(credit: &str) -> Vec { const MARK: char = '\u{1}'; let mut s = credit.to_string(); diff --git a/src/mpris.rs b/src/mpris.rs new file mode 100644 index 0000000..31c47cf --- /dev/null +++ b/src/mpris.rs @@ -0,0 +1,162 @@ +/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */ + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Cmd { + Play, + Pause, + Toggle, + Stop, + Next, + Prev, + SeekBy(f64), + SeekTo(f64), + Quit, +} + +#[cfg(target_os = "linux")] +mod imp { + use std::time::Duration; + + use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, + PlatformConfig, SeekDirection, + }; + + use super::Cmd; + use crate::app::PlayState; + use crate::model::Track; + + pub struct Mpris { + controls: MediaControls, + track: Option, + state: PlayState, + secs: u64, + } + + impl Mpris { + /* None when there is no session bus. */ + pub fn new(send: impl Fn(Cmd) + Send + 'static) -> Option { + let name = format!("drift.instance{}", std::process::id()); + let config = PlatformConfig { + dbus_name: &name, + display_name: "drift", + hwnd: None, + }; + let mut controls = MediaControls::new(config).ok()?; + controls + .attach(move |ev| { + if let Some(c) = convert(ev) { + send(c); + } + }) + .ok()?; + Some(Mpris { + controls, + track: None, + state: PlayState::Stopped, + secs: 0, + }) + } + + pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) { + let id = track.map(|t| t.id.as_str()); + if id != self.track.as_deref() { + self.track = id.map(str::to_string); + let cover = track.map(|t| format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", t.id)); + let meta = match track { + Some(t) => MediaMetadata { + title: Some(t.title.as_str()), + album: t.album.as_deref(), + artist: Some(t.artist.as_str()), + cover_url: cover.as_deref(), + duration: t.duration.map(|d| Duration::from_secs(u64::from(d))), + }, + None => MediaMetadata::default(), + }; + let _ = self.controls.set_metadata(meta); + self.secs = u64::MAX; + } + let pos = if position.is_finite() { position.max(0.0) } else { 0.0 }; + let secs = pos as u64; + if state == self.state && secs == self.secs { + return; + } + self.state = state; + self.secs = secs; + let progress = Some(MediaPosition(Duration::from_secs_f64(pos))); + let _ = self.controls.set_playback(match state { + PlayState::Playing | PlayState::Loading => MediaPlayback::Playing { progress }, + PlayState::Paused => MediaPlayback::Paused { progress }, + PlayState::Stopped => MediaPlayback::Stopped, + }); + } + } + + fn dir(d: SeekDirection, secs: f64) -> f64 { + if d == SeekDirection::Backward { + -secs + } else { + secs + } + } + + pub(super) fn convert(ev: MediaControlEvent) -> Option { + Some(match ev { + MediaControlEvent::Play => Cmd::Play, + MediaControlEvent::Pause => Cmd::Pause, + MediaControlEvent::Toggle => Cmd::Toggle, + MediaControlEvent::Next => Cmd::Next, + MediaControlEvent::Previous => Cmd::Prev, + MediaControlEvent::Stop => Cmd::Stop, + MediaControlEvent::Seek(d) => Cmd::SeekBy(dir(d, 5.0)), + MediaControlEvent::SeekBy(d, by) => Cmd::SeekBy(dir(d, by.as_secs_f64())), + MediaControlEvent::SetPosition(MediaPosition(p)) => Cmd::SeekTo(p.as_secs_f64()), + MediaControlEvent::Quit => Cmd::Quit, + _ => return None, + }) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn maps_events() { + assert_eq!(convert(MediaControlEvent::Previous), Some(Cmd::Prev)); + assert_eq!( + convert(MediaControlEvent::SeekBy( + SeekDirection::Backward, + Duration::from_secs(10) + )), + Some(Cmd::SeekBy(-10.0)) + ); + assert_eq!( + convert(MediaControlEvent::SetPosition(MediaPosition( + Duration::from_secs(90) + ))), + Some(Cmd::SeekTo(90.0)) + ); + assert_eq!(convert(MediaControlEvent::Raise), None); + } + } +} + +#[cfg(not(target_os = "linux"))] +mod imp { + use super::Cmd; + use crate::app::PlayState; + use crate::model::Track; + + pub struct Mpris; + + impl Mpris { + pub fn new(_send: impl Fn(Cmd) + Send + 'static) -> Option { + None + } + + pub fn update(&mut self, _track: Option<&Track>, _state: PlayState, _position: f64) {} + } +} + +pub use imp::Mpris; diff --git a/src/ui.rs b/src/ui.rs index c3cfa98..a4321db 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -69,6 +69,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { if app.mode == Mode::ArtistPick { draw_artist_picker(f, app, area); } + if app.mode == Mode::CollectionPick { + draw_collection_picker(f, app, area); + } if app.mode == Mode::Info { draw_info(f, app, area); } @@ -801,6 +804,9 @@ const CMD_INFO: &[(&str, &str)] = &[ ("addto", "add the selected track to a playlist"), ("import", "import a YouTube / YT Music playlist URL"), ("artist", "songs by the selected artist"), + ("similar", "radio: songs similar to the selected one"), + ("album", "open the selected song's album, or :album to search"), + ("playlist", "search YouTube Music playlists"), ("lyrics", "show or hide the lyrics panel"), ("help", "show key bindings"), ]; @@ -1075,10 +1081,19 @@ const HELP: &[(&str, &str)] = &[ "ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several", ), + ( + "gb or :album", + "open the album of the selected song; :album searches albums", + ), + (":playlist ", "search YouTube Music playlists (pick one, then ⏎)"), + ( + "gs or :similar", + "radio: load songs similar to the selected one and start playing them", + ), ("SONG MENU ( G )", "#"), ("G", "menu for the highlighted song or playlist"), ("j k ⏎", "move / choose (the yellow letter is a shortcut)"), - ("p n a g l", "play · play next · add to queue · artist · playlist"), + ("p n a g s b l", "play · next · queue · artist · similar · album · playlist"), ("x i", "remove · song info"), ("Esc q G", "close (Esc goes back from a sub-menu)"), ("QUEUE", "#"), @@ -1116,6 +1131,8 @@ const HELP: &[(&str, &str)] = &[ (":addto n", "add the selected track to playlist n"), (":import URL", "load a YouTube / YT Music playlist"), (":artist", "same as ga"), + (":similar", "same as gs (also :radio)"), + (":album [q]", "same as gb, or search albums"), (":lyrics", "same as L"), (":quit (or q, Ctrl-c)", ""), ]; @@ -1219,6 +1236,55 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { ); } +fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) { + let n = app.picks.len(); + let w = area.width.min(78); + let h = (n as u16).saturating_add(3).min(area.height); + let visible = h.saturating_sub(3) as usize; + let start = if app.pick_sel < visible { + 0 + } else { + app.pick_sel + 1 - visible + }; + let popup = Rect { + x: area.x + (area.width - w) / 2, + y: area.y + (area.height - h) / 2, + width: w, + height: h, + }; + let inner = w.saturating_sub(2) as usize; + let mut lines: Vec = app + .picks + .iter() + .enumerate() + .skip(start) + .take(visible) + .map(|(i, c)| { + let mut text = format!(" {:>2} {}", i + 1, c.title); + if !c.subtitle.is_empty() { + text.push_str(&format!(" · {}", c.subtitle)); + } + let text: String = text.chars().take(inner).collect(); + if i == app.pick_sel { + let pad = inner.saturating_sub(text.chars().count()); + Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) + } else { + Line::raw(text) + } + }) + .collect(); + lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim())); + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(lines).block( + Block::bordered() + .title(format!(" {} ", app.pick_title)) + .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, app.accent); diff --git a/src/ytmusic.rs b/src/ytmusic.rs index 203f9f2..97dd7f1 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -4,15 +4,26 @@ 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}; +use crate::model::{is_valid_id, parse_clock, Collection, Track}; const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false"; +const NEXT_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/next?prettyPrint=false"; +const ALBUMS_FILTER: &str = "EgWKAQIYAWoMEA4QChADEAQQCRAF"; +const PLAYLISTS_FILTER: &str = "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D"; +const RADIO_TARGET: usize = 50; +const RADIO_PAGES: usize = 4; const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF"; const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF"; const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; const FALLBACK_COUNT: usize = 25; +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + Album, + Playlist, +} + pub struct Page { pub tracks: Vec, pub next: Option, @@ -49,6 +60,91 @@ pub fn search_more(token: &str) -> Result { .map_err(|e| format!("{e:#}")) } +pub fn search_collections(query: &str, kind: Kind) -> Result, String> { + let params = match kind { + Kind::Album => ALBUMS_FILTER, + Kind::Playlist => PLAYLISTS_FILTER, + }; + post(ENDPOINT, json!({ "query": query, "params": params })) + .map(|v| parse_collections(&v, kind)) + .map_err(|e| format!("{e:#}")) +} + +/* browse_id: MPREb_... for an album, VL... for a playlist */ +pub fn browse_tracks(browse_id: &str, album: &str, artist: &str) -> Result, String> { + let playlist = browse_id.starts_with("VL"); + let v = post(BROWSE_ENDPOINT, json!({ "browseId": browse_id })).map_err(|e| format!("{e:#}"))?; + let mut tracks = parse_browse(&v, playlist); + let mut next = if playlist { find_continuation(&v) } else { None }; + let mut pages = 0; + while let Some(tok) = next.take() { + if pages >= 5 || tracks.len() >= 300 { + break; + } + pages += 1; + let url = format!("{BROWSE_ENDPOINT}&ctoken={tok}&continuation={tok}"); + let Ok(v) = post(&url, json!({})) else { break }; + let more = parse_browse(&v, true); + if more.is_empty() { + break; + } + let known: std::collections::HashSet = + tracks.iter().map(|t| t.id.clone()).collect(); + tracks.extend(more.into_iter().filter(|t| !known.contains(&t.id))); + next = find_continuation(&v); + } + if tracks.is_empty() { + return Err("nothing playable found".into()); + } + if !playlist { + for t in &mut tracks { + if !album.is_empty() && t.album.is_none() { + t.album = Some(album.to_string()); + } + if !artist.is_empty() && t.artist == "Unknown artist" { + t.artist = artist.to_string(); + } + } + } + Ok(tracks) +} + +pub fn radio(seed: &str) -> Result, String> { + fetch_radio(seed).map_err(|e| format!("{e:#}")) +} + +fn fetch_radio(seed: &str) -> Result> { + let body = json!({ + "videoId": seed, + "playlistId": format!("RDAMVM{seed}"), + "params": "wAEB", + "isAudioOnly": true, + "enablePersistentPlaylistPanel": true, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", + }); + let mut url = NEXT_ENDPOINT.to_string(); + let mut tracks: Vec = Vec::new(); + for _ in 0..RADIO_PAGES { + let v = post(&url, body.clone())?; + let before = tracks.len(); + for t in parse_radio_response(&v) { + if !tracks.iter().any(|x| x.id == t.id) { + tracks.push(t); + } + } + match find_continuation(&v) { + Some(tok) if tracks.len() < RADIO_TARGET && tracks.len() > before => { + url = format!("{NEXT_ENDPOINT}&ctoken={tok}&continuation={tok}"); + } + _ => break, + } + } + if tracks.is_empty() { + bail!("no similar songs found"); + } + Ok(tracks) +} + 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()); @@ -119,6 +215,7 @@ fn find_continuation(v: &Value) -> Option { Value::Object(map) => { let token = map .get("nextContinuationData") + .or_else(|| map.get("nextRadioContinuationData")) .and_then(|c| c.get("continuation")) .or_else(|| { map.get("continuationCommand") @@ -220,11 +317,15 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { } 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); + run_segments( + item.get("flexColumns") + .and_then(|c| c.get(col)) + .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) + .and_then(Value::as_array), + ) +} + +fn run_segments(runs: Option<&Vec>) -> Vec { let mut segments = Vec::new(); let mut cur = String::new(); for run in runs.into_iter().flatten() { @@ -249,6 +350,13 @@ fn looks_like_duration(s: &str) -> bool { s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':') } +fn fixed_duration(item: &Value) -> Option { + let s = item + .pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")? + .as_str()?; + parse_clock(s).map(|s| s as u32) +} + fn parse_item(item: &Value) -> Option { let id = find_str(item, "videoId")?; if !is_valid_id(id) { @@ -271,6 +379,7 @@ fn parse_item(item: &Value) -> Option { } _ => None, }; + let duration = duration.or_else(|| fixed_duration(item)); let artist = meta .first() .cloned() @@ -289,6 +398,63 @@ fn parse_item(item: &Value) -> Option { }) } +fn parse_browse(v: &Value, playlist: bool) -> Vec { + let mut items = Vec::new(); + collect_items(v, &mut items); + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter_map(|item| { + let mut t = parse_item(item)?; + if playlist && t.album.is_none() { + t.album = column_segments(item, 2).into_iter().next(); + } + Some(t) + }) + .filter(|t| seen.insert(t.id.clone())) + .collect() +} + +pub fn parse_collections(v: &Value, kind: Kind) -> Vec { + let mut items = Vec::new(); + collect_items(v, &mut items); + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter_map(|item| { + let id = item + .pointer("/navigationEndpoint/browseEndpoint/browseId")? + .as_str()?; + let ok = match kind { + Kind::Album => id.starts_with("MPRE"), + Kind::Playlist => id.starts_with("VL"), + }; + if !ok { + 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("Album" | "EP" | "Single" | "Playlist") + ) { + meta.remove(0); + } + let artist = match kind { + Kind::Album => meta.first().cloned().unwrap_or_default(), + Kind::Playlist => String::new(), + }; + Some(Collection { + browse_id: id.to_string(), + title, + subtitle: meta.join(" · "), + artist, + }) + }) + .filter(|c| seen.insert(c.browse_id.clone())) + .collect() +} + pub fn parse_search_response(v: &Value) -> Vec { let mut items = Vec::new(); collect_items(v, &mut items); @@ -300,6 +466,76 @@ pub fn parse_search_response(v: &Value) -> Vec { .collect() } +fn collect_panel<'a>(v: &'a Value, out: &mut Vec<&'a Value>) { + match v { + Value::Object(map) => { + if let Some(w) = map.get("playlistPanelVideoWrapperRenderer") { + out.extend(w.pointer("/primaryRenderer/playlistPanelVideoRenderer")); + } else if let Some(item) = map.get("playlistPanelVideoRenderer") { + out.push(item); + } else { + map.values().for_each(|c| collect_panel(c, out)); + } + } + Value::Array(arr) => arr.iter().for_each(|c| collect_panel(c, out)), + _ => {} + } +} + +fn parse_panel_item(item: &Value) -> Option { + let id = item.get("videoId")?.as_str()?; + if !is_valid_id(id) { + return None; + } + let title: String = item + .pointer("/title/runs")? + .as_array()? + .iter() + .filter_map(|r| r.get("text")?.as_str()) + .collect(); + let title = title.trim().to_string(); + if title.is_empty() { + return None; + } + let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array)); + let artist = meta + .first() + .cloned() + .unwrap_or_else(|| "Unknown artist".into()); + let album = meta + .get(1) + .filter(|a| { + !a.contains(" views") + && !a.contains(" plays") + && !(a.len() == 4 && a.bytes().all(|b| b.is_ascii_digit())) + }) + .cloned(); + let duration = item + .pointer("/lengthText/runs/0/text") + .or_else(|| item.pointer("/lengthText/simpleText")) + .and_then(Value::as_str) + .and_then(parse_clock) + .map(|s| s as u32); + Some(Track { + id: id.to_string(), + title, + artist, + album, + duration, + }) +} + +pub fn parse_radio_response(v: &Value) -> Vec { + let mut items = Vec::new(); + collect_panel(v, &mut items); + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter_map(parse_panel_item) + .filter(|t| seen.insert(t.id.clone())) + .collect() +} + fn ytdlp_search(query: &str) -> Result> { ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}")) } @@ -463,6 +699,76 @@ not json assert_eq!(find_continuation(&json!({"a": [1, 2]})), None); } + #[test] + fn parses_radio_panel() { + let item = |id: &str, title: &str, by: Value, len: &str| { + json!({"playlistPanelVideoRenderer": { + "videoId": id, + "title": {"runs": [{"text": title}]}, + "longBylineText": {"runs": by}, + "lengthText": {"runs": [{"text": len}]}, + }}) + }; + let by = json!([{"text": "A"}, {"text": " & "}, {"text": "B"}, + {"text": " • "}, {"text": "Album"}, {"text": " • "}, {"text": "2019"}]); + let resp = json!({"contents": [ + item("abcdefghijk", "One", by, "3:21"), + {"playlistPanelVideoWrapperRenderer": { + "primaryRenderer": item("bcdefghijkl", "Two", json!([{"text": "C"}]), "1:05"), + "counterpart": [item("zzzzzzzzzzz", "Video", json!([{"text": "C"}]), "1:05")], + }}, + item("abcdefghijk", "dup", json!([{"text": "A"}]), "3:21"), + ]}); + let t = parse_radio_response(&resp); + assert_eq!(t.len(), 2); + assert_eq!(t[0].artist, "A & B"); + assert_eq!(t[0].album.as_deref(), Some("Album")); + assert_eq!(t[0].duration, Some(201)); + assert_eq!(t[1].id, "bcdefghijkl"); + assert_eq!(t[1].album, None); + } + + #[test] + fn parses_album_and_playlist_results() { + let row = |id: &str, title: &str, runs: Value| { + json!({"musicResponsiveListItemRenderer": { + "navigationEndpoint": {"browseEndpoint": {"browseId": id}}, + "flexColumns": [ + {"musicResponsiveListItemFlexColumnRenderer": run(title)}, + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": runs}}}, + ] + }}) + }; + let resp = json!([ + row("MPREb_abc", "Discovery", json!([{"text": "Album"}, {"text": " • "}, + {"text": "Daft Punk"}, {"text": " • "}, {"text": "2001"}])), + row("VLPL123", "Mix", json!([{"text": "Playlist"}, {"text": " • "}, {"text": "Someone"}])), + row("UCartist", "Artist", json!([{"text": "Artist"}])), + ]); + let a = parse_collections(&resp, Kind::Album); + assert_eq!(a.len(), 1); + assert_eq!(a[0].title, "Discovery"); + assert_eq!(a[0].artist, "Daft Punk"); + assert_eq!(a[0].subtitle, "Daft Punk · 2001"); + let p = parse_collections(&resp, Kind::Playlist); + assert_eq!(p.len(), 1); + assert_eq!(p[0].browse_id, "VLPL123"); + assert_eq!(p[0].artist, ""); + } + + #[test] + fn parses_browse_tracks() { + let resp = json!({"contents": [{"musicResponsiveListItemRenderer": { + "playlistItemData": {"videoId": "abcdefghijk"}, + "flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("One More Time")}], + "fixedColumns": [{"musicResponsiveListItemFixedColumnRenderer": run("5:20")}], + }}]}); + let t = parse_browse(&resp, false); + assert_eq!(t.len(), 1); + assert_eq!(t[0].duration, Some(320)); + assert_eq!(t[0].artist, "Unknown artist"); + } + #[test] fn date_math() { assert_eq!(civil_from_days(0), (1970, 1, 1)); From 468ba7ee96803e0efa83b45cfc553457c2113fb5 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Tue, 20 Oct 2026 13:33:17 +0200 Subject: [PATCH 2/4] add(nix): add flake.lock --- flake.lock | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 flake.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bc0b988 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1792488032, + "narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e513db7b0a65def6f6e17bb55195dfe510871944", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} From b3fbcfdfd64a5158e2b0c85383d88963c0651fc6 Mon Sep 17 00:00:00 2001 From: Socat Date: Tue, 20 Oct 2026 13:37:54 +0200 Subject: [PATCH 3/4] Revise CHANGELOG.md with recent updates Updated changelog to reflect new features, changes, and fixes in Drift. --- CHANGELOG.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a23715..32c2f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,28 @@ # Changelog -All notable changes to Drift are documented in this file. +Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased ### Added -- Synced lyrics via LRCLIB. `L` or `:lyrics` toggles a side panel, `F` opens fullscreen. -- Current lyric line is highlighted with a progress fill; nearby lines fade out. -- Plain lyrics fallback (scroll with `j`/`k`) when no synced version exists. +- MPRIS on linux. media keys, `playerctl` and waybar can now see and control drift (play/pause/next/prev/seek/stop), with title, artist, album and cover art. Shows up as `org.mpris.MediaPlayer2.drift`. If theres no dbus session it just doesnt start, nothing breaks. +- `:album ` to search albums, `:playlist ` to search youtube music playlists. Results show up in a picker, enter loads the tracks. (`:playlists` is still your own playlists tab.) Big playlists stop at ~300 tracks. +- `gb` / `:album` / `G` then `b` opens the album of the selected song. It finds it by album + artist name so it can guess wrong sometimes, and the song needs album info to begin with. +- `gs` / `:similar` / `:radio` loads ~50 songs similar to the selected one and starts playing them, like youtube music radio. +- Synced lyrics from LRCLIB. `L` toggles the side panel, `F` for fullscreen. Current line gets a progress fill, lines around it fade. +- Plain lyrics fallback when theres no synced version, scroll with `j`/`k`. ### Changed -- Tab bar redesigned as pills with dimmed counts on inactive tabs. -- Tab bar only reserves spinner space while busy, so tabs fit on narrow terminals. +- Album tracks now get the artist/album/length filled in when youtube leaves them out. +- Tracks from a playlist get their album too, so `gb` works on them. +- New dependency on linux: `souvlaki` (zbus backend, pure rust, no system libs). `Cargo.lock` needs a regen, just run `cargo build` once. `--locked` builds, the flake and the AUR package need the new lock. +- Tab bar is pills now, inactive tabs have dimmed counts. +- Tab bar only reserves space for the spinner while busy, fits narrow terminals better. + +### Fixed + +- Playing from a playlist no longer replaces the queue. Playlists play as their own list, queue only has what you add, queued songs go first and then the playlist continues. +- Shuffle and repeat-all work for playlists and results now. repeat-all used to loop the queue instead of the list you were actualy playing. +- `P` / `:playall` doesnt wipe the queue anymore. From a7f101d1e79d5c2fdc8834d52d63a1335482b138 Mon Sep 17 00:00:00 2001 From: Socat Date: Tue, 20 Oct 2026 13:42:50 +0200 Subject: [PATCH 4/4] Update README with new commands for similar songs Added commands for similar songs, album, and playlist search to the README. --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d847798..d958b1d 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ Search, queue and play music with no account, no login and no browser. - **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info - **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history - **Artist browsing** - press `ga` to see an artist's songs +- **Similar songs** - press `gs` to see similar songs - **Audio visualizer** - a live visualizer while music plays -- **Session restore** - your queue and playback position come back on the next launch - **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file ## Installation @@ -116,6 +116,7 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `J` `K` | move a queue or playlist item down / up | | `G` | song menu | | `ga` | go to the artist | +| `gs` | see similar songs | | `L` | toggle the lyrics panel | | `F` | fullscreen lyrics | | `/` `:` | search / command line | @@ -135,6 +136,10 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `:save name` `:load name` `:append name` | work with playlists | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | +| `:album [query]` | open the selected song's album, or search albums | +| `:playlist query` | search YouTube Music playlists | +| `:similar` | radio: songs similar to the selected one (also `:radio`) | +| `:artist` | songs by the selected song's artist | | `:lyrics` | toggle the lyrics panel | ## Lyrics