feat: discord presence, cached audio playback, multiinstance safety

Add
- Discord rich presence ("Listening to", cover, progress, Listen Along
  button). enable via [discord] client_id in config.toml : retries quietly
  if Discord isn't running. new dep: discord-rich-presence.
- :save! / :w! to overwrite a playlist. Plain :save now refuses to
  clobber an existing name.

Change
- Download audio with yt-dlp into the cache dir and play the local file
  (mpv --ytdl=no) instead of streaming. Keep the last 40 files and
  prefetch the next queued track.
- show the the actual yt-dlp/mpv error on playback failure instead of the
  generic "update yt-dlp" hint; allow wider status messages (70 - 120 char).
- Split artists from YouTube's linked artist runs and yt-dlp's 'artists'
  list (new Track::artists) rather than guessing from the display string.
  Fallback splitter keeps names like "Tyler, The Creator" whole and
  matches separators case-insensitively. (shit)
- Restyle selection (accent bold text + bar) and tab bar (underlined
  active tab instead of pills)
- Share one ureq agent across yt Music requests

Fix
- Multiple drift instances: only the first (flock on session.lock) owns
  the session, and playlist saves merge with what other instances wrote.
- Collection picker no longer steals keys while typing.
- "Play next" now shifts `played` indices so shuffle history stays right.
- Leaving the queue for a playlist/result list marks the queue as done.
- Replace unwrap() in lyrics sorting with total_cmp (NaN-safe).
This commit is contained in:
socatlolmeow 2026-10-29 18:19:00 +01:00
commit eb6b16e70b
16 changed files with 966 additions and 171 deletions

View file

@ -6,6 +6,8 @@ Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/
### Added ### Added
- Discord rich presence. Your current song shows up in your Discord status as "Listening to ...", with cover art, album, a progress bar and a "Listen Along" button. Paused songs show `(paused)` and drop the timer. Off by default: make an application at https://discord.com/developers/applications (its name is what shows after "Listening to"), paste the Application ID into `client_id` under the new `[discord]` section of `config.toml`. If discord isnt running drift keeps retrying quietly in the background.
- `:save!` / `:w!` to overwrite an existing playlist. Plain `:save` now refuses if the name is taken and tells you to use `:save!` (replace) or `:append` (add to the end).
- 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. - 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 <query>` to search albums, `:playlist <query>` 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. - `:album <query>` to search albums, `:playlist <query>` 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. - `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.
@ -15,14 +17,24 @@ Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/
### Changed ### Changed
- Playback now downloads the audio with yt-dlp first and plays the local file instead of letting mpv stream it (`--ytdl=no`). Files live in the cache dir under `drift/audio` (last 40 are kept). The next queued song is fetched in the background while the current one plays, so track changes are quicker. Songs that are already cached start instantly.
- Playback errors show the actual reason from yt-dlp/mpv (e.g. "Sign in to confirm you're not a bot") instead of a generic "update yt-dlp" hint. Status messages can be wider too (70 -> 120 chars).
- Artists are split using the credits youtube gives us (linked artist names) instead of guessing from the text, so names like "Tyler, The Creator" stay in one piece. yt-dlp's `artists` list is used too. The text-based fallback (old saves, plain yt-dlp) knows a list of common band names with `,` / `&` in them, and matches separators case-insensitively. Affects `ga`, `gb`, `gs` and the lyrics lookup.
- Selected rows use an accent colored bold text + `▎` bar instead of a filled background.
- Tab bar went back to plain text: the active tab is accent colored, bold and underlined, inactive ones are gray with dimmed counts. (replaces the pills.)
- Tab bar only reserves space for the spinner while busy, fits narrow terminals better.
- Album tracks now get the artist/album/length filled in when youtube leaves them out. - 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. - 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. - Search/radio requests share one http connection instead of making a new one every time.
- Tab bar is pills now, inactive tabs have dimmed counts. - New dependencies: `discord-rich-presence` (everywhere) and, 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 only reserves space for the spinner while busy, fits narrow terminals better.
### Fixed ### Fixed
- Running several drifts at once no longer clobbers stuff. Only the first one restores and saves the session (a `session.lock` file, unix only), and saving playlists merges with whatever the other instance wrote instead of overwriting it, so playlists made in another window dont disappear.
- Collection picker (`:album`, `:playlist`) no longer pops up in the middle of typing and steals your keys, it waits until you're back in normal mode.
- "Play next" (song menu) didnt shift the "already played" marks, so shuffle could replay or skip the wrong songs after it. fixed.
- Starting a song from a playlist/results while a queue is playing no longer drags the old queue along: whats queued counts as done and only songs you add afterwards play after it.
- 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. - 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. - 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. - `P` / `:playall` doesnt wipe the queue anymore.
- Lyrics sorting no longer has an `unwrap()` that could panic on a NaN time drift

131
Cargo.lock generated
View file

