From eb6b16e70b6f93b8ee040a04fc7e8a8742f1e757 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Thu, 29 Oct 2026 18:19:00 +0100 Subject: [PATCH] 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). --- CHANGELOG.md | 18 +++++- Cargo.lock | 131 ++++++++++++++++++++++++------------- Cargo.toml | 1 + install.sh | 79 ++++++++++++++++++++--- src/app.rs | 157 ++++++++++++++++++++++++++++++++++++--------- src/command.rs | 6 ++ src/config.rs | 24 +++++-- src/discord.rs | 171 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lyrics.rs | 7 +- src/main.rs | 1 + src/model.rs | 125 ++++++++++++++++++++++++++++++++---- src/player.rs | 128 ++++++++++++++++++++++++++++++++++-- src/session.rs | 32 +++++++++ src/store.rs | 72 ++++++++++++++++++++- src/ui.rs | 51 +++++++++------ src/ytmusic.rs | 134 +++++++++++++++++++++++++++----------- 16 files changed, 966 insertions(+), 171 deletions(-) create mode 100644 src/discord.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c2f00..eedaa5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/ ### 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. - `: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. @@ -15,14 +17,24 @@ Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/ ### 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. - 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. +- Search/radio requests share one http connection instead of making a new one every time. +- 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. ### 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. - 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. +- Lyrics sorting no longer has an `unwrap()` that could panic on a NaN time drift diff --git a/Cargo.lock b/Cargo.lock index 412aff4..1262e1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,7 +110,7 @@ dependencies = [ "futures-lite 2.6.1", "parking", "polling 3.11.0", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -175,7 +175,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -279,9 +279,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -397,9 +397,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -521,6 +521,21 @@ dependencies = [ "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]] name = "dispatch" version = "0.2.0" @@ -544,6 +559,7 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs", + "discord-rich-presence", "libc", "ratatui", "serde", @@ -837,9 +853,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -851,9 +867,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -864,9 +880,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -878,15 +894,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -899,15 +915,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -1257,7 +1273,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.3", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -1387,7 +1403,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1462,9 +1478,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -1499,9 +1515,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1651,9 +1667,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "socket2" @@ -1678,7 +1694,7 @@ dependencies = [ "dispatch", "objc", "pollster", - "thiserror", + "thiserror 1.0.69", "windows", "zbus", "zvariant", @@ -1783,7 +1799,7 @@ dependencies = [ "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -1793,7 +1809,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 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]] @@ -1808,10 +1833,21 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.4" +name = "thiserror-impl" +version = "2.0.21" 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 = [ "displaydoc", "zerovec", @@ -1994,6 +2030,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "version_check" version = "0.9.5" @@ -2018,14 +2063,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -2317,9 +2362,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2464,9 +2509,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -2475,9 +2520,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 971a155..185e096 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ toml = "0.8" ureq = { version = "2", features = ["json"] } dirs = "5" libc = "0.2" +discord-rich-presence = "1.1.0" [target.'cfg(target_os = "linux")'.dependencies] souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } diff --git a/install.sh b/install.sh index b9614ba..9c038b2 100644 --- a/install.sh +++ b/install.sh @@ -1,17 +1,76 @@ #!/bin/sh -set -e +set -eu + +install_packages() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + 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 -if command -v pacman >/dev/null; then - sudo pacman -S --needed --noconfirm mpv yt-dlp base-devel rust -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 - echo "unsupported package manager: install mpv, yt-dlp and cargo manually" >&2 + echo "Error: unsupported package manager." >&2 + echo "Install mpv, yt-dlp, a C compiler, and Cargo manually." >&2 exit 1 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 + +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" diff --git a/src/app.rs b/src/app.rs index 2dae383..952b95f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,9 +9,10 @@ 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, Collection, RepeatMode, Track}; +use crate::model::{Collection, RepeatMode, Track}; +use crate::discord::Discord; use crate::mpris::{Cmd, Mpris}; -use crate::player::{Player, PlayerEvent}; +use crate::player::{self, Player, PlayerEvent}; use crate::session::Session; use crate::store::Store; use crate::ytmusic::{self, Kind, Page}; @@ -37,6 +38,10 @@ pub enum AppEvent { result: Result, String>, }, Media(Cmd), + Audio { + id: String, + result: Result, + }, } const FIRST_PAGE: usize = 20; @@ -201,6 +206,8 @@ pub struct App { radio_played: HashSet, resume_radio: bool, fail_streak: u8, + want: Option, + fetching: Option, result_cursor: Option, buffered: Vec, next_page: Option, @@ -210,6 +217,8 @@ pub struct App { on_radio: bool, similar_load: Option, mpris: Option, + discord: Option, + owns_session: bool, seek_step: f64, volume_step: f64, autoplay: bool, @@ -248,6 +257,8 @@ impl App { let _ = t.send(AppEvent::Media(c)); }) }; + let discord = Discord::new(&cfg.discord.client_id); + let owns_session = Session::claim(); let mut app = App { view: View::Results, mode: Mode::Normal, @@ -295,6 +306,8 @@ impl App { radio_played: HashSet::new(), resume_radio: false, fail_streak: 0, + want: None, + fetching: None, result_cursor: None, buffered: Vec::new(), next_page: None, @@ -304,6 +317,8 @@ impl App { on_radio: false, similar_load: None, mpris, + discord, + owns_session, seek_step: p.seek_step, volume_step: p.volume_step, autoplay: p.autoplay, @@ -321,7 +336,7 @@ impl App { tx, rng: seed, }; - if p.restore_session { + if p.restore_session && owns_session { let s = Session::load(); app.resume = s .current @@ -346,9 +361,13 @@ impl App { } self.player = None; self.mpris = None; + self.discord = None; } fn save_session(&mut self, force: bool) -> Result<()> { + if !self.owns_session { + return Ok(()); + } let session = Session { queue: self.queue.clone(), current: self.current, @@ -390,6 +409,11 @@ impl App { self.load_more(); self.sync_lyrics(); 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) { match self.save_session(false) { Ok(()) => self.save_failed = false, @@ -812,6 +836,11 @@ impl App { let len = self.queue.len(); let at = self.current.map_or(0, |c| (c + 1).min(len)); 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}")); } @@ -1103,7 +1132,8 @@ impl App { self.repeat = m.unwrap_or_else(|| self.repeat.cycle()); 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) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1147,7 +1177,7 @@ impl App { let Some(track) = self.artist_target() else { 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" { return self.error("No artist known for this song"); } @@ -1219,7 +1249,7 @@ impl App { tracks.extend(found.into_iter().filter(|t| { !known.contains(&t.id) && (t.artist.to_lowercase() == wanted - || split_artists(&t.artist) + || t.artist_names() .iter() .any(|a| a.to_lowercase() == wanted)) })); @@ -1234,7 +1264,8 @@ impl App { let Some(album) = track.album.clone() else { return self.error("No album known for this song"); }; - let artist = split_artists(&track.artist) + let artist = track + .artist_names() .into_iter() .next() .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) { let Some(track) = self.artist_target() else { return self.error("No song selected"); @@ -1449,7 +1487,6 @@ impl App { self.picks = list; self.pick_sel = 0; self.pick_title = title; - self.mode = Mode::CollectionPick; } Err(e) => self.error(e), } @@ -1465,6 +1502,7 @@ impl App { Err(e) => LyricsState::Failed(e), }; } + AppEvent::Audio { id, result } => self.on_audio(id, result), AppEvent::Player(generation, pe) => { if generation == self.player_gen { self.on_player(pe); @@ -1546,6 +1584,7 @@ impl App { PlayerEvent::FileLoaded => { self.play_state = PlayState::Playing; self.fail_streak = 0; + self.prefetch_next(); if let Some((id, pos)) = self.resume.take() { let ok = self.now_track().is_some_and(|t| { 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 { self.stop_playback(); 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 { 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) { + 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) { self.start_track(i, false); } @@ -1606,14 +1699,8 @@ impl App { _ => 0.0, }; self.duration = track.duration.map_or(0.0, f64::from); - let url = track.url(); - match self.with_player(|p| p.load(&url)) { - Ok(()) => self.info(format!("♪ {}", track.label())), - Err(e) => { - self.play_state = PlayState::Stopped; - self.error(format!("Playback failed: {e:#}")); - } - } + self.info(format!("♪ {}", track.label())); + self.load_track(&track.id); } fn play_default(&mut self) { @@ -1690,6 +1777,12 @@ impl App { let Some(track) = self.radio.get(i).cloned() else { 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.resume_radio = true; self.result_cursor = Some(i); @@ -1698,14 +1791,8 @@ impl App { self.play_state = PlayState::Loading; self.position = 0.0; self.duration = track.duration.map_or(0.0, f64::from); - let url = track.url(); - match self.with_player(|p| p.load(&url)) { - Ok(()) => self.info(format!("♪ {}", track.label())), - Err(e) => { - self.play_state = PlayState::Stopped; - self.error(format!("Playback failed: {e:#}")); - } - } + self.info(format!("♪ {}", track.label())); + self.load_track(&track.id); } fn set_pause(&mut self, pause: bool) { @@ -1736,6 +1823,7 @@ impl App { } fn stop_playback(&mut self) { + self.want = None; if let Some(p) = self.player.as_mut() { 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() { 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(); - self.store - .playlists - .insert(name.to_string(), self.queue.clone()); + self.store.playlists.insert(key.clone(), self.queue.clone()); if self.persist() { 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(), title: title.into(), artist: "Artist".into(), + artists: Vec::new(), album: None, duration: Some(100), } diff --git a/src/command.rs b/src/command.rs index 815e1fc..ccb116d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -30,6 +30,7 @@ pub enum Command { Shuffle, Repeat(Option), Save(String), + SaveForce(String), Load(String), Append(String), DeletePlaylist(String), @@ -172,6 +173,7 @@ pub fn parse(line: &str) -> Result, String> { other => return Err(format!(":repeat expects off|all|one, got '{other}'")), }, "save" | "w" => Save(name_arg(arg, "save")?), + "save!" | "w!" => SaveForce(name_arg(arg, "save!")?), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -278,5 +280,9 @@ mod tests { Ok(Some(Command::Save("my mix".into()))) ); assert!(parse("save").is_err()); + assert_eq!( + parse("save! my mix"), + Ok(Some(Command::SaveForce("my mix".into()))) + ); } } diff --git a/src/config.rs b/src/config.rs index 296d890..07e67a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,6 +74,12 @@ pub struct Artist { pub popular_first: bool, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Discord { + pub client_id: String, +} + fn default_accent() -> Color { Color::Cyan } @@ -97,6 +103,7 @@ pub struct Config { pub playback: Playback, pub artist: Artist, pub ui: Ui, + pub discord: Discord, } impl Config { @@ -104,7 +111,7 @@ impl Config { 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 { Color::Reset => "reset", Color::Black => "black", @@ -150,6 +157,12 @@ popular_first = {popular} # light-cyan, white # Or use a hex value: \"#FF8800\" 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, shuffle = p.shuffle, @@ -160,6 +173,7 @@ accent = \"{accent}\" vstep = p.volume_step, popular = a.popular_first, accent = accent, + client_id = d.client_id, ) } @@ -174,7 +188,7 @@ accent = \"{accent}\" let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default())); return (Config::default(), None); } Err(e) => { @@ -253,7 +267,7 @@ pub fn migrate_legacy() { 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)] @@ -262,7 +276,7 @@ mod tests { #[test] 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 d = Playback::default(); assert_eq!(c.playback.volume, d.volume); @@ -329,7 +343,7 @@ mod tests { // Roundtrip: render then parse 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(); assert_eq!(c.ui.accent, Color::Blue); } diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..367349a --- /dev/null +++ b/src/discord.rs @@ -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, + cover: String, + url: String, + paused: bool, + start: i64, + end: Option, +} + +pub struct Discord { + tx: Sender, + track: Option, + state: PlayState, + start: i64, +} + +impl Discord { + pub fn new(client_id: &str) -> Option { + 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) { + let mut client: Option = None; + let mut pending: Option = 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() + } + } +} diff --git a/src/lyrics.rs b/src/lyrics.rs index 9b29d69..373c8fa 100644 --- a/src/lyrics.rs +++ b/src/lyrics.rs @@ -2,7 +2,7 @@ use std::time::Duration; 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 UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION")); @@ -102,7 +102,7 @@ fn pick(entries: &[Value], duration: Option) -> Option { .filter(|e| drift(e) <= MAX_DRIFT) .filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l))) .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) } @@ -122,7 +122,8 @@ fn query(params: &[(&str, &str)]) -> Result, String> { } pub fn fetch(track: &Track) -> Result, String> { - let artist = split_artists(&track.artist) + let artist = track + .artist_names() .into_iter() .next() .unwrap_or_default(); diff --git a/src/main.rs b/src/main.rs index 5a44a69..c3634f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod command; mod config; +mod discord; mod lyrics; mod model; mod mpris; diff --git a/src/model.rs b/src/model.rs index 0f7a156..aef6d38 100644 --- a/src/model.rs +++ b/src/model.rs @@ -5,6 +5,9 @@ pub struct Track { pub id: String, pub title: String, pub artist: String, + /* individual credited artists when the source gave them to us */ + #[serde(default)] + pub artists: Vec, #[serde(default)] pub album: Option, #[serde(default)] @@ -16,6 +19,16 @@ impl Track { 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 { + if self.artists.is_empty() { + split_artists(&self.artist) + } else { + self.artists.clone() + } + } + pub fn label(&self) -> String { format!("{} — {}", self.title, self.artist) } @@ -29,21 +42,75 @@ pub struct Collection { pub artist: String, } +/* names that contain a separator and must stay in one piece */ +const WHOLE_NAMES: &[&str] = &[ + "Tyler, The Creator", + "Earth, Wind & Fire", + "Emerson, Lake & Palmer", + "Crosby, Stills, Nash & Young", + "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)) +} + +fn push_name(out: &mut Vec, raw: &str) { + let name = raw.trim(); + if !name.is_empty() && !out.iter().any(|o| o == name) { + 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 { - const MARK: char = '\u{1}'; - let mut s = credit.to_string(); - for sep in [ - " & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ", - ] { - s = s.replace(sep, &MARK.to_string()); - } let mut out: Vec = Vec::new(); - for part in s.split(MARK) { - let name = part.trim(); - if !name.is_empty() && !out.iter().any(|o| o == name) { - out.push(name.to_string()); + 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 } @@ -133,9 +200,45 @@ mod tests { assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); assert_eq!(split_artists("A feat. B"), ["A", "B"]); assert_eq!(split_artists("A, A"), ["A"]); + assert_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()); } + #[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] fn ids() { assert!(is_valid_id("dQw4w9WgXcQ")); diff --git a/src/player.rs b/src/player.rs index 8229dbd..8b41a85 100644 --- a/src/player.rs +++ b/src/player.rs @@ -140,8 +140,7 @@ impl Player { "--really-quiet", "--audio-display=no", "--force-window=no", - "--ytdl=yes", - "--ytdl-format=bestaudio/best", + "--ytdl=no", "--cache=yes", "--demuxer-max-bytes=32MiB", ]) @@ -211,16 +210,44 @@ impl Player { ] { player.send(json!(["observe_property", id, prop]))?; } + player.send(json!(["request_log_messages", "error"]))?; std::thread::spawn(move || { + let mut reason: Option = None; for line in BufReader::new(reader).lines() { let Ok(line) = line else { break }; let Ok(v) = serde_json::from_str::(&line) else { continue; }; - if let Some(ev) = parse_event(&v) { - on_event(ev); + match v.get("event").and_then(Value::as_str) { + 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); }); @@ -241,9 +268,9 @@ impl Player { 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!(["loadfile", url, "replace"])) + self.send(json!(["loadfile", path, "replace"])) } 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 { + 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 { + 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 { match v.get("event")?.as_str()? { "property-change" => { @@ -359,6 +462,19 @@ mod tests { 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] fn event_parsing() { let v: Value = serde_json::from_str( diff --git a/src/session.rs b/src/session.rs index 45dc9a1..599e620 100644 --- a/src/session.rs +++ b/src/session.rs @@ -22,6 +22,37 @@ impl Session { 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 { Self::path().is_ok_and(|p| p.exists()) } @@ -75,6 +106,7 @@ mod tests { id: id.into(), title: "T".into(), artist: "A".into(), + artists: Vec::new(), album: None, duration: Some(200), } diff --git a/src/store.rs b/src/store.rs index ee05cde..6216d3d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -10,6 +10,32 @@ use crate::model::Track; #[derive(Debug, Default, Serialize, Deserialize)] pub struct Store { pub playlists: BTreeMap>, + /* what the file looked like when we last read or wrote it */ + #[serde(skip)] + base: BTreeMap>, +} + +/* keep whatever another drift wrote, but apply the playlists this one changed */ +fn merge( + base: &BTreeMap>, + mine: &BTreeMap>, + disk: BTreeMap>, +) -> BTreeMap> { + 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<()> { @@ -84,7 +110,10 @@ impl Store { } }; match serde_json::from_str::(&text) { - Ok(s) => (s, None), + Ok(mut s) => { + s.base = s.playlists.clone(); + (s, None) + } Err(e) => { let bak = path.with_extension("json.bak"); let _ = fs::rename(&path, &bak); @@ -99,8 +128,18 @@ impl Store { } } - pub fn save(&self) -> Result<()> { - write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) + fn read_disk() -> Option>> { + let text = fs::read_to_string(Self::path().ok()?).ok()?; + serde_json::from_str::(&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 { @@ -134,6 +173,33 @@ impl Store { mod tests { 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> = 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] fn exact_ignores_prefixes() { let mut s = Store::default(); diff --git a/src/ui.rs b/src/ui.rs index a4321db..6894886 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,11 +10,19 @@ use crate::model::{fmt_time, Track}; 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 { - Style::new() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD) + Style::new().fg(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 { @@ -115,21 +123,19 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { ("Queue", app.queue.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 = Vec::new(); for (i, (title, count)) in titles.into_iter().enumerate() { + spans.push(Span::raw(" ")); if i == app.view.idx() { - spans.push(Span::styled("▐", Style::new().fg(app.accent))); - spans.push(Span::styled(format!(" {title} "), pill.bold())); - spans.push(Span::styled(format!("{count} "), pill)); - spans.push(Span::styled("▌", Style::new().fg(app.accent))); + spans.push(Span::styled(format!("{title} {count}"), active)); } else { - spans.push(Span::raw(" ")); - spans.push(Span::styled(format!(" {title} "), Style::new().fg(Color::Gray))); - spans.push(Span::styled(format!("{count} "), dim())); - spans.push(Span::raw(" ")); + spans.push(Span::styled(title, Style::new().fg(Color::Gray))); + spans.push(Span::styled(format!(" {count}"), dim())); } - spans.push(Span::raw(" ")); + spans.push(Span::raw(" ")); } f.render_widget(Paragraph::new(Line::from(spans)), tabs); @@ -215,6 +221,7 @@ fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>, accen .block(block) .column_spacing(1) .row_highlight_style(selected_style(accent)) + .highlight_symbol(CURSOR) } 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())) .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()]); let sel = app.selected(); @@ -694,7 +702,7 @@ fn draw_toast(f: &mut Frame, app: &App, body: Rect) { Level::Error => ("✖", Color::Red), 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(); if text.chars().count() > max_text { text = text.chars().take(max_text.saturating_sub(1)).collect::() + "…"; @@ -931,7 +939,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) { let text: String = text.chars().take(width).collect(); lines.push(if Some(i) == sel { 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 { Line::from(vec![ 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 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!( it.action, @@ -1122,7 +1130,7 @@ const HELP: &[(&str, &str)] = &[ (":clear", "empty the queue"), ( ":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", @@ -1218,7 +1226,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { .map(|(i, name)| { let text = format!(" {} {name}", i + 1); if i == app.artist_sel { - Line::styled(text, selected_style(app.accent)) + Line::styled(cursor_text(&text), selected_style(app.accent)) } else { 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(); if i == app.pick_sel { 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 { Line::raw(text) } @@ -1350,6 +1358,7 @@ mod tests { id: id.into(), title: title.into(), artist: "Someone".into(), + artists: Vec::new(), album: None, duration: Some(90), }; diff --git a/src/ytmusic.rs b/src/ytmusic.rs index 97dd7f1..f356c95 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -1,4 +1,5 @@ use std::process::{Command, Stdio}; +use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; 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 { - let agent = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(12)) - .user_agent(UA) - .build(); - let body = json!({ - "context": { "client": { - "clientName": "WEB_REMIX", - "clientVersion": client_version(), - "hl": "en", - "gl": "US", - }}, - "query": query, - "params": SONGS_FILTER, - }); - let resp = agent - .post(ENDPOINT) - .set("Origin", "https://music.youtube.com") - .set("Referer", "https://music.youtube.com/") - .set("Cookie", "SOCS=CAI") - .send_json(body) - .map_err(|e| anyhow!("request failed: {e}"))?; - let v: Value = resp - .into_json() - .context("invalid JSON from YouTube Music")?; + let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?; Ok(page_from(&v)) } @@ -232,18 +210,25 @@ fn find_continuation(v: &Value) -> Option { } } +/* one shared agent so paging and radio reuse the same connection */ +fn agent() -> &'static ureq::Agent { + static AGENT: OnceLock = OnceLock::new(); + AGENT.get_or_init(|| { + ureq::AgentBuilder::new() + .timeout(Duration::from_secs(12)) + .user_agent(UA) + .build() + }) +} + fn post(url: &str, mut body: Value) -> Result { - let agent = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(12)) - .user_agent(UA) - .build(); body["context"] = json!({ "client": { "clientName": "WEB_REMIX", "clientVersion": client_version(), "hl": "en", "gl": "US", }}); - let resp = agent + let resp = agent() .post(url) .set("Origin", "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_runs(item: &Value, col: usize) -> Option<&Vec> { + item.get("flexColumns") + .and_then(|c| c.get(col)) + .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) + .and_then(Value::as_array) +} + fn column_segments(item: &Value, col: usize) -> Vec { - run_segments( - item.get("flexColumns") - .and_then(|c| c.get(col)) - .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) - .and_then(Value::as_array), - ) + 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>, credit: &str) -> Vec { + let mut names: Vec = 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>) -> Vec { @@ -384,6 +398,7 @@ fn parse_item(item: &Value) -> Option { .first() .cloned() .unwrap_or_else(|| "Unknown artist".into()); + let artists = artist_names_from_runs(column_runs(item, 1), &artist); let album = meta .get(1) .filter(|a| !a.contains(" views") && !a.contains(" plays")) @@ -393,6 +408,7 @@ fn parse_item(item: &Value) -> Option { id: id.to_string(), title, artist, + artists, album, duration, }) @@ -497,11 +513,13 @@ fn parse_panel_item(item: &Value) -> Option { if title.is_empty() { 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 .first() .cloned() .unwrap_or_else(|| "Unknown artist".into()); + let artists = artist_names_from_runs(byline, &artist); let album = meta .get(1) .filter(|a| { @@ -520,6 +538,7 @@ fn parse_panel_item(item: &Value) -> Option { id: id.to_string(), title, artist, + artists, album, duration, }) @@ -585,11 +604,24 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec { .unwrap_or("Unknown artist") .trim_end_matches(" - Topic") .to_string(); + /* newer yt-dlp lists each credited artist separately */ + let artists: Vec = 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); Some(Track { id: id.to_string(), title, artist, + artists, album: None, duration, }) @@ -648,6 +680,36 @@ mod tests { 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] fn skips_items_without_video_id() { let resp = json!([{"musicResponsiveListItemRenderer": {