@ -110,7 +110,7 @@ dependencies = [
"futures-lite 2.6.1", "futures-lite 2.6.1",
"parking", "parking",
"polling 3.11.0", "polling 3.11.0",
"rustix 1.1.5", "rustix 1.1.6",
"slab", "slab",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -175,7 +175,7 @@ dependencies = [
"cfg-if", "cfg-if",
"futures-core", "futures-core",
"futures-io", "futures-io",
"rustix 1.1.5", "rustix 1.1.6",
"signal-hook-registry", "signal-hook-registry",
"slab", "slab",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@ -279,9 +279,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.9.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -397,9 +397,9 @@ dependencies = [
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]
@ -521,6 +521,21 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "discord-rich-presence"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.21",
"uuid",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -544,6 +559,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs", "dirs",
"discord-rich-presence",
"libc", "libc",
"ratatui", "ratatui",
"serde", "serde",
@ -837,9 +853,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"potential_utf", "potential_utf",
@ -851,9 +867,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_locale_core" name = "icu_locale_core"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"litemap", "litemap",
@ -864,9 +880,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer" name = "icu_normalizer"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
dependencies = [ dependencies = [
"icu_collections", "icu_collections",
"icu_normalizer_data", "icu_normalizer_data",
@ -878,15 +894,15 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer_data" name = "icu_normalizer_data"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
[[package]] [[package]]
name = "icu_properties" name = "icu_properties"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_collections", "icu_collections",
@ -899,15 +915,15 @@ dependencies = [
[[package]] [[package]]
name = "icu_properties_data" name = "icu_properties_data"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
[[package]] [[package]]
name = "icu_provider" name = "icu_provider"
version = "2.3.1" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_locale_core", "icu_locale_core",
@ -1257,7 +1273,7 @@ dependencies = [
"concurrent-queue", "concurrent-queue",
"hermit-abi 0.5.3", "hermit-abi 0.5.3",
"pin-project-lite", "pin-project-lite",
"rustix 1.1.5", "rustix 1.1.6",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -1387,7 +1403,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@ -1462,9 +1478,9 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.5" version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"errno", "errno",
@ -1499,9 +1515,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.15" version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -1651,9 +1667,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.3" version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]] [[package]]
name = "socket2" name = "socket2"
@ -1678,7 +1694,7 @@ dependencies = [
"dispatch", "dispatch",
"objc", "objc",
"pollster", "pollster",
"thiserror", "thiserror 1.0.69",
"windows", "windows",
"zbus", "zbus",
"zvariant", "zvariant",
@ -1783,7 +1799,7 @@ dependencies = [
"fastrand 2.5.0", "fastrand 2.5.0",
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.5", "rustix 1.1.6",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -1793,7 +1809,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@ -1808,10 +1833,21 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tinystr" name = "thiserror-impl"
version = "0.8.4" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "tinystr"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"zerovec", "zerovec",
@ -1994,6 +2030,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -2018,14 +2063,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [ dependencies = [
"webpki-roots 1.0.10", "webpki-roots 1.0.11",
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.10" version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@ -2317,9 +2362,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@ -2464,9 +2509,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec" name = "zerovec"
version = "0.11.8" version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [ dependencies = [
"yoke", "yoke",
"zerofrom", "zerofrom",
@ -2475,9 +2520,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec-derive" name = "zerovec-derive"
version = "0.11.6" version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -16,6 +16,7 @@ toml = "0.8"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
dirs = "5" dirs = "5"
libc = "0.2" libc = "0.2"
discord-rich-presence = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }

View file

@ -1,17 +1,76 @@
#!/bin/sh #!/bin/sh
set -e set -eu
if command -v pacman >/dev/null; then install_packages() {
sudo pacman -S --needed --noconfirm mpv yt-dlp base-devel rust if [ "$(id -u)" -eq 0 ]; then
elif command -v apt >/dev/null; then "$@"
sudo apt install -y mpv yt-dlp build-essential cargo
elif command -v dnf >/dev/null; then
sudo dnf install -y mpv yt-dlp gcc cargo
elif command -v xbps-install >/dev/null; then
sudo xbps-install -Sy mpv yt-dlp base-devel cargo
else else
echo "unsupported package manager: install mpv, yt-dlp and cargo manually" >&2 sudo "$@"
fi
}
echo "-> Detecting package manager..."
if command -v pacman >/dev/null 2>&1; then
# Arch Linux, Manjaro, EndeavourOS, CachyOS
install_packages pacman -S --needed --noconfirm \
mpv yt-dlp base-devel rust
elif command -v apt-get >/dev/null 2>&1; then
# Debian, Ubuntu, Linux Mint, Pop!_OS, elementary OS
install_packages apt-get update
install_packages apt-get install -y \
mpv yt-dlp build-essential cargo
elif command -v dnf >/dev/null 2>&1; then
# Fedora, RHEL (ew), Rocky Linux, AlmaLinux
install_packages dnf install -y \
mpv yt-dlp gcc gcc-c++ make cargo
elif command -v zypper >/dev/null 2>&1; then
# openSUSE Leap, Tumbleweed, SUSE Linux Enterprise
install_packages zypper --non-interactive install \
mpv yt-dlp gcc gcc-c++ make cargo
elif command -v xbps-install >/dev/null 2>&1; then
# Void Linux
install_packages xbps-install -Sy \
mpv yt-dlp base-devel cargo
elif command -v apk >/dev/null 2>&1; then
# Alpine Linux or Chimera
install_packages apk add \
mpv yt-dlp build-base cargo
elif command -v emerge >/dev/null 2>&1; then
# Gentoo :3
install_packages emerge \
media-video/mpv net-misc/yt-dlp \
sys-devel/gcc dev-lang/rust
elif command -v zypper >/dev/null 2>&1; then
install_packages zypper install \
mpv yt-dlp gcc cargo
else
echo "Error: unsupported package manager." >&2
echo "Install mpv, yt-dlp, a C compiler, and Cargo manually." >&2
exit 1 exit 1
fi fi
echo "-> Checking required commands..."
for cmd in cargo mpv yt-dlp; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: $cmd was not found after installation." >&2
exit 1
fi
done
echo "-> Installing drift..."
cargo install --git https://github.com/socatlolmeow/drift cargo install --git https://github.com/socatlolmeow/drift
echo "-> Drift installation complete!"
echo "Run 'drift' to start the application."
echo "If the command is not found, pls check that ~/.cargo/bin is in your PATH"

View file

@ -9,9 +9,10 @@ use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Config}; use crate::config::{self, Config};
use crate::lyrics::{self, Lyrics}; use crate::lyrics::{self, Lyrics};
use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::model::{Collection, RepeatMode, Track};
use crate::discord::Discord;
use crate::mpris::{Cmd, Mpris}; use crate::mpris::{Cmd, Mpris};
use crate::player::{Player, PlayerEvent}; use crate::player::{self, Player, PlayerEvent};
use crate::session::Session; use crate::session::Session;
use crate::store::Store; use crate::store::Store;
use crate::ytmusic::{self, Kind, Page}; use crate::ytmusic::{self, Kind, Page};
@ -37,6 +38,10 @@ pub enum AppEvent {
result: Result<Vec<Collection>, String>, result: Result<Vec<Collection>, String>,
}, },
Media(Cmd), Media(Cmd),
Audio {
id: String,
result: Result<std::path::PathBuf, String>,
},
} }
const FIRST_PAGE: usize = 20; const FIRST_PAGE: usize = 20;
@ -201,6 +206,8 @@ pub struct App {
radio_played: HashSet<usize>, radio_played: HashSet<usize>,
resume_radio: bool, resume_radio: bool,
fail_streak: u8, fail_streak: u8,
want: Option<String>,
fetching: Option<String>,
result_cursor: Option<usize>, result_cursor: Option<usize>,
buffered: Vec<Track>, buffered: Vec<Track>,
next_page: Option<String>, next_page: Option<String>,
@ -210,6 +217,8 @@ pub struct App {
on_radio: bool, on_radio: bool,
similar_load: Option<u64>, similar_load: Option<u64>,
mpris: Option<Mpris>, mpris: Option<Mpris>,
discord: Option<Discord>,
owns_session: bool,
seek_step: f64, seek_step: f64,
volume_step: f64, volume_step: f64,
autoplay: bool, autoplay: bool,
@ -248,6 +257,8 @@ impl App {
let _ = t.send(AppEvent::Media(c)); let _ = t.send(AppEvent::Media(c));
}) })
}; };
let discord = Discord::new(&cfg.discord.client_id);
let owns_session = Session::claim();
let mut app = App { let mut app = App {
view: View::Results, view: View::Results,
mode: Mode::Normal, mode: Mode::Normal,
@ -295,6 +306,8 @@ impl App {
radio_played: HashSet::new(), radio_played: HashSet::new(),
resume_radio: false, resume_radio: false,
fail_streak: 0, fail_streak: 0,
want: None,
fetching: None,
result_cursor: None, result_cursor: None,
buffered: Vec::new(), buffered: Vec::new(),
next_page: None, next_page: None,
@ -304,6 +317,8 @@ impl App {
on_radio: false, on_radio: false,
similar_load: None, similar_load: None,
mpris, mpris,
discord,
owns_session,
seek_step: p.seek_step, seek_step: p.seek_step,
volume_step: p.volume_step, volume_step: p.volume_step,
autoplay: p.autoplay, autoplay: p.autoplay,
@ -321,7 +336,7 @@ impl App {
tx, tx,
rng: seed, rng: seed,
}; };
if p.restore_session { if p.restore_session && owns_session {
let s = Session::load(); let s = Session::load();
app.resume = s app.resume = s
.current .current
@ -346,9 +361,13 @@ impl App {
} }
self.player = None; self.player = None;
self.mpris = None; self.mpris = None;
self.discord = None;
} }
fn save_session(&mut self, force: bool) -> Result<()> { fn save_session(&mut self, force: bool) -> Result<()> {
if !self.owns_session {
return Ok(());
}
let session = Session { let session = Session {
queue: self.queue.clone(), queue: self.queue.clone(),
current: self.current, current: self.current,
@ -390,6 +409,11 @@ impl App {
self.load_more(); self.load_more();
self.sync_lyrics(); self.sync_lyrics();
self.sync_mpris(); self.sync_mpris();
self.sync_discord();
/* open the picker once nothing else is being typed, so keys aren't hijacked */
if self.mode == Mode::Normal && !self.picks.is_empty() {
self.mode = Mode::CollectionPick;
}
if self.tick.is_multiple_of(50) { if self.tick.is_multiple_of(50) {
match self.save_session(false) { match self.save_session(false) {
Ok(()) => self.save_failed = false, Ok(()) => self.save_failed = false,
@ -812,6 +836,11 @@ impl App {
let len = self.queue.len(); let len = self.queue.len();
let at = self.current.map_or(0, |c| (c + 1).min(len)); let at = self.current.map_or(0, |c| (c + 1).min(len));
self.queue.insert(at, t); self.queue.insert(at, t);
self.played = self
.played
.iter()
.map(|&i| if i >= at { i + 1 } else { i })
.collect();
self.info(format!("Playing next: {label}")); self.info(format!("Playing next: {label}"));
} }
@ -1103,7 +1132,8 @@ impl App {
self.repeat = m.unwrap_or_else(|| self.repeat.cycle()); self.repeat = m.unwrap_or_else(|| self.repeat.cycle());
self.info(format!("Repeat: {}", self.repeat.label())); self.info(format!("Repeat: {}", self.repeat.label()));
} }
Command::Save(name) => self.save_playlist(&name), Command::Save(name) => self.save_playlist(&name, false),
Command::SaveForce(name) => self.save_playlist(&name, true),
Command::Load(name) => match self.store.resolve(&name) { Command::Load(name) => match self.store.resolve(&name) {
Some(n) => self.load_playlist(&n, true), Some(n) => self.load_playlist(&n, true),
None => self.error(format!("no playlist named '{name}'")), None => self.error(format!("no playlist named '{name}'")),
@ -1147,7 +1177,7 @@ impl App {
let Some(track) = self.artist_target() else { let Some(track) = self.artist_target() else {
return self.error("No song selected"); return self.error("No song selected");
}; };
let names = split_artists(&track.artist); let names = track.artist_names();
if names.is_empty() || track.artist == "Unknown artist" { if names.is_empty() || track.artist == "Unknown artist" {
return self.error("No artist known for this song"); return self.error("No artist known for this song");
} }
@ -1219,7 +1249,7 @@ impl App {
tracks.extend(found.into_iter().filter(|t| { tracks.extend(found.into_iter().filter(|t| {
!known.contains(&t.id) !known.contains(&t.id)
&& (t.artist.to_lowercase() == wanted && (t.artist.to_lowercase() == wanted
|| split_artists(&t.artist) || t.artist_names()
.iter() .iter()
.any(|a| a.to_lowercase() == wanted)) .any(|a| a.to_lowercase() == wanted))
})); }));
@ -1234,7 +1264,8 @@ impl App {
let Some(album) = track.album.clone() else { let Some(album) = track.album.clone() else {
return self.error("No album known for this song"); return self.error("No album known for this song");
}; };
let artist = split_artists(&track.artist) let artist = track
.artist_names()
.into_iter() .into_iter()
.next() .next()
.unwrap_or_default(); .unwrap_or_default();
@ -1337,6 +1368,13 @@ impl App {
} }
} }
fn sync_discord(&mut self) {
if let Some(mut d) = self.discord.take() {
d.update(self.now_track(), self.play_state, self.position);
self.discord = Some(d);
}
}
fn go_similar(&mut self) { fn go_similar(&mut self) {
let Some(track) = self.artist_target() else { let Some(track) = self.artist_target() else {
return self.error("No song selected"); return self.error("No song selected");
@ -1449,7 +1487,6 @@ impl App {
self.picks = list; self.picks = list;
self.pick_sel = 0; self.pick_sel = 0;
self.pick_title = title; self.pick_title = title;
self.mode = Mode::CollectionPick;
} }
Err(e) => self.error(e), Err(e) => self.error(e),
} }
@ -1465,6 +1502,7 @@ impl App {
Err(e) => LyricsState::Failed(e), Err(e) => LyricsState::Failed(e),
}; };
} }
AppEvent::Audio { id, result } => self.on_audio(id, result),
AppEvent::Player(generation, pe) => { AppEvent::Player(generation, pe) => {
if generation == self.player_gen { if generation == self.player_gen {
self.on_player(pe); self.on_player(pe);
@ -1546,6 +1584,7 @@ impl App {
PlayerEvent::FileLoaded => { PlayerEvent::FileLoaded => {
self.play_state = PlayState::Playing; self.play_state = PlayState::Playing;
self.fail_streak = 0; self.fail_streak = 0;
self.prefetch_next();
if let Some((id, pos)) = self.resume.take() { if let Some((id, pos)) = self.resume.take() {
let ok = self.now_track().is_some_and(|t| { let ok = self.now_track().is_some_and(|t| {
t.id == id && t.duration.is_none_or(|d| pos < f64::from(d) - 1.0) t.id == id && t.duration.is_none_or(|d| pos < f64::from(d) - 1.0)
@ -1567,7 +1606,7 @@ impl App {
if self.fail_streak >= 3 { if self.fail_streak >= 3 {
self.stop_playback(); self.stop_playback();
self.error(format!( self.error(format!(
"Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" "Playback failed 3 times in a row: {msg}"
)); ));
} else { } else {
self.error(format!( self.error(format!(
@ -1586,6 +1625,60 @@ impl App {
} }
} }
fn fetch_audio(&mut self, id: &str) {
if self.fetching.as_deref() == Some(id) {
return;
}
self.fetching = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
std::thread::spawn(move || {
let result = player::fetch_audio(&id);
let _ = tx.send(AppEvent::Audio { id, result });
});
}
fn load_track(&mut self, id: &str) {
self.want = Some(id.to_string());
match player::cached_audio(id) {
Some(path) => self.on_audio(id.to_string(), Ok(path)),
None => self.fetch_audio(id),
}
}
fn on_audio(&mut self, id: String, result: Result<std::path::PathBuf, String>) {
if self.fetching.as_deref() == Some(id.as_str()) {
self.fetching = None;
}
if self.want.as_deref() != Some(id.as_str()) {
return;
}
self.want = None;
match result {
Ok(path) => {
let path = path.to_string_lossy().into_owned();
if let Err(e) = self.with_player(|p| p.load(&path)) {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
Err(msg) => self.on_player(PlayerEvent::LoadError(msg)),
}
}
fn prefetch_next(&mut self) {
if self.shuffle || self.on_radio || self.fetching.is_some() {
return;
}
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
return;
};
let id = next.id.clone();
if player::cached_audio(&id).is_none() {
self.fetch_audio(&id);
}
}
fn play_index(&mut self, i: usize) { fn play_index(&mut self, i: usize) {
self.start_track(i, false); self.start_track(i, false);
} }
@ -1606,14 +1699,8 @@ impl App {
_ => 0.0, _ => 0.0,
}; };
self.duration = track.duration.map_or(0.0, f64::from); self.duration = track.duration.map_or(0.0, f64::from);
let url = track.url(); self.info(format!("♪ {}", track.label()));
match self.with_player(|p| p.load(&url)) { self.load_track(&track.id);
Ok(()) => self.info(format!("♪ {}", track.label())),
Err(e) => {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
} }
fn play_default(&mut self) { fn play_default(&mut self) {
@ -1690,6 +1777,12 @@ impl App {
let Some(track) = self.radio.get(i).cloned() else { let Some(track) = self.radio.get(i).cloned() else {
return; return;
}; };
if !self.on_radio && self.current.is_some() {
/* leaving the queue for a list: what is queued now counts as done,
only songs added from here on play after this one */
self.current = self.queue.len().checked_sub(1);
self.played = (0..self.queue.len()).collect();
}
self.on_radio = true; self.on_radio = true;
self.resume_radio = true; self.resume_radio = true;
self.result_cursor = Some(i); self.result_cursor = Some(i);
@ -1698,14 +1791,8 @@ impl App {
self.play_state = PlayState::Loading; self.play_state = PlayState::Loading;
self.position = 0.0; self.position = 0.0;
self.duration = track.duration.map_or(0.0, f64::from); self.duration = track.duration.map_or(0.0, f64::from);
let url = track.url(); self.info(format!("♪ {}", track.label()));
match self.with_player(|p| p.load(&url)) { self.load_track(&track.id);
Ok(()) => self.info(format!("♪ {}", track.label())),
Err(e) => {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
} }
fn set_pause(&mut self, pause: bool) { fn set_pause(&mut self, pause: bool) {
@ -1736,6 +1823,7 @@ impl App {
} }
fn stop_playback(&mut self) { fn stop_playback(&mut self) {
self.want = None;
if let Some(p) = self.player.as_mut() { if let Some(p) = self.player.as_mut() {
let _ = p.stop(); let _ = p.stop();
} }
@ -2068,17 +2156,25 @@ impl App {
} }
} }
fn save_playlist(&mut self, name: &str) { fn save_playlist(&mut self, name: &str, force: bool) {
if self.queue.is_empty() { if self.queue.is_empty() {
return self.error("Queue is empty — nothing to save"); return self.error("Queue is empty — nothing to save");
} }
let key = self
.store
.resolve_exact(name)
.unwrap_or_else(|| name.to_string());
let exists = self.store.playlists.get(&key).is_some_and(|l| !l.is_empty());
if exists && !force {
return self.error(format!(
"Playlist \u{201c}{key}\u{201d} already exists. :save! {key} replaces it, :append {key} adds the queue to the end"
));
}
let n = self.queue.len(); let n = self.queue.len();
self.store self.store.playlists.insert(key.clone(), self.queue.clone());
.playlists
.insert(name.to_string(), self.queue.clone());
if self.persist() { if self.persist() {
self.info(format!( self.info(format!(
"Saved {n} tracks to playlist \u{201c}{name}\u{201d}" "Saved {n} tracks to playlist \u{201c}{key}\u{201d}"
)); ));
} }
} }
@ -2242,6 +2338,7 @@ mod tests {
id: id.into(), id: id.into(),
title: title.into(), title: title.into(),
artist: "Artist".into(), artist: "Artist".into(),
artists: Vec::new(),
album: None, album: None,
duration: Some(100), duration: Some(100),
} }

View file

@ -30,6 +30,7 @@ pub enum Command {
Shuffle, Shuffle,
Repeat(Option<RepeatMode>), Repeat(Option<RepeatMode>),
Save(String), Save(String),
SaveForce(String),
Load(String), Load(String),
Append(String), Append(String),
DeletePlaylist(String), DeletePlaylist(String),
@ -172,6 +173,7 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
other => return Err(format!(":repeat expects off|all|one, got '{other}'")), other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
}, },
"save" | "w" => Save(name_arg(arg, "save")?), "save" | "w" => Save(name_arg(arg, "save")?),
"save!" | "w!" => SaveForce(name_arg(arg, "save!")?),
"load" | "e" => Load(name_arg(arg, "load")?), "load" | "e" => Load(name_arg(arg, "load")?),
"append" => Append(name_arg(arg, "append")?), "append" => Append(name_arg(arg, "append")?),
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
@ -278,5 +280,9 @@ mod tests {
Ok(Some(Command::Save("my mix".into()))) Ok(Some(Command::Save("my mix".into())))
); );
assert!(parse("save").is_err()); assert!(parse("save").is_err());
assert_eq!(
parse("save! my mix"),
Ok(Some(Command::SaveForce("my mix".into())))
);
} }
} }

View file

@ -74,6 +74,12 @@ pub struct Artist {
pub popular_first: bool, pub popular_first: bool,
} }
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Discord {
pub client_id: String,
}
fn default_accent() -> Color { fn default_accent() -> Color {
Color::Cyan Color::Cyan
} }
@ -97,6 +103,7 @@ pub struct Config {
pub playback: Playback, pub playback: Playback,
pub artist: Artist, pub artist: Artist,
pub ui: Ui, pub ui: Ui,
pub discord: Discord,
} }
impl Config { impl Config {
@ -104,7 +111,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml")) Ok(Store::dir()?.join("config.toml"))
} }
pub fn render(p: &Playback, a: &Artist, u: &Ui) -> String { pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String {
let accent = match u.accent { let accent = match u.accent {
Color::Reset => "reset", Color::Reset => "reset",
Color::Black => "black", Color::Black => "black",
@ -150,6 +157,12 @@ popular_first = {popular}
# light-cyan, white # light-cyan, white
# Or use a hex value: \"#FF8800\" # Or use a hex value: \"#FF8800\"
accent = \"{accent}\" accent = \"{accent}\"
[discord]
# Show the current song in your Discord status. Create an application at
# https://discord.com/developers/applications (its name is shown after
# \"Listening to\") and paste its Application ID here. Empty means off.
client_id = \"{client_id}\"
", ",
volume = p.volume, volume = p.volume,
shuffle = p.shuffle, shuffle = p.shuffle,
@ -160,6 +173,7 @@ accent = \"{accent}\"
vstep = p.volume_step, vstep = p.volume_step,
popular = a.popular_first, popular = a.popular_first,
accent = accent, accent = accent,
client_id = d.client_id,
) )
} }
@ -174,7 +188,7 @@ accent = \"{accent}\"
let text = match fs::read_to_string(&path) { let text = match fs::read_to_string(&path) {
Ok(t) => t, Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default())); let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default()));
return (Config::default(), None); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
@ -253,7 +267,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s); let _ = Session::write(&s);
} }
} }
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default())); let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default()));
} }
#[cfg(test)] #[cfg(test)]
@ -262,7 +276,7 @@ mod tests {
#[test] #[test]
fn default_file_parses_back() { fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default()); let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default());
let c: Config = toml::from_str(&text).unwrap(); let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default(); let d = Playback::default();
assert_eq!(c.playback.volume, d.volume); assert_eq!(c.playback.volume, d.volume);
@ -329,7 +343,7 @@ mod tests {
// Roundtrip: render then parse // Roundtrip: render then parse
let u = Ui { accent: Color::Blue }; let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u); let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default());
let c: Config = toml::from_str(&text).unwrap(); let c: Config = toml::from_str(&text).unwrap();
assert_eq!(c.ui.accent, Color::Blue); assert_eq!(c.ui.accent, Color::Blue);
} }

171
src/discord.rs Normal file
View file

@ -0,0 +1,171 @@
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use crate::app::PlayState;
use crate::model::Track;
const RETRY: Duration = Duration::from_secs(5);
const DRIFT_TOLERANCE_MS: i64 = 2500;
enum Msg {
Show(Presence),
Clear,
}
struct Presence {
title: String,
artist: String,
album: Option<String>,
cover: String,
url: String,
paused: bool,
start: i64,
end: Option<i64>,
}
pub struct Discord {
tx: Sender<Msg>,
track: Option<String>,
state: PlayState,
start: i64,
}
impl Discord {
pub fn new(client_id: &str) -> Option<Discord> {
let id = client_id.trim();
if id.is_empty() {
return None;
}
let id = id.to_string();
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("discord".into())
.spawn(move || run(&id, rx))
.ok()?;
Some(Discord {
tx,
track: None,
state: PlayState::Stopped,
start: 0,
})
}
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
let Some(track) = track.filter(|_| state != PlayState::Stopped) else {
if self.state != PlayState::Stopped {
self.state = PlayState::Stopped;
self.track = None;
let _ = self.tx.send(Msg::Clear);
}
return;
};
let position = if position.is_finite() { position.max(0.0) } else { 0.0 };
let start = now_ms() - (position * 1000.0) as i64;
let paused = state == PlayState::Paused;
let same_track = self.track.as_deref() == Some(track.id.as_str());
let was_paused = self.state == PlayState::Paused;
let on_time = paused || (start - self.start).abs() < DRIFT_TOLERANCE_MS;
if same_track && paused == was_paused && self.state != PlayState::Stopped && on_time {
self.state = state;
return;
}
self.track = Some(track.id.clone());
self.state = state;
self.start = start;
let end = track
.duration
.map(|d| start + i64::from(d) * 1000)
.filter(|_| !paused);
let _ = self.tx.send(Msg::Show(Presence {
title: track.title.clone(),
artist: track.artist.clone(),
album: track.album.clone(),
cover: format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", track.id),
url: track.url(),
paused,
start,
end,
}));
}
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn run(client_id: &str, rx: mpsc::Receiver<Msg>) {
let mut client: Option<DiscordIpcClient> = None;
let mut pending: Option<Msg> = None;
loop {
match rx.recv_timeout(RETRY) {
Ok(msg) => pending = Some(msg),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
while let Ok(msg) = rx.try_recv() {
pending = Some(msg);
}
let Some(msg) = pending.take() else { continue };
if client.is_none() {
if matches!(msg, Msg::Clear) {
continue;
}
let mut c = DiscordIpcClient::new(client_id);
if c.connect().is_ok() {
client = Some(c);
}
}
let sent = client.as_mut().is_some_and(|c| send(c, &msg));
if !sent {
client = None;
if matches!(msg, Msg::Show(_)) {
pending = Some(msg);
}
}
}
if let Some(mut c) = client {
let _ = c.clear_activity();
let _ = c.close();
}
}
fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool {
match msg {
Msg::Clear => client.clear_activity().is_ok(),
Msg::Show(p) => {
let mut assets = Assets::new().large_image(&p.cover);
if let Some(album) = &p.album {
assets = assets.large_text(album);
}
let mut timestamps = Timestamps::new().start(p.start);
if let Some(end) = p.end {
timestamps = timestamps.end(end);
}
let artist = if p.paused {
format!("{} (paused)", p.artist)
} else {
p.artist.clone()
};
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&p.title)
.state(&artist)
.assets(assets)
.buttons(vec![Button::new("Listen Along", &p.url)]);
if !p.paused {
activity = activity.timestamps(timestamps);
}
client.set_activity(activity).is_ok()
}
}
}

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use serde_json::Value; use serde_json::Value;
use crate::model::{parse_clock, split_artists, Track}; use crate::model::{parse_clock, Track};
const SEARCH: &str = "https://lrclib.net/api/search"; const SEARCH: &str = "https://lrclib.net/api/search";
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION")); const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
@ -102,7 +102,7 @@ fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
.filter(|e| drift(e) <= MAX_DRIFT) .filter(|e| drift(e) <= MAX_DRIFT)
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l))) .filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
.collect(); .collect();
found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap()); found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
found.into_iter().next().map(|(_, _, l)| l) found.into_iter().next().map(|(_, _, l)| l)
} }
@ -122,7 +122,8 @@ fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
} }
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> { pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
let artist = split_artists(&track.artist) let artist = track
.artist_names()
.into_iter() .into_iter()
.next() .next()
.unwrap_or_default(); .unwrap_or_default();

View file

@ -1,6 +1,7 @@
mod app; mod app;
mod command; mod command;
mod config; mod config;
mod discord;
mod lyrics; mod lyrics;
mod model; mod model;
mod mpris; mod mpris;

View file

@ -5,6 +5,9 @@ pub struct Track {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub artist: String, pub artist: String,
/* individual credited artists when the source gave them to us */
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)] #[serde(default)]
pub album: Option<String>, pub album: Option<String>,
#[serde(default)] #[serde(default)]
@ -16,6 +19,16 @@ impl Track {
format!("https://music.youtube.com/watch?v={}", self.id) format!("https://music.youtube.com/watch?v={}", self.id)
} }
/* Individual artists: the structured list if we have one, else a guess
* made by splitting the display string (old saves, plain yt-dlp). */
pub fn artist_names(&self) -> Vec<String> {
if self.artists.is_empty() {
split_artists(&self.artist)
} else {
self.artists.clone()
}
}
pub fn label(&self) -> String { pub fn label(&self) -> String {
format!("{} — {}", self.title, self.artist) format!("{} — {}", self.title, self.artist)
} }
@ -29,21 +42,75 @@ pub struct Collection {
pub artist: String, pub artist: String,
} }
pub fn split_artists(credit: &str) -> Vec<String> { /* names that contain a separator and must stay in one piece */
const MARK: char = '\u{1}'; const WHOLE_NAMES: &[&str] = &[
let mut s = credit.to_string(); "Tyler, The Creator",
for sep in [ "Earth, Wind & Fire",
" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ", "Emerson, Lake & Palmer",
] { "Crosby, Stills, Nash & Young",
s = s.replace(sep, &MARK.to_string()); "Crosby, Stills & Nash",
"Simon & Garfunkel",
"Hall & Oates",
"Brooks & Dunn",
"Mumford & Sons",
"Belle & Sebastian",
"Sly & The Family Stone",
"Ike & Tina Turner",
"Bob Seger & The Silver Bullet Band",
"Hootie & the Blowfish",
"Florence & The Machine",
"Years & Years",
"Kool & The Gang",
"Gladys Knight & The Pips",
"Diana Ross & The Supremes",
"Smokey Robinson & The Miracles",
"Martha Reeves & The Vandellas",
"Huey Lewis & The News",
"Echo & the Bunnymen",
"Prince & The Revolution",
"Bruce Springsteen & The E Street Band",
"Tom Petty & The Heartbreakers",
];
/* credit separators, matched case-insensitively */
const SEPARATORS: &[&str] = &[" & ", ", ", " feat. ", " feat ", " ft. ", " × "];
fn starts_with_ci(s: &str, prefix: &str) -> bool {
s.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
} }
let mut out: Vec<String> = Vec::new();
for part in s.split(MARK) { fn push_name(out: &mut Vec<String>, raw: &str) {
let name = part.trim(); let name = raw.trim();
if !name.is_empty() && !out.iter().any(|o| o == name) { if !name.is_empty() && !out.iter().any(|o| o == name) {
out.push(name.to_string()); out.push(name.to_string());
} }
} }
/* Best-effort split of a flattened credit string. Only a fallback: prefer
* Track::artists, which comes straight from the source's own boundaries. */
pub fn split_artists(credit: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut start = 0;
let mut i = 0;
while i < credit.len() {
let rest = &credit[i..];
let at_piece_start = credit[start..i].trim().is_empty();
if at_piece_start {
if let Some(name) = WHOLE_NAMES.iter().find(|n| starts_with_ci(rest, n)) {
i += name.len();
continue;
}
}
if let Some(sep) = SEPARATORS.iter().find(|sep| starts_with_ci(rest, sep)) {
push_name(&mut out, &credit[start..i]);
i += sep.len();
start = i;
continue;
}
i += rest.chars().next().map_or(1, char::len_utf8);
}
push_name(&mut out, &credit[start..]);
out out
} }
@ -133,9 +200,45 @@ mod tests {
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
assert_eq!(split_artists("A feat. B"), ["A", "B"]); assert_eq!(split_artists("A feat. B"), ["A", "B"]);
assert_eq!(split_artists("A, A"), ["A"]); assert_eq!(split_artists("A, A"), ["A"]);
assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]);
assert_eq!(
split_artists("Tyler, The Creator & Kali Uchis"),
["Tyler, The Creator", "Kali Uchis"]
);
assert_eq!(
split_artists("Earth, Wind & Fire, Someone"),
["Earth, Wind & Fire", "Someone"]
);
assert!(split_artists(" ").is_empty()); assert!(split_artists(" ").is_empty());
} }
#[test]
fn artist_splitting_edge_cases() {
assert_eq!(split_artists("hall & oates"), ["hall & oates"]);
assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]);
assert_eq!(split_artists("A Feat. B"), ["A", "B"]);
assert_eq!(
split_artists("Drake, Hall & Oates"),
["Drake", "Hall & Oates"]
);
assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]);
}
#[test]
fn artist_names_prefers_structured_list() {
let mut t = Track {
id: "abcdefghijk".into(),
title: "T".into(),
artist: "Blood, Sweat & Tears".into(),
artists: vec!["Blood, Sweat & Tears".into()],
album: None,
duration: None,
};
assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]);
t.artists.clear();
assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]);
}
#[test] #[test]
fn ids() { fn ids() {
assert!(is_valid_id("dQw4w9WgXcQ")); assert!(is_valid_id("dQw4w9WgXcQ"));

View file

@ -140,8 +140,7 @@ impl Player {
"--really-quiet", "--really-quiet",
"--audio-display=no", "--audio-display=no",
"--force-window=no", "--force-window=no",
"--ytdl=yes", "--ytdl=no",
"--ytdl-format=bestaudio/best",
"--cache=yes", "--cache=yes",
"--demuxer-max-bytes=32MiB", "--demuxer-max-bytes=32MiB",
]) ])
@ -211,16 +210,44 @@ impl Player {
] { ] {
player.send(json!(["observe_property", id, prop]))?; player.send(json!(["observe_property", id, prop]))?;
} }
player.send(json!(["request_log_messages", "error"]))?;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut reason: Option<String> = None;
for line in BufReader::new(reader).lines() { for line in BufReader::new(reader).lines() {
let Ok(line) = line else { break }; let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<Value>(&line) else { let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue; continue;
}; };
if let Some(ev) = parse_event(&v) { match v.get("event").and_then(Value::as_str) {
on_event(ev); Some("log-message") => {
let prefix = v.get("prefix").and_then(Value::as_str).unwrap_or("");
let text = v.get("text").and_then(Value::as_str).unwrap_or("").trim();
match text.strip_prefix("ERROR: ") {
Some(t) if prefix == "ytdl_hook" => reason = Some(ytdl_reason(t)),
_ if reason.is_none() && !text.is_empty() && !text.starts_with("Failed to open") => {
reason = Some(text.lines().next().unwrap_or(text).to_string());
} }
_ => {}
}
continue;
}
Some("start-file") => reason = None,
_ => {}
}
let Some(mut ev) = parse_event(&v) else {
continue;
};
match &mut ev {
PlayerEvent::LoadError(msg) => {
if let Some(r) = reason.take() {
*msg = r;
}
}
PlayerEvent::FileLoaded => reason = None,
_ => {}
}
on_event(ev);
} }
on_event(PlayerEvent::Died); on_event(PlayerEvent::Died);
}); });
@ -241,9 +268,9 @@ impl Player {
matches!(self.child.try_wait(), Ok(None)) matches!(self.child.try_wait(), Ok(None))
} }
pub fn load(&mut self, url: &str) -> Result<()> { pub fn load(&mut self, path: &str) -> Result<()> {
self.send(json!(["set_property", "pause", false]))?; self.send(json!(["set_property", "pause", false]))?;
self.send(json!(["loadfile", url, "replace"])) self.send(json!(["loadfile", path, "replace"]))
} }
pub fn set_pause(&mut self, pause: bool) -> Result<()> { pub fn set_pause(&mut self, pause: bool) -> Result<()> {
@ -282,6 +309,82 @@ impl Drop for Player {
} }
} }
const KEEP_FILES: usize = 40;
fn audio_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("drift")
.join("audio")
}
pub fn cached_audio(id: &str) -> Option<PathBuf> {
std::fs::read_dir(audio_dir())
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
}
pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
if let Some(path) = cached_audio(id) {
return Ok(path);
}
let dir = audio_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut cmd = Command::new("yt-dlp");
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
.arg("-P")
.arg(&dir)
.args(["-o", "%(id)s.%(ext)s"])
.arg(format!("https://music.youtube.com/watch?v={id}"))
.stdin(Stdio::null())
.stdout(Stdio::null());
crate::sys::no_window(&mut cmd);
let out = cmd
.output()
.map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
if let Some(path) = cached_audio(id).filter(|_| out.status.success()) {
prune(&dir);
return Ok(path);
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(stderr
.lines()
.rev()
.find_map(|l| l.strip_prefix("ERROR: "))
.map(ytdl_reason)
.unwrap_or_else(|| "yt-dlp downloaded nothing".into()))
}
fn prune(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<_> = entries
.flatten()
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
.collect();
if files.len() > KEEP_FILES {
files.sort();
for (_, path) in &files[..files.len() - KEEP_FILES] {
let _ = std::fs::remove_file(path);
}
}
}
pub fn ytdl_reason(text: &str) -> String {
let text = text.split("; please report").next().unwrap_or(text).trim();
let text = match text.strip_prefix('[').and_then(|t| t.split_once("] ")) {
Some((_, rest)) => match rest.split_once(": ") {
Some((id, msg)) if id.len() == 11 && !id.contains(' ') => msg,
_ => rest,
},
None => text,
};
text.trim().to_string()
}
fn parse_event(v: &Value) -> Option<PlayerEvent> { fn parse_event(v: &Value) -> Option<PlayerEvent> {
match v.get("event")?.as_str()? { match v.get("event")?.as_str()? {
"property-change" => { "property-change" => {
@ -359,6 +462,19 @@ mod tests {
assert!(eof, "end-of-file not seen"); assert!(eof, "end-of-file not seen");
} }
#[test]
fn ytdl_reason_is_trimmed() {
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"),
"Sign in to confirm you're not a bot. Use --cookies."
);
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"),
"Failed to extract any player response"
);
assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo");
}
#[test] #[test]
fn event_parsing() { fn event_parsing() {
let v: Value = serde_json::from_str( let v: Value = serde_json::from_str(

View file

@ -22,6 +22,37 @@ impl Session {
Ok(dir.join("drift").join("session.json")) Ok(dir.join("drift").join("session.json"))
} }
/* The first drift to start owns the saved session. Later ones neither
restore nor overwrite it. The lock goes away when the process exits. */
#[cfg(unix)]
pub fn claim() -> bool {
use std::os::unix::io::AsRawFd;
let Ok(path) = Self::path().map(|p| p.with_file_name("session.lock")) else {
return true;
};
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
let Ok(file) = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
else {
return true;
};
let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if ok {
std::mem::forget(file);
}
ok
}
#[cfg(not(unix))]
pub fn claim() -> bool {
true
}
pub fn exists() -> bool { pub fn exists() -> bool {
Self::path().is_ok_and(|p| p.exists()) Self::path().is_ok_and(|p| p.exists())
} }
@ -75,6 +106,7 @@ mod tests {
id: id.into(), id: id.into(),
title: "T".into(), title: "T".into(),
artist: "A".into(), artist: "A".into(),
artists: Vec::new(),
album: None, album: None,
duration: Some(200), duration: Some(200),
} }

View file

@ -10,6 +10,32 @@ use crate::model::Track;
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct Store { pub struct Store {
pub playlists: BTreeMap<String, Vec<Track>>, pub playlists: BTreeMap<String, Vec<Track>>,
/* what the file looked like when we last read or wrote it */
#[serde(skip)]
base: BTreeMap<String, Vec<Track>>,
}
/* keep whatever another drift wrote, but apply the playlists this one changed */
fn merge(
base: &BTreeMap<String, Vec<Track>>,
mine: &BTreeMap<String, Vec<Track>>,
disk: BTreeMap<String, Vec<Track>>,
) -> BTreeMap<String, Vec<Track>> {
let mut out = disk;
for name in base.keys().chain(mine.keys()) {
if base.get(name) == mine.get(name) {
continue;
}
match mine.get(name) {
Some(list) => {
out.insert(name.clone(), list.clone());
}
None => {
out.remove(name);
}
}
}
out
} }
pub fn write_atomic(path: &Path, text: &str) -> Result<()> { pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
@ -84,7 +110,10 @@ impl Store {
} }
}; };
match serde_json::from_str::<Store>(&text) { match serde_json::from_str::<Store>(&text) {
Ok(s) => (s, None), Ok(mut s) => {
s.base = s.playlists.clone();
(s, None)
}
Err(e) => { Err(e) => {
let bak = path.with_extension("json.bak"); let bak = path.with_extension("json.bak");
let _ = fs::rename(&path, &bak); let _ = fs::rename(&path, &bak);
@ -99,8 +128,18 @@ impl Store {
} }
} }
pub fn save(&self) -> Result<()> { fn read_disk() -> Option<BTreeMap<String, Vec<Track>>> {
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) let text = fs::read_to_string(Self::path().ok()?).ok()?;
serde_json::from_str::<Store>(&text).ok().map(|s| s.playlists)
}
pub fn save(&mut self) -> Result<()> {
if let Some(disk) = Self::read_disk() {
self.playlists = merge(&self.base, &self.playlists, disk);
}
write_atomic(&Self::path()?, &serde_json::to_string_pretty(&*self)?)?;
self.base = self.playlists.clone();
Ok(())
} }
pub fn resolve_exact(&self, name: &str) -> Option<String> { pub fn resolve_exact(&self, name: &str) -> Option<String> {
@ -134,6 +173,33 @@ impl Store {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn merge_keeps_other_instances_changes() {
let t = |id: &str| Track {
id: id.into(),
title: "T".into(),
artist: "A".into(),
artists: Vec::new(),
album: None,
duration: None,
};
let base: BTreeMap<String, Vec<Track>> = BTreeMap::from([
("a".to_string(), vec![t("aaaaaaaaaaa")]),
("b".to_string(), vec![]),
]);
let mut mine = base.clone();
mine.insert("c".into(), vec![t("ccccccccccc")]);
mine.remove("b");
let mut disk = base.clone();
disk.insert("d".into(), vec![t("ddddddddddd")]);
disk.get_mut("a").unwrap().push(t("eeeeeeeeeee"));
let out = merge(&base, &mine, disk);
let names: Vec<&str> = out.keys().map(String::as_str).collect();
assert_eq!(names, ["a", "c", "d"]);
assert_eq!(out["a"].len(), 2);
}
#[test] #[test]
fn exact_ignores_prefixes() { fn exact_ignores_prefixes() {
let mut s = Store::default(); let mut s = Store::default();

View file

@ -10,11 +10,19 @@ use crate::model::{fmt_time, Track};
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/* Cursor row: accent-colored bold text plus a bar, no filled background. */
const CURSOR: &str = "▎";
fn selected_style(accent: Color) -> Style { fn selected_style(accent: Color) -> Style {
Style::new() Style::new().fg(accent).add_modifier(Modifier::BOLD)
.fg(Color::Black) }
.bg(accent)
.add_modifier(Modifier::BOLD) /* Swap the leading space of a menu line for the cursor bar. */
fn cursor_text(text: &str) -> String {
match text.strip_prefix(' ') {
Some(rest) => format!("{CURSOR}{rest}"),
None => text.to_string(),
}
} }
fn header_style() -> Style { fn header_style() -> Style {
@ -115,19 +123,17 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
("Queue", app.queue.len()), ("Queue", app.queue.len()),
("Playlists", app.store.playlists.len()), ("Playlists", app.store.playlists.len()),
]; ];
let pill = Style::new().fg(Color::Black).bg(app.accent); let active = Style::new()
.fg(app.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let mut spans: Vec<Span> = Vec::new(); let mut spans: Vec<Span> = Vec::new();
for (i, (title, count)) in titles.into_iter().enumerate() { for (i, (title, count)) in titles.into_iter().enumerate() {
spans.push(Span::raw(" "));
if i == app.view.idx() { if i == app.view.idx() {
spans.push(Span::styled("▐", Style::new().fg(app.accent))); spans.push(Span::styled(format!("{title} {count}"), active));
spans.push(Span::styled(format!(" {title} "), pill.bold()));
spans.push(Span::styled(format!("{count} "), pill));
spans.push(Span::styled("▌", Style::new().fg(app.accent)));
} else { } else {
spans.push(Span::raw(" ")); spans.push(Span::styled(title, Style::new().fg(Color::Gray)));
spans.push(Span::styled(format!(" {title} "), Style::new().fg(Color::Gray)));
spans.push(Span::styled(format!(" {count}"), dim())); spans.push(Span::styled(format!(" {count}"), dim()));
spans.push(Span::raw(" "));
} }
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
} }
@ -215,6 +221,7 @@ fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>, accen
.block(block) .block(block)
.column_spacing(1) .column_spacing(1)
.row_highlight_style(selected_style(accent)) .row_highlight_style(selected_style(accent))
.highlight_symbol(CURSOR)
} }
fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) {
@ -364,7 +371,8 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
) )
.header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style()))
.block(block) .block(block)
.row_highlight_style(selected_style(app.accent)); .row_highlight_style(selected_style(app.accent))
.highlight_symbol(CURSOR);
f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]);
let sel = app.selected(); let sel = app.selected();
@ -694,7 +702,7 @@ fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
Level::Error => ("✖", Color::Red), Level::Error => ("✖", Color::Red),
Level::Info => ("●", Color::Green), Level::Info => ("●", Color::Green),
}; };
let max_text = (body.width as usize).saturating_sub(8).min(70); let max_text = (body.width as usize).saturating_sub(8).min(120);
let mut text: String = s.text.lines().next().unwrap_or("").to_string(); let mut text: String = s.text.lines().next().unwrap_or("").to_string();
if text.chars().count() > max_text { if text.chars().count() > max_text {
text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…"; text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…";
@ -931,7 +939,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
let text: String = text.chars().take(width).collect(); let text: String = text.chars().take(width).collect();
lines.push(if Some(i) == sel { lines.push(if Some(i) == sel {
let pad = width.saturating_sub(text.chars().count()); let pad = width.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent))
} else { } else {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
@ -1001,7 +1009,7 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) {
} }
let text: String = text.chars().take(width).collect(); let text: String = text.chars().take(width).collect();
let pad = width.saturating_sub(text.chars().count()); let pad = width.saturating_sub(text.chars().count());
return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(accent)); return Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(accent));
} }
let danger = matches!( let danger = matches!(
it.action, it.action,
@ -1122,7 +1130,7 @@ const HELP: &[(&str, &str)] = &[
(":clear", "empty the queue"), (":clear", "empty the queue"),
( (
":save n :load n", ":save n :load n",
"save the queue as a playlist / load one", "save the queue as a playlist / load one (:save! n replaces an existing one)",
), ),
( (
":append n :delpl n", ":append n :delpl n",
@ -1218,7 +1226,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
.map(|(i, name)| { .map(|(i, name)| {
let text = format!(" {} {name}", i + 1); let text = format!(" {} {name}", i + 1);
if i == app.artist_sel { if i == app.artist_sel {
Line::styled(text, selected_style(app.accent)) Line::styled(cursor_text(&text), selected_style(app.accent))
} else { } else {
Line::raw(text) Line::raw(text)
} }
@ -1267,7 +1275,7 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
let text: String = text.chars().take(inner).collect(); let text: String = text.chars().take(inner).collect();
if i == app.pick_sel { if i == app.pick_sel {
let pad = inner.saturating_sub(text.chars().count()); let pad = inner.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent))
} else { } else {
Line::raw(text) Line::raw(text)
} }
@ -1350,6 +1358,7 @@ mod tests {
id: id.into(), id: id.into(),
title: title.into(), title: title.into(),
artist: "Someone".into(), artist: "Someone".into(),
artists: Vec::new(),
album: None, album: None,
duration: Some(90), duration: Some(90),
}; };

View file

@ -1,4 +1,5 @@
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@ -176,30 +177,7 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) {
} }
fn search_innertube(query: &str) -> Result<Page> { fn search_innertube(query: &str) -> Result<Page> {
let agent = ureq::AgentBuilder::new() let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?;
.timeout(Duration::from_secs(12))
.user_agent(UA)
.build();
let body = json!({
"context": { "client": {
"clientName": "WEB_REMIX",
"clientVersion": client_version(),
"hl": "en",
"gl": "US",
}},
"query": query,
"params": SONGS_FILTER,
});
let resp = agent
.post(ENDPOINT)
.set("Origin", "https://music.youtube.com")
.set("Referer", "https://music.youtube.com/")
.set("Cookie", "SOCS=CAI")
.send_json(body)
.map_err(|e| anyhow!("request failed: {e}"))?;
let v: Value = resp
.into_json()
.context("invalid JSON from YouTube Music")?;
Ok(page_from(&v)) Ok(page_from(&v))
} }
@ -232,18 +210,25 @@ fn find_continuation(v: &Value) -> Option<String> {
} }
} }
fn post(url: &str, mut body: Value) -> Result<Value> { /* one shared agent so paging and radio reuse the same connection */
let agent = ureq::AgentBuilder::new() fn agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12)) .timeout(Duration::from_secs(12))
.user_agent(UA) .user_agent(UA)
.build(); .build()
})
}
fn post(url: &str, mut body: Value) -> Result<Value> {
body["context"] = json!({ "client": { body["context"] = json!({ "client": {
"clientName": "WEB_REMIX", "clientName": "WEB_REMIX",
"clientVersion": client_version(), "clientVersion": client_version(),
"hl": "en", "hl": "en",
"gl": "US", "gl": "US",
}}); }});
let resp = agent let resp = agent()
.post(url) .post(url)
.set("Origin", "https://music.youtube.com") .set("Origin", "https://music.youtube.com")
.set("Referer", "https://music.youtube.com/") .set("Referer", "https://music.youtube.com/")
@ -316,13 +301,42 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
} }
} }
fn column_segments(item: &Value, col: usize) -> Vec<String> { fn column_runs(item: &Value, col: usize) -> Option<&Vec<Value>> {
run_segments(
item.get("flexColumns") item.get("flexColumns")
.and_then(|c| c.get(col)) .and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array), .and_then(Value::as_array)
) }
fn column_segments(item: &Value, col: usize) -> Vec<String> {
run_segments(column_runs(item, col))
}
/* Artists are the runs that link to an artist/channel page ("UC..." browse
* ids); the "&" and "," between them are plain runs. Using the source's own
* boundaries means names that contain a separator stay in one piece. Names
* must also appear in `credit`, so a stray linked run can't leak in. Empty
* when the response carries no links (the caller falls back to guessing). */
fn artist_names_from_runs(runs: Option<&Vec<Value>>, credit: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for run in runs.into_iter().flatten() {
let id = run
.pointer("/navigationEndpoint/browseEndpoint/browseId")
.and_then(Value::as_str);
let text = run.get("text").and_then(Value::as_str);
let (Some(id), Some(text)) = (id, text) else {
continue;
};
let text = text.trim();
if id.starts_with("UC")
&& !text.is_empty()
&& credit.contains(text)
&& !names.iter().any(|n| n == text)
{
names.push(text.to_string());
}
}
names
} }
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> { fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
@ -384,6 +398,7 @@ fn parse_item(item: &Value) -> Option<Track> {
.first() .first()
.cloned() .cloned()
.unwrap_or_else(|| "Unknown artist".into()); .unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(column_runs(item, 1), &artist);
let album = meta let album = meta
.get(1) .get(1)
.filter(|a| !a.contains(" views") && !a.contains(" plays")) .filter(|a| !a.contains(" views") && !a.contains(" plays"))
@ -393,6 +408,7 @@ fn parse_item(item: &Value) -> Option<Track> {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album, album,
duration, duration,
}) })
@ -497,11 +513,13 @@ fn parse_panel_item(item: &Value) -> Option<Track> {
if title.is_empty() { if title.is_empty() {
return None; return None;
} }
let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array)); let byline = item.pointer("/longBylineText/runs").and_then(Value::as_array);
let meta = run_segments(byline);
let artist = meta let artist = meta
.first() .first()
.cloned() .cloned()
.unwrap_or_else(|| "Unknown artist".into()); .unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(byline, &artist);
let album = meta let album = meta
.get(1) .get(1)
.filter(|a| { .filter(|a| {
@ -520,6 +538,7 @@ fn parse_panel_item(item: &Value) -> Option<Track> {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album, album,
duration, duration,
}) })
@ -585,11 +604,24 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
.unwrap_or("Unknown artist") .unwrap_or("Unknown artist")
.trim_end_matches(" - Topic") .trim_end_matches(" - Topic")
.to_string(); .to_string();
/* newer yt-dlp lists each credited artist separately */
let artists: Vec<String> = v
.get("artists")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32); let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
Some(Track { Some(Track {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album: None, album: None,
duration, duration,
}) })
@ -648,6 +680,36 @@ mod tests {
assert_eq!(t.duration, Some(241)); assert_eq!(t.duration, Some(241));
} }
#[test]
fn keeps_linked_artist_runs_whole() {
let link = |text: &str, id: &str| {
json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}})
};
let resp = json!([{"musicResponsiveListItemRenderer": {
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "},
link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "},
link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"}
]}}}
]
}}]);
let t = &parse_search_response(&resp)[0];
assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis");
assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]);
assert_eq!(t.album.as_deref(), Some("Album"));
}
#[test]
fn reads_ytdlp_artists_list() {
let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#;
let t = parse_ytdlp_lines(line);
assert_eq!(t[0].artists, ["A", "B"]);
assert_eq!(t[0].artist, "A, B");
}
#[test] #[test]
fn skips_items_without_video_id() { fn skips_items_without_video_id() {
let resp = json!([{"musicResponsiveListItemRenderer": { let resp = json!([{"musicResponsiveListItemRenderer": {