diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Rust - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index eedaa5f..32c2f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,6 @@ 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. @@ -17,24 +15,14 @@ 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. -- 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. +- New dependency on linux: `souvlaki` (zbus backend, pure rust, no system libs). `Cargo.lock` needs a regen, just run `cargo build` once. `--locked` builds, the flake and the AUR package need the new lock. +- Tab bar is pills now, inactive tabs have dimmed counts. +- Tab bar only reserves space for the spinner while busy, fits narrow terminals better. ### Fixed -- 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 1262e1a..412aff4 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.6", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -175,7 +175,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.6", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -279,9 +279,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.10.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "shlex", @@ -397,9 +397,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -521,21 +521,6 @@ 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" @@ -559,7 +544,6 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs", - "discord-rich-presence", "libc", "ratatui", "serde", @@ -853,9 +837,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -867,9 +851,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -880,9 +864,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -894,15 +878,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -915,15 +899,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1273,7 +1257,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.3", "pin-project-lite", - "rustix 1.1.6", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -1403,7 +1387,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1478,9 +1462,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.6" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", @@ -1515,9 +1499,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1667,9 +1651,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.4" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -1694,7 +1678,7 @@ dependencies = [ "dispatch", "objc", "pollster", - "thiserror 1.0.69", + "thiserror", "windows", "zbus", "zvariant", @@ -1799,7 +1783,7 @@ dependencies = [ "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", - "rustix 1.1.6", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -1809,16 +1793,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "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", + "thiserror-impl", ] [[package]] @@ -1832,22 +1807,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "tinystr" -version = "0.8.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2030,15 +1994,6 @@ 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" @@ -2063,14 +2018,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.11", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -2362,9 +2317,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2509,9 +2464,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.9" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2520,9 +2475,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.7" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 22d50f9..971a155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "drift" version = "0.1.0" edition = "2021" description = "A Vim-style terminal music player for YouTube Music (no login required)" -license = "GPL-3.0-or-later" +license = "MIT" [workspace] @@ -16,7 +16,6 @@ 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/README.md b/README.md index f5e0613..d847798 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,41 @@
-# Drift +

Drift

+ +[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) +![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square) +![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) ### A YouTube Music player for your terminal, with Vim keybinds. Search, queue and play music with no account, no login and no browser. -[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) -[![Build](https://img.shields.io/github/actions/workflow/status/socatlolmeow/drift/rust.yml?branch=main&style=flat-square&label=build)](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml) -[![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square)](LICENSE) -![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) - -Drift showcase -
--- -## Table of contents - -- [Features](#features) -- [Installation](#installation) - - [Arch Linux (AUR)](#arch-linux-aur) - - [Install script](#install-script) - - [From source](#from-source) - - [Nix](#nix) - - [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually) -- [Usage](#usage) -- [Keybinds](#keybinds) -- [Commands](#commands) -- [Lyrics](#lyrics) -- [Configuration](#configuration) - - [Streaming or downloading](#streaming-or-downloading) - - [Discord rich presence](#discord-rich-presence) - - [Custom keys](#custom-keys) - - [Environment variables](#environment-variables) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) -- [License](#license) +
+ Drift showcase +
--- ## Features -- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line -- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung -- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists -- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info -- A command palette with completion and history -- Your queue and position are restored the next time you start Drift -- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence -- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds +- **No login** - search and stream straight from YouTube Music +- **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line +- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung +- **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL +- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info +- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history +- **Artist browsing** - press `ga` to see an artist's songs +- **Audio visualizer** - a live visualizer while music plays +- **Session restore** - your queue and playback position come back on the next launch +- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file ## Installation -Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) to get the audio streams. Run `drift --check` at any time to see if both are installed. +Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream extraction. Run `drift --check` at any time to verify both are installed. ### Arch Linux (AUR) @@ -63,7 +43,7 @@ Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/ yay -S driftmusic-git ``` -Any AUR helper works. You can also build it by hand: +Any AUR helper works, or build it by hand: ```bash git clone https://aur.archlinux.org/driftmusic-git.git @@ -71,11 +51,11 @@ cd driftmusic-git makepkg -si ``` -`mpv` and `yt-dlp` are pulled in automatically. +`mpv` and `yt-dlp` are installed automatically. ### Install script -Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo. +On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo: ```bash git clone https://github.com/socatlolmeow/drift.git @@ -95,15 +75,8 @@ cargo install --path . This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. -### Nix - -The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you: - -```bash -nix run github:socatlolmeow/drift -``` - -### Installing mpv and yt-dlp manually +
+Installing mpv and yt-dlp manually | Platform | Command | | --- | --- | @@ -112,22 +85,20 @@ nix run github:socatlolmeow/drift | Arch | `sudo pacman -S mpv yt-dlp` | | Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` | +
+ ## Usage ```bash drift # start the player drift "daft punk" # start with a search -drift --check # check that mpv and yt-dlp are installed -drift --help -drift --version +drift --check # verify mpv and yt-dlp are installed ``` -Press `/` to search, `Enter` to play, `a` to add a song to the queue and `?` for the full list of keybinds. Inside the player, `:help` lists the commands. +Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of keybinds. ## Keybinds -These are the defaults. You can change them, see [Custom keys](#custom-keys). - | Key | Action | | --- | --- | | `j` `k` / `↓` `↑` | move (counts work: `5j`) | @@ -145,48 +116,30 @@ These are the defaults. You can change them, see [Custom keys](#custom-keys). | `J` `K` | move a queue or playlist item down / up | | `G` | song menu | | `ga` | go to the artist | -| `gs` | see similar songs | -| `gb` | go to the album | -| `gB` | save the album as a playlist | | `L` | toggle the lyrics panel | | `F` | fullscreen lyrics | | `/` `:` | search / command line | | `?` | help | | `q` | quit | -## Commands - -Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on). +### Commands | Command | Action | | --- | --- | | `:search "query"` | search YouTube Music | | `:play [N]` | play or resume, or play row `N` | -| `:pause` `:resume` `:toggle` `:stop` | control playback | -| `:next` `:prev` | next / previous track | | `:seek 90` `1:30` `+10` | absolute or relative seek | | `:vol 50` `+5` | set or change the volume | | `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:add [N]` `:addall` `:playall` | queue results | -| `:remove [N]` `:clear` | remove from the queue / clear it | | `:save name` `:load name` `:append name` | work with playlists | -| `:save! name` | save and overwrite an existing playlist | -| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | -| `:album [query]` | open the selected song's album, or search albums | -| `:playlist query` | search YouTube Music playlists | -| `:similar` | songs similar to the selected one (also `:radio`) | -| `:artist` | songs by the selected song's artist | -| `:results` `:queue` `:playlists` | switch view | | `:lyrics` | toggle the lyrics panel | -| `:quit` | quit | - -`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it. ## Lyrics -Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text that you can scroll with `j`/`k`. +Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text you can scroll with `j`/`k`. Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open. @@ -209,113 +162,17 @@ popular_first = false [ui] accent = "cyan" # a color name or a hex value like "#FF8800" - -[discord] -client_id = "" # set this to enable Discord rich presence - -[audio] -download = false # false: stream songs; true: download to the cache first -download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit) -cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback - -[keys] -quit = "Q" -move_down = ["j", "down", "ctrl+n"] -move_up = ["k", "up", "ctrl+p"] -go_top = ["gg", "home"] -shuffle = [] # an empty list unbinds the action ``` -If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file. - -### Streaming or downloading - -By default Drift streams songs, so playback starts right away, even for hour-long tracks. With `download = true` songs are cached on disk first, and the next song in the queue is fetched in the background while the current one plays. - -### Discord rich presence - -This is off by default. - -1. Create an application at https://discord.com/developers/applications. Its name is what shows up after "Listening to". -2. Copy the Application ID into `client_id` under `[discord]` in `config.toml`. -3. Restart Drift. - -Your status then shows the current song with cover art, album, a progress bar and a "Listen Along" button. If Discord isn't running, Drift keeps retrying quietly in the background. - -### Custom keys - -To change keys, add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys. Everything you leave out keeps its default. - -```toml -[keys] -quit = "Q" -move_down = ["j", "down", "ctrl+n"] -move_up = ["k", "up", "ctrl+p"] -go_top = ["gg", "home"] -shuffle = [] # an empty list unbinds the action -``` - -A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys. - -| Action | Default keys | -| --- | --- | -| `move_down` `move_up` | `j` `down` / `k` `up` | -| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` | -| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` | -| `go_top` `go_bottom` | `gg` `home` / `ge` `end` | -| `next_view` `prev_view` | `tab` / `backtab` | -| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` | -| `activate` | `enter` | -| `pause` | `space` | -| `next` `prev` | `n` `>` / `N` `<` | -| `seek_back` `seek_forward` | `h` `left` / `l` `right` | -| `volume_up` `volume_down` | `+` `=` / `-` `_` | -| `add` `add_all` `play_all` | `a` / `A` / `P` | -| `delete` | `x` `delete` `dd` | -| `move_item_down` `move_item_up` | `J` / `K` | -| `shuffle` `repeat` | `s` / `r` | -| `menu` | `G` `menu` | -| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` | -| `lyrics_panel` `lyrics` | `L` / `F` | -| `search` `command` | `/` / `:` | -| `help` | `?` | -| `quit` | `q` | -| `back` | `backspace` `esc` | - -In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable. Popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. - -### Environment variables - -| Variable | What it does | -| --- | --- | -| `DRIFT_CONFIG_DIR` | where Drift keeps `config.toml` and your playlists | -| `DRIFT_STATE_DIR` | where Drift keeps the saved session | -| `DRIFT_MPV_ARGS` | extra arguments passed to mpv | - -## Troubleshooting - -**Drift says mpv or yt-dlp is missing.** Run `drift --check` to see which one, and install it with the table in [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually). - -**Playback fails with "Sign in to confirm you're not a bot".** YouTube wants a login. Set `cookies_from_browser` under `[audio]` to a browser you're logged into YouTube with (for example `"firefox"`), then restart Drift. Updating yt-dlp also helps, since YouTube changes things often. - -**The lyrics panel doesn't show up.** It needs a terminal at least 70 columns wide. Make the window wider or use `F` for fullscreen lyrics. - -**`drift: command not found`.** Make sure `~/.cargo/bin` is in your `$PATH`. - -**`gb` opens the wrong album.** Drift looks albums up by album and artist name, so it can guess wrong, and the song needs album info to begin with. `:album ` lets you search for it yourself. - -You can run several Drift windows at once. Only the first one restores and saves the session, and playlists saved from different windows are merged instead of overwritten. +Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. ## Contributing -Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests: +Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) before you start. -```bash -cargo build -cargo test -``` +## Star History -Recent changes are listed in [CHANGELOG.md](CHANGELOG.md). +[![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift) ## License diff --git a/install.sh b/install.sh index 9c038b2..b9614ba 100644 --- a/install.sh +++ b/install.sh @@ -1,76 +1,17 @@ #!/bin/sh -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 +set -e +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 "Error: unsupported package manager." >&2 - echo "Install mpv, yt-dlp, a C compiler, and Cargo manually." >&2 + echo "unsupported package manager: install mpv, yt-dlp 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 d8fdade..2dae383 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,13 +7,11 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; -use crate::config::{self, Audio, Config}; +use crate::config::{self, Config}; use crate::lyrics::{self, Lyrics}; use crate::model::{split_artists, Collection, RepeatMode, Track}; -use crate::discord::Discord; -use crate::keys::{Action, Key, Keymap, Lookup}; use crate::mpris::{Cmd, Mpris}; -use crate::player::{self, Player, PlayerEvent}; +use crate::player::{Player, PlayerEvent}; use crate::session::Session; use crate::store::Store; use crate::ytmusic::{self, Kind, Page}; @@ -38,19 +36,7 @@ pub enum AppEvent { title: String, result: Result, String>, }, - SavePlaylist { - name: String, - result: Result, String>, - }, Media(Cmd), - Audio { - id: String, - result: Result, - }, - Stream { - id: String, - result: Result, - }, } const FIRST_PAGE: usize = 20; @@ -123,9 +109,6 @@ pub enum MenuAction { Artist, Similar, Album, - SaveTrackAlbum, - SaveAlbum, - SaveResults, PlaylistMenu, PickPlaylist(String), NewPlaylist, @@ -205,7 +188,7 @@ pub struct App { history: Vec, hist_pos: Option, pub count: Option, - pub pending: Vec, + pub pending: Option, pub play_state: PlayState, pub position: f64, @@ -218,10 +201,6 @@ pub struct App { radio_played: HashSet, resume_radio: bool, fail_streak: u8, - want: Option, - fetching: Option, - resolving: Option, - audio: Audio, result_cursor: Option, buffered: Vec, next_page: Option, @@ -231,9 +210,6 @@ pub struct App { on_radio: bool, similar_load: Option, mpris: Option, - discord: Option, - owns_session: bool, - keymap: Keymap, seek_step: f64, volume_step: f64, autoplay: bool, @@ -272,8 +248,6 @@ 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, @@ -309,8 +283,7 @@ impl App { history: Vec::new(), hist_pos: None, count: None, - pending: Vec::new(), - keymap: cfg.keymap.clone(), + pending: None, play_state: PlayState::Stopped, position: 0.0, duration: 0.0, @@ -322,10 +295,6 @@ impl App { radio_played: HashSet::new(), resume_radio: false, fail_streak: 0, - want: None, - fetching: None, - resolving: None, - audio: cfg.audio.clone(), result_cursor: None, buffered: Vec::new(), next_page: None, @@ -335,8 +304,6 @@ 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, @@ -354,7 +321,7 @@ impl App { tx, rng: seed, }; - if p.restore_session && owns_session { + if p.restore_session { let s = Session::load(); app.resume = s .current @@ -379,13 +346,9 @@ 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, @@ -427,11 +390,6 @@ 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, @@ -544,103 +502,121 @@ impl App { } fn key_normal(&mut self, key: KeyEvent) { - let key = Key::from(key); + let ctrl = is_ctrl(&key); - if self.pending.is_empty() { - if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { + if let Some(p) = self.pending.take() { + let n = self.count.take(); + if let KeyCode::Char(c) = key.code { + match (p, c) { + ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + ('g', 'e') => { + self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + } + ('g', 'r') => self.set_view(View::Results), + ('g', 'q') => self.set_view(View::Queue), + ('g', 'p') => self.set_view(View::Playlists), + ('d', 'd') => self.delete_selected(), + ('g', 'a') => self.go_to_artist(), + ('g', 's') => self.go_similar(), + ('g', 'b') => self.go_album(), + _ => {} + } + } + return; + } + + if let KeyCode::Char(c @ '0'..='9') = key.code { + if !ctrl && !(c == '0' && self.count.is_none()) { + let d = c as usize - '0' as usize; self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); return; } } - - self.pending.push(key); - match self.keymap.lookup(&self.pending) { - Lookup::Pending => {} - Lookup::Run(action) => { - self.pending.clear(); - let n = self.count.take(); - self.run_action(action, n); - } - Lookup::Nothing => { - self.pending.clear(); - self.count = None; - } - } - } - - fn run_action(&mut self, action: Action, n: Option) { + let n = self.count.take(); let times = n.unwrap_or(1); if self.view == View::Playlists { - match action { - Action::SeekForward if !self.in_playlist() => { + match key.code { + KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { return self.open_selected_playlist(); } - Action::SeekBack | Action::Back if self.in_playlist() => { + KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc + if self.in_playlist() && !ctrl => + { return self.close_playlist(); } _ => {} } } - match action { - Action::HalfPageDown => self.move_sel(10), - Action::HalfPageUp => self.move_sel(-10), - Action::PageDown => self.move_sel(20), - Action::PageUp => self.move_sel(-20), - Action::MoveDown => self.move_sel(times as isize), - Action::MoveUp => self.move_sel(-(times as isize)), - Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), - Action::GoBottom => { - self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + match key.code { + KeyCode::Char('d') if ctrl => self.move_sel(10), + KeyCode::Char('u') if ctrl => self.move_sel(-10), + KeyCode::Char('f') if ctrl => self.move_sel(20), + KeyCode::Char('b') if ctrl => self.move_sel(-20), + KeyCode::PageDown => self.move_sel(20), + KeyCode::PageUp => self.move_sel(-20), + KeyCode::Home => self.set_selected(0), + KeyCode::End => self.set_selected(usize::MAX), + + KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize), + KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)), + KeyCode::Char('g') => { + self.pending = Some('g'); + self.count = n; } - Action::Menu => self.open_menu(), - Action::Delete => self.delete_selected(), + KeyCode::Char('G') | KeyCode::Menu => self.open_menu(), + KeyCode::Char('d') => { + if matches!(self.view, View::Queue | View::Playlists) { + self.pending = Some('d'); + } + } + KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), - Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)), - Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::ViewResults => self.set_view(View::Results), - Action::ViewQueue => self.set_view(View::Queue), - Action::ViewPlaylists => self.set_view(View::Playlists), + KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), + KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::Activate => self.activate(), - Action::Pause => self.toggle_pause(), - Action::Next => self.next(), - Action::Prev => self.prev(), - Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), - Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), - Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)), - Action::VolumeDown => { + KeyCode::Enter => self.activate(), + KeyCode::Char(' ') => self.toggle_pause(), + KeyCode::Char('n') | KeyCode::Char('>') => self.next(), + KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), + KeyCode::Char('h') | KeyCode::Left => { + self.seek(Amount::Delta(-self.seek_step * times as f64)) + } + KeyCode::Char('l') | KeyCode::Right => { + self.seek(Amount::Delta(self.seek_step * times as f64)) + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.set_volume(Amount::Delta(self.volume_step * times as f64)) + } + KeyCode::Char('-') | KeyCode::Char('_') => { self.set_volume(Amount::Delta(-self.volume_step * times as f64)) } - Action::Add => self.add_selected(), - Action::AddAll => self.add_all_results(), - Action::PlayAll => self.play_all_results(), - Action::MoveItemDown => self.move_item(1), - Action::MoveItemUp => self.move_item(-1), - Action::Shuffle => self.toggle_shuffle(), - Action::Repeat => self.cycle_repeat(), + KeyCode::Char('a') => self.add_selected(), + KeyCode::Char('A') => self.add_all_results(), + KeyCode::Char('P') => self.play_all_results(), + KeyCode::Char('J') => self.move_item(1), + KeyCode::Char('K') => self.move_item(-1), + KeyCode::Char('s') => self.toggle_shuffle(), + KeyCode::Char('r') => self.cycle_repeat(), - Action::Search => { + KeyCode::Char('/') => { self.cmd_buf.clear(); self.cmd_search = true; self.enter_command_mode(); } - Action::Command => { + KeyCode::Char(':') => { self.cmd_buf.clear(); self.cmd_search = false; self.enter_command_mode(); } - Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, - Action::Lyrics => self.open_lyrics(), - Action::GoArtist => self.go_to_artist(), - Action::GoSimilar => self.go_similar(), - Action::GoAlbum => self.go_album(), - Action::SaveAlbum => self.save_album(None), - Action::Help => self.open_help(), - Action::Quit => self.should_quit = true, - Action::Back => self.status = None, + KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel, + KeyCode::Char('F') => self.open_lyrics(), + KeyCode::Char('?') => self.open_help(), + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Esc => self.status = None, + _ => {} } } @@ -688,7 +664,6 @@ impl App { items.push(menu_item('s', "Similar songs", MenuAction::Similar)); if track.album.is_some() { items.push(menu_item('b', "Show album", MenuAction::Album)); - items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum)); } items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); if in_queue { @@ -700,11 +675,6 @@ impl App { if self.view == View::Results { items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); - if self.results_title.starts_with("Album: ") { - items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum)); - } else { - items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults)); - } } if in_queue { items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); @@ -812,21 +782,6 @@ impl App { MenuAction::Artist => self.go_to_artist(), MenuAction::Similar => self.go_similar(), MenuAction::Album => self.go_album(), - MenuAction::SaveTrackAlbum => { - if let Some(t) = track { - let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string()); - self.save_album_background(t, album_title); - } - } - MenuAction::SaveAlbum => { - let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim(); - if !default_name.is_empty() { - self.prefill_command(&format!("savealbum \"{default_name}\"")); - } else { - self.prefill_command("savealbum "); - } - } - MenuAction::SaveResults => self.prefill_command("saveresults "), MenuAction::PlaylistMenu => {} MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::NewPlaylist => self.prefill_command("addto "), @@ -857,11 +812,6 @@ 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}")); } @@ -1153,10 +1103,7 @@ 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, false), - Command::SaveForce(name) => self.save_playlist(&name, true), - Command::SaveAlbum(name) => self.save_album(name), - Command::SaveResults(name) => self.save_results_as_playlist(name), + Command::Save(name) => self.save_playlist(&name), Command::Load(name) => match self.store.resolve(&name) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1200,7 +1147,7 @@ impl App { let Some(track) = self.artist_target() else { return self.error("No song selected"); }; - let names = track.artist_names(); + let names = split_artists(&track.artist); if names.is_empty() || track.artist == "Unknown artist" { return self.error("No artist known for this song"); } @@ -1272,7 +1219,7 @@ impl App { tracks.extend(found.into_iter().filter(|t| { !known.contains(&t.id) && (t.artist.to_lowercase() == wanted - || t.artist_names() + || split_artists(&t.artist) .iter() .any(|a| a.to_lowercase() == wanted)) })); @@ -1287,8 +1234,7 @@ impl App { let Some(album) = track.album.clone() else { return self.error("No album known for this song"); }; - let artist = track - .artist_names() + let artist = split_artists(&track.artist) .into_iter() .next() .unwrap_or_default(); @@ -1306,56 +1252,6 @@ impl App { }); } - fn save_album(&mut self, name: Option) { - if self.view == View::Results - && self.results_title.starts_with("Album: ") - && !self.results.is_empty() - { - return self.save_results_as_playlist(name); - } - - if let Some(track) = self.artist_target() { - if let Some(album) = track.album.clone() { - let target_name = name.unwrap_or(album); - return self.save_album_background(track, target_name); - } - } - - if self.view == View::Results && !self.results.is_empty() { - return self.save_results_as_playlist(name); - } - - self.error("No album to save — open an album or select a song with an album"); - } - - fn save_album_background(&mut self, track: Track, target_name: String) { - let Some(album) = track.album else { - return self.error("No album known for this song"); - }; - let artist = split_artists(&track.artist) - .into_iter() - .next() - .unwrap_or_default(); - self.busy = Some(format!("Saving album \u{201c}{album}\u{201d}")); - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = (|| -> Result, String> { - let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; - let want = album.to_lowercase(); - let pick = found - .iter() - .find(|c| c.title.to_lowercase() == want) - .or(found.first()) - .ok_or_else(|| "album not found".to_string())?; - ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist) - })(); - let _ = tx.send(AppEvent::SavePlaylist { - name: target_name, - result, - }); - }); - } - fn start_collection_search(&mut self, query: String, kind: Kind) { let (what, title) = match kind { Kind::Album => ("albums", format!("Albums: {query}")), @@ -1379,7 +1275,6 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), - KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(), KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, @@ -1414,23 +1309,6 @@ impl App { }); } - fn save_picked_collection(&mut self) { - let c = self.picks.get(self.pick_sel).cloned(); - self.close_collection_pick(); - let Some(c) = c else { return }; - let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; - let target_name = c.title.clone(); - self.busy = Some(format!("Saving {kind} \u{201c}{target_name}\u{201d}")); - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist); - let _ = tx.send(AppEvent::SavePlaylist { - name: target_name, - result, - }); - }); - } - fn on_media(&mut self, cmd: Cmd) { match cmd { Cmd::Play => match self.play_state { @@ -1459,13 +1337,6 @@ 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"); @@ -1578,34 +1449,12 @@ impl App { self.picks = list; self.pick_sel = 0; self.pick_title = title; + self.mode = Mode::CollectionPick; } Err(e) => self.error(e), } } AppEvent::Media(cmd) => self.on_media(cmd), - AppEvent::SavePlaylist { name, result } => { - self.busy = None; - match result { - Ok(tracks) if tracks.is_empty() => { - self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks")); - } - Ok(tracks) => { - let n = tracks.len(); - let key = self.store.resolve_exact(&name).unwrap_or(name); - self.store.playlists.insert(key.clone(), tracks); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - let sel = self.selected(); - self.set_selected(sel); - } - } - Err(e) => { - self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}")) - } - } - } AppEvent::Lyrics { id, result } => { if self.lyrics_id.as_deref() != Some(id.as_str()) { return; @@ -1616,8 +1465,6 @@ impl App { Err(e) => LyricsState::Failed(e), }; } - AppEvent::Audio { id, result } => self.on_audio(id, result), - AppEvent::Stream { id, result } => self.on_stream(id, result), AppEvent::Player(generation, pe) => { if generation == self.player_gen { self.on_player(pe); @@ -1699,7 +1546,6 @@ 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) @@ -1721,7 +1567,7 @@ impl App { if self.fail_streak >= 3 { self.stop_playback(); self.error(format!( - "Playback failed 3 times in a row: {msg}" + "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" )); } else { self.error(format!( @@ -1740,100 +1586,6 @@ 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(); - let audio = self.audio.clone(); - std::thread::spawn(move || { - let result = player::fetch_audio(&id, &audio); - let _ = tx.send(AppEvent::Audio { id, result }); - }); - } - - fn load_track(&mut self, id: &str, duration: Option) { - self.want = Some(id.to_string()); - match player::cached_audio(id) { - Some(path) => self.on_audio(id.to_string(), Ok(path)), - None if self.audio.should_download(duration) => self.fetch_audio(id), - None => self.resolve_stream(id), - } - } - - /* Start playing from the network right away; no full download first. */ - fn resolve_stream(&mut self, id: &str) { - if self.resolving.as_deref() == Some(id) { - return; - } - self.resolving = Some(id.to_string()); - let tx = self.tx.clone(); - let id = id.to_string(); - let audio = self.audio.clone(); - std::thread::spawn(move || { - let result = player::resolve_stream(&id, &audio); - let _ = tx.send(AppEvent::Stream { id, result }); - }); - } - - fn on_stream(&mut self, id: String, result: Result) { - if self.resolving.as_deref() == Some(id.as_str()) { - self.resolving = None; - } - if self.want.as_deref() != Some(id.as_str()) { - return; - } - self.want = None; - match result { - Ok(url) => { - if let Err(e) = self.with_player(|p| p.load(&url)) { - self.play_state = PlayState::Stopped; - self.error(format!("Playback failed: {e:#}")); - } - } - Err(msg) => self.on_player(PlayerEvent::LoadError(msg)), - } - } - - 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; - }; - /* streamed tracks (the default, or too long to download) aren't prefetched */ - if !self.audio.should_download(next.duration) { - 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); } @@ -1854,8 +1606,14 @@ impl App { _ => 0.0, }; self.duration = track.duration.map_or(0.0, f64::from); - self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + let url = track.url(); + match self.with_player(|p| p.load(&url)) { + Ok(()) => self.info(format!("♪ {}", track.label())), + Err(e) => { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } } fn play_default(&mut self) { @@ -1932,12 +1690,6 @@ 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); @@ -1946,8 +1698,14 @@ impl App { self.play_state = PlayState::Loading; self.position = 0.0; self.duration = track.duration.map_or(0.0, f64::from); - self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + let url = track.url(); + match self.with_player(|p| p.load(&url)) { + Ok(()) => self.info(format!("♪ {}", track.label())), + Err(e) => { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } } fn set_pause(&mut self, pause: bool) { @@ -1978,7 +1736,6 @@ impl App { } fn stop_playback(&mut self) { - self.want = None; if let Some(p) = self.player.as_mut() { let _ = p.stop(); } @@ -2311,60 +2068,18 @@ impl App { } } - fn save_playlist(&mut self, name: &str, force: bool) { + fn save_playlist(&mut self, name: &str) { 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(key.clone(), self.queue.clone()); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - } - } - - fn save_results_as_playlist(&mut self, name: Option) { - if self.results.is_empty() { - return self.error("No results to save — search or open an album first"); - } - let target_name = match name { - Some(n) if !n.trim().is_empty() => n.trim().to_string(), - _ => { - if let Some(rest) = self.results_title.strip_prefix("Album: ") { - rest.trim().to_string() - } else if let Some(rest) = self.results_title.strip_prefix("Playlist: ") { - rest.trim().to_string() - } else if let Some(album) = self.results.iter().find_map(|t| t.album.as_deref()) { - album.to_string() - } else { - return self.error( - "No album name found; usage: :savealbum or :saveresults ", - ); - } - } - }; - let n = self.results.len(); - let key = self.store.resolve_exact(&target_name).unwrap_or(target_name); self.store .playlists - .insert(key.clone(), self.results.clone()); + .insert(name.to_string(), self.queue.clone()); if self.persist() { self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + "Saved {n} tracks to playlist \u{201c}{name}\u{201d}" )); - let sel = self.selected(); - self.set_selected(sel); } } @@ -2520,7 +2235,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::keys::Keys; use std::sync::mpsc; fn track(id: &str, title: &str) -> Track { @@ -2528,7 +2242,6 @@ mod tests { id: id.into(), title: title.into(), artist: "Artist".into(), - artists: Vec::new(), album: None, duration: Some(100), } @@ -2637,40 +2350,6 @@ mod tests { app.shuffle = false; } - fn check_custom_keys(app: &mut App) { - let custom = [ - ("move_down", Keys::Many(vec!["e".into()])), - ("go_top", Keys::One("zz".into())), - ("quit", Keys::One("Q".into())), - ]; - let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect(); - app.keymap = Keymap::build(&custom).unwrap(); - - app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect(); - app.view = View::Results; - app.states[View::Results.idx()].select(Some(0)); - - press(app, KeyCode::Char('j')); - assert_eq!(app.selected(), 0); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 1); - press(app, KeyCode::Char('2')); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 3); - press(app, KeyCode::Char('z')); - assert!(app.count.is_none() && !app.pending.is_empty()); - press(app, KeyCode::Char('z')); - assert_eq!(app.selected(), 0); - assert!(app.pending.is_empty()); - - press(app, KeyCode::Char('q')); - assert!(!app.should_quit); - press(app, KeyCode::Char('Q')); - assert!(app.should_quit); - app.should_quit = false; - app.keymap = Keymap::default(); - } - #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); @@ -2761,53 +2440,12 @@ mod tests { check_artist_popup(&mut app); check_paging(&mut app); - check_custom_keys(&mut app); app.run_command("remove"); assert_eq!(app.queue.len(), 1); check_playlist_playback(&mut app); - // Test savealbum and saveresults - let mut t1 = track("song1111111", "Dreams"); - t1.album = Some("Rumours".into()); - let mut t2 = track("song2222222", "Go Your Own Way"); - t2.album = Some("Rumours".into()); - app.results = vec![t1.clone(), t2.clone()]; - app.results_title = "Album: Rumours".into(); - app.view = View::Results; - - // :savealbum with no args uses album name from results_title - app.run_command("savealbum"); - assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2)); - - // :savealbum with custom name - app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#); - assert_eq!( - app.store - .playlists - .get("Fleetwood Mac - Rumours") - .map(Vec::len), - Some(2) - ); - - // :saveresults with custom name - app.run_command(r#"saveresults "Classic Hits""#); - assert_eq!( - app.store.playlists.get("Classic Hits").map(Vec::len), - Some(2) - ); - - // Test AppEvent::SavePlaylist background event - app.on_event(AppEvent::SavePlaylist { - name: "Background Album".into(), - result: Ok(vec![track("bg111111111", "BG Track")]), - }); - assert_eq!( - app.store.playlists.get("Background Album").map(Vec::len), - Some(1) - ); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index 3f6ada2..815e1fc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -30,9 +30,6 @@ pub enum Command { Shuffle, Repeat(Option), Save(String), - SaveForce(String), - SaveAlbum(Option), - SaveResults(Option), Load(String), Append(String), DeletePlaylist(String), @@ -69,8 +66,6 @@ pub const COMMAND_NAMES: &[&str] = &[ "shuffle", "repeat", "save", - "savealbum", - "saveresults", "load", "append", "delpl", @@ -177,9 +172,6 @@ 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!")?), - "savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())), - "saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -286,22 +278,5 @@ 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()))) - ); - assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None)))); - assert_eq!( - parse(r#"savealbum "Abbey Road""#), - Ok(Some(Command::SaveAlbum(Some("Abbey Road".into())))) - ); - assert_eq!( - parse("saveresults"), - Ok(Some(Command::SaveResults(None))) - ); - assert_eq!( - parse(r#"saveresults "My Hits""#), - Ok(Some(Command::SaveResults(Some("My Hits".into())))) - ); } } diff --git a/src/config.rs b/src/config.rs index cb92b38..296d890 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::str::FromStr; @@ -8,7 +7,6 @@ use ratatui::style::Color; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer}; -use crate::keys::{Keymap, Keys}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; @@ -76,51 +74,6 @@ pub struct Artist { pub popular_first: bool, } -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default)] -pub struct Discord { - pub client_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct Audio { - pub download: bool, - pub download_max_minutes: u32, - pub cookies_from_browser: String, -} - -impl Default for Audio { - fn default() -> Self { - Audio { - download: false, - download_max_minutes: 15, - cookies_from_browser: String::new(), - } - } -} - -impl Audio { - /// Browser name for yt-dlp's --cookies-from-browser, if one is set. - pub fn cookies(&self) -> Option<&str> { - let c = self.cookies_from_browser.trim(); - (!c.is_empty()).then_some(c) - } - - /// Whether a track should be downloaded to the cache before playing, - /// as opposed to streamed. Unknown lengths are streamed when a limit is set. - pub fn should_download(&self, duration: Option) -> bool { - if !self.download { - return false; - } - match (self.download_max_minutes, duration) { - (0, _) => true, - (max, Some(d)) => d <= max.saturating_mul(60), - (_, None) => false, - } - } -} - fn default_accent() -> Color { Color::Cyan } @@ -144,11 +97,6 @@ pub struct Config { pub playback: Playback, pub artist: Artist, pub ui: Ui, - pub discord: Discord, - pub audio: Audio, - pub keys: HashMap, - #[serde(skip)] - pub keymap: Keymap, } impl Config { @@ -156,7 +104,7 @@ impl Config { Ok(Store::dir()?.join("config.toml")) } - pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String { + pub fn render(p: &Playback, a: &Artist, u: &Ui) -> String { let accent = match u.accent { Color::Reset => "reset", Color::Black => "black", @@ -202,36 +150,6 @@ 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}\" - -[audio] -# false: stream songs straight from YouTube (starts playing right away). -# true: download songs to the cache first, then play (and prefetch the next one). -download = {download} -# With download = true, songs longer than this many minutes are still streamed -# instead of downloaded. 0 means download everything, however long. -download_max_minutes = {dl_max} -# Use your browser's cookies with yt-dlp when YouTube refuses to play something -# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...). -# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", -# \"firefox:my-profile\". Empty means off. Close the browser if reading fails. -cookies_from_browser = \"{cookies}\" - -[keys] -# Remap keys here. Each line sets an action to one key or a list of keys and -# replaces that action's default keys. Everything you leave out keeps its vim -# default. See the README for the list of actions. -# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab, -# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12), -# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds. -# quit = \"Q\" -# move_down = [\"j\", \"down\"] -# go_top = [\"gg\", \"home\"] ", volume = p.volume, shuffle = p.shuffle, @@ -242,10 +160,6 @@ cookies_from_browser = \"{cookies}\" vstep = p.volume_step, popular = a.popular_first, accent = accent, - client_id = d.client_id, - download = au.download, - dl_max = au.download_max_minutes, - cookies = au.cookies_from_browser.replace(['\\', '"'], ""), ) } @@ -260,7 +174,7 @@ cookies_from_browser = \"{cookies}\" 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(), &Discord::default(), &Audio::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default())); return (Config::default(), None); } Err(e) => { @@ -273,14 +187,7 @@ cookies_from_browser = \"{cookies}\" match toml::from_str::(&text) { Ok(mut c) => { c.playback.sanitize(); - let warning = match Keymap::build(&c.keys) { - Ok(keymap) => { - c.keymap = keymap; - None - } - Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")), - }; - (c, warning) + (c, None) } Err(e) => { let bak = path.with_extension("toml.bak"); @@ -346,7 +253,7 @@ pub fn migrate_legacy() { let _ = Session::write(&s); } } - let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); + let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default())); } #[cfg(test)] @@ -355,7 +262,7 @@ mod tests { #[test] fn default_file_parses_back() { - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()); + let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default()); let c: Config = toml::from_str(&text).unwrap(); let d = Playback::default(); assert_eq!(c.playback.volume, d.volume); @@ -422,52 +329,8 @@ mod tests { // Roundtrip: render then parse let u = Ui { accent: Color::Blue }; - let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default()); + let text = Config::render(&Playback::default(), &Artist::default(), &u); let c: Config = toml::from_str(&text).unwrap(); assert_eq!(c.ui.accent, Color::Blue); } - - #[test] - fn keys_section() { - let c: Config = toml::from_str("").unwrap(); - assert!(c.keys.is_empty()); - let c: Config = - toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap(); - assert!(Keymap::build(&c.keys).is_ok()); - assert_eq!(c.keys.len(), 2); - } - - #[test] - fn audio_section() { - let c: Config = toml::from_str("").unwrap(); - assert!(!c.audio.download); - assert_eq!(c.audio.cookies(), None); - let c: Config = toml::from_str( - "[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"", - ) - .unwrap(); - assert_eq!(c.audio.cookies(), Some("firefox")); - assert!(c.audio.should_download(Some(600))); - assert!(!c.audio.should_download(Some(601))); - assert!(!c.audio.should_download(None)); - let mut a = c.audio.clone(); - a.download_max_minutes = 0; - assert!(a.should_download(Some(10_000))); - a.download = false; - assert!(!a.should_download(Some(1))); - } - - #[test] - fn audio_roundtrip() { - let a = Audio { - download: true, - download_max_minutes: 30, - cookies_from_browser: "chrome".into(), - }; - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a); - let c: Config = toml::from_str(&text).unwrap(); - assert!(c.audio.download); - assert_eq!(c.audio.download_max_minutes, 30); - assert_eq!(c.audio.cookies(), Some("chrome")); - } } diff --git a/src/discord.rs b/src/discord.rs deleted file mode 100644 index 1a0af81..0000000 --- a/src/discord.rs +++ /dev/null @@ -1,174 +0,0 @@ -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, StatusDisplayType, 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) - .status_display_type(StatusDisplayType::Details) - .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/keys.rs b/src/keys.rs deleted file mode 100644 index 50a997a..0000000 --- a/src/keys.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::collections::HashMap; -use std::fmt; - -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use serde::Deserialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Action { - MoveDown, - MoveUp, - HalfPageDown, - HalfPageUp, - PageDown, - PageUp, - GoTop, - GoBottom, - Menu, - Delete, - NextView, - PrevView, - ViewResults, - ViewQueue, - ViewPlaylists, - Activate, - Pause, - Next, - Prev, - SeekBack, - SeekForward, - VolumeUp, - VolumeDown, - Add, - AddAll, - PlayAll, - MoveItemDown, - MoveItemUp, - Shuffle, - Repeat, - Search, - Command, - LyricsPanel, - Lyrics, - GoArtist, - GoSimilar, - GoAlbum, - SaveAlbum, - Help, - Quit, - Back, -} - -const DEFAULTS: &[(&str, Action, &[&str])] = &[ - ("move_down", Action::MoveDown, &["j", "down"]), - ("move_up", Action::MoveUp, &["k", "up"]), - ("half_page_down", Action::HalfPageDown, &["ctrl+d"]), - ("half_page_up", Action::HalfPageUp, &["ctrl+u"]), - ("page_down", Action::PageDown, &["ctrl+f", "pagedown"]), - ("page_up", Action::PageUp, &["ctrl+b", "pageup"]), - ("go_top", Action::GoTop, &["gg", "home"]), - ("go_bottom", Action::GoBottom, &["ge", "end"]), - ("menu", Action::Menu, &["G", "menu"]), - ("delete", Action::Delete, &["x", "delete", "dd"]), - ("next_view", Action::NextView, &["tab"]), - ("prev_view", Action::PrevView, &["backtab"]), - ("view_results", Action::ViewResults, &["gr"]), - ("view_queue", Action::ViewQueue, &["gq"]), - ("view_playlists", Action::ViewPlaylists, &["gp"]), - ("activate", Action::Activate, &["enter"]), - ("pause", Action::Pause, &["space"]), - ("next", Action::Next, &["n", ">"]), - ("prev", Action::Prev, &["N", "<"]), - ("seek_back", Action::SeekBack, &["h", "left"]), - ("seek_forward", Action::SeekForward, &["l", "right"]), - ("volume_up", Action::VolumeUp, &["+", "="]), - ("volume_down", Action::VolumeDown, &["-", "_"]), - ("add", Action::Add, &["a"]), - ("add_all", Action::AddAll, &["A"]), - ("play_all", Action::PlayAll, &["P"]), - ("move_item_down", Action::MoveItemDown, &["J"]), - ("move_item_up", Action::MoveItemUp, &["K"]), - ("shuffle", Action::Shuffle, &["s"]), - ("repeat", Action::Repeat, &["r"]), - ("search", Action::Search, &["/"]), - ("command", Action::Command, &[":"]), - ("lyrics_panel", Action::LyricsPanel, &["L"]), - ("lyrics", Action::Lyrics, &["F"]), - ("go_artist", Action::GoArtist, &["ga"]), - ("go_similar", Action::GoSimilar, &["gs"]), - ("go_album", Action::GoAlbum, &["gb"]), - ("save_album", Action::SaveAlbum, &["gB"]), - ("help", Action::Help, &["?"]), - ("quit", Action::Quit, &["q"]), - ("back", Action::Back, &["backspace", "esc"]), -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Key { - code: KeyCode, - ctrl: bool, -} - -impl Key { - pub fn digit(&self) -> Option { - match self.code { - KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize), - _ => None, - } - } -} - -impl From for Key { - fn from(e: KeyEvent) -> Key { - Key { - code: e.code, - ctrl: e.modifiers.contains(KeyModifiers::CONTROL) - && !e.modifiers.contains(KeyModifiers::ALT), - } - } -} - -impl fmt::Display for Key { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if self.ctrl { - f.write_str("^")?; - } - match self.code { - KeyCode::Char(c) => write!(f, "{c}"), - other => write!(f, "{other}"), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum Keys { - One(String), - Many(Vec), -} - -impl Keys { - fn as_slice(&self) -> &[String] { - match self { - Keys::One(s) => std::slice::from_ref(s), - Keys::Many(v) => v, - } - } -} - -pub enum Lookup { - Run(Action), - Pending, - Nothing, -} - -#[derive(Debug, Clone)] -pub struct Keymap(Vec<(Vec, Action)>); - -impl Default for Keymap { - fn default() -> Keymap { - Keymap::build(&HashMap::new()).expect("default keys are valid") - } -} - -impl Keymap { - pub fn build(custom: &HashMap) -> Result { - if let Some(name) = custom - .keys() - .find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str())) - { - return Err(format!("unknown action \"{name}\"")); - } - - let mut map: Vec<(Vec, Action)> = Vec::new(); - for (name, action, _) in DEFAULTS { - let Some(keys) = custom.get(*name) else { - continue; - }; - for text in keys.as_slice() { - let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?; - if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) { - if other != action { - return Err(format!("\"{text}\" is bound to two actions")); - } - continue; - } - map.push((seq, *action)); - } - } - - for (name, action, keys) in DEFAULTS { - if custom.contains_key(*name) { - continue; - } - for text in *keys { - let seq = parse_sequence(text).expect("default keys are valid"); - if !map.iter().any(|(s, _)| *s == seq) { - map.push((seq, *action)); - } - } - } - Ok(Keymap(map)) - } - - pub fn lookup(&self, seq: &[Key]) -> Lookup { - if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) { - Lookup::Run(*action) - } else if self.0.iter().any(|(s, _)| s.starts_with(seq)) { - Lookup::Pending - } else { - Lookup::Nothing - } - } -} - -fn parse_sequence(text: &str) -> Result, String> { - let mut keys = Vec::new(); - for token in text.split_whitespace() { - match parse_key(token) { - Some(key) => keys.push(key), - None if !token.contains('+') => keys.extend(token.chars().map(|c| Key { - code: KeyCode::Char(c), - ctrl: false, - })), - None => return Err(format!("unknown key \"{token}\"")), - } - } - if keys.is_empty() { - return Err("empty key".into()); - } - Ok(keys) -} - -fn parse_key(token: &str) -> Option { - let (ctrl, name) = match token.get(..5) { - Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]), - _ => (false, token), - }; - let mut chars = name.chars(); - let code = match (chars.next()?, chars.next()) { - (c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()), - (c, None) => KeyCode::Char(c), - _ => match name.to_ascii_lowercase().as_str() { - "space" => KeyCode::Char(' '), - "enter" | "return" => KeyCode::Enter, - "esc" | "escape" => KeyCode::Esc, - "tab" => KeyCode::Tab, - "backtab" => KeyCode::BackTab, - "backspace" => KeyCode::Backspace, - "delete" | "del" => KeyCode::Delete, - "insert" => KeyCode::Insert, - "home" => KeyCode::Home, - "end" => KeyCode::End, - "pageup" => KeyCode::PageUp, - "pagedown" => KeyCode::PageDown, - "up" => KeyCode::Up, - "down" => KeyCode::Down, - "left" => KeyCode::Left, - "right" => KeyCode::Right, - "menu" => KeyCode::Menu, - f => match f.strip_prefix('f')?.parse::() { - Ok(n @ 1..=12) => KeyCode::F(n), - _ => return None, - }, - }, - }; - Some(Key { code, ctrl }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn custom(entries: &[(&str, &[&str])]) -> HashMap { - entries - .iter() - .map(|(name, keys)| { - let keys = keys.iter().map(|k| k.to_string()).collect(); - (name.to_string(), Keys::Many(keys)) - }) - .collect() - } - - fn seq(text: &str) -> Vec { - parse_sequence(text).unwrap() - } - - fn run(map: &Keymap, text: &str) -> Option { - match map.lookup(&seq(text)) { - Lookup::Run(a) => Some(a), - _ => None, - } - } - - #[test] - fn defaults_are_vim() { - let map = Keymap::default(); - assert_eq!(run(&map, "j"), Some(Action::MoveDown)); - assert_eq!(run(&map, "down"), Some(Action::MoveDown)); - assert_eq!(run(&map, "gg"), Some(Action::GoTop)); - assert_eq!(run(&map, "dd"), Some(Action::Delete)); - assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown)); - assert_eq!(run(&map, "G"), Some(Action::Menu)); - assert_eq!(run(&map, "space"), Some(Action::Pause)); - assert!(matches!(map.lookup(&seq("g")), Lookup::Pending)); - assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing)); - } - - #[test] - fn parsing() { - assert_eq!(seq("ctrl+D"), seq("ctrl+d")); - assert_eq!(seq("gg"), seq("g g")); - assert_eq!(seq("+").len(), 1); - assert_eq!(seq("ctrl+x g").len(), 2); - assert_eq!(seq("f5")[0].code, KeyCode::F(5)); - assert!(parse_sequence("").is_err()); - assert!(parse_sequence("ctrl+nope").is_err()); - assert!(parse_key("f13").is_none()); - } - - #[test] - fn custom_replaces_default_keys() { - let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap(); - assert_eq!(run(&map, "Q"), Some(Action::Quit)); - assert_eq!(run(&map, "q"), None); - assert_eq!(run(&map, "e"), Some(Action::MoveDown)); - assert_eq!(run(&map, "j"), None); - assert_eq!(run(&map, "k"), Some(Action::MoveUp)); - } - - #[test] - fn custom_wins_over_other_defaults() { - let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap(); - assert_eq!(run(&map, "x"), Some(Action::Quit)); - assert_eq!(run(&map, "dd"), Some(Action::Delete)); - } - - #[test] - fn single_string_and_disable() { - let toml = "quit = \"Q\"\nshuffle = []"; - let keys: HashMap = toml::from_str(toml).unwrap(); - let map = Keymap::build(&keys).unwrap(); - assert_eq!(run(&map, "Q"), Some(Action::Quit)); - assert_eq!(run(&map, "s"), None); - } - - #[test] - fn bad_config_is_rejected() { - assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok()); - } -} diff --git a/src/lyrics.rs b/src/lyrics.rs index 373c8fa..9b29d69 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, Track}; +use crate::model::{parse_clock, split_artists, 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.cmp(&b.0).then(a.1.total_cmp(&b.1))); + found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap()); found.into_iter().next().map(|(_, _, l)| l) } @@ -122,8 +122,7 @@ fn query(params: &[(&str, &str)]) -> Result, String> { } pub fn fetch(track: &Track) -> Result, String> { - let artist = track - .artist_names() + let artist = split_artists(&track.artist) .into_iter() .next() .unwrap_or_default(); diff --git a/src/main.rs b/src/main.rs index b43714a..5a44a69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,6 @@ mod app; mod command; mod config; -mod discord; -mod keys; mod lyrics; mod model; mod mpris; diff --git a/src/model.rs b/src/model.rs index aef6d38..0f7a156 100644 --- a/src/model.rs +++ b/src/model.rs @@ -5,9 +5,6 @@ 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)] @@ -19,16 +16,6 @@ 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) } @@ -42,75 +29,21 @@ 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(); - 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; - } + 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()); } - 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 } @@ -200,45 +133,9 @@ 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 a4ea4b2..8229dbd 100644 --- a/src/player.rs +++ b/src/player.rs @@ -6,8 +6,6 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Result}; use serde_json::{json, Value}; -use crate::config::Audio; - /// Platform-specific transport for mpv's JSON IPC. /// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). #[cfg(unix)] @@ -142,7 +140,8 @@ impl Player { "--really-quiet", "--audio-display=no", "--force-window=no", - "--ytdl=no", + "--ytdl=yes", + "--ytdl-format=bestaudio/best", "--cache=yes", "--demuxer-max-bytes=32MiB", ]) @@ -212,44 +211,16 @@ 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; }; - 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, - _ => {} + if let Some(ev) = parse_event(&v) { + on_event(ev); } - 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); }); @@ -270,9 +241,9 @@ impl Player { matches!(self.child.try_wait(), Ok(None)) } - pub fn load(&mut self, path: &str) -> Result<()> { + pub fn load(&mut self, url: &str) -> Result<()> { self.send(json!(["set_property", "pause", false]))?; - self.send(json!(["loadfile", path, "replace"])) + self.send(json!(["loadfile", url, "replace"])) } pub fn set_pause(&mut self, pause: bool) -> Result<()> { @@ -311,120 +282,6 @@ impl Drop for Player { } } -const KEEP_FILES: usize = 40; - -/// Base yt-dlp command with the user's cookie setting applied. -fn ytdlp(audio: &Audio) -> Command { - let mut cmd = Command::new("yt-dlp"); - if let Some(browser) = audio.cookies() { - cmd.arg("--cookies-from-browser").arg(browser); - } - cmd -} - -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, audio: &Audio) -> 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 = ytdlp(audio); - 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())) -} - -/// Ask yt-dlp for the direct media URL so mpv can start playing right away -/// instead of waiting for the whole file to download. -pub fn resolve_stream(id: &str, audio: &Audio) -> Result { - let mut cmd = ytdlp(audio); - cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"]) - .arg(format!("https://music.youtube.com/watch?v={id}")) - .stdin(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?"))?; - let stdout = String::from_utf8_lossy(&out.stdout); - if let Some(url) = stdout - .lines() - .map(str::trim) - .find(|l| l.starts_with("http")) - .filter(|_| out.status.success()) - { - return Ok(url.to_string()); - } - 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 returned no stream URL".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" => { @@ -502,19 +359,6 @@ 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 599e620..45dc9a1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -22,37 +22,6 @@ 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()) } @@ -106,7 +75,6 @@ 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 6216d3d..ee05cde 100644 --- a/src/store.rs +++ b/src/store.rs @@ -10,32 +10,6 @@ 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<()> { @@ -110,10 +84,7 @@ impl Store { } }; match serde_json::from_str::(&text) { - Ok(mut s) => { - s.base = s.playlists.clone(); - (s, None) - } + Ok(s) => (s, None), Err(e) => { let bak = path.with_extension("json.bak"); let _ = fs::rename(&path, &bak); @@ -128,18 +99,8 @@ impl Store { } } - 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 save(&self) -> Result<()> { + write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) } pub fn resolve_exact(&self, name: &str) -> Option { @@ -173,33 +134,6 @@ 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 1f27bdf..a4321db 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,19 +10,11 @@ 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(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(), - } + Style::new() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD) } fn header_style() -> Style { @@ -123,19 +115,21 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { ("Queue", app.queue.len()), ("Playlists", app.store.playlists.len()), ]; - let active = Style::new() - .fg(app.accent) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + let pill = Style::new().fg(Color::Black).bg(app.accent); 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(format!("{title} {count}"), active)); + 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))); } else { - spans.push(Span::styled(title, Style::new().fg(Color::Gray))); - spans.push(Span::styled(format!(" {count}"), dim())); + 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::raw(" ")); + spans.push(Span::raw(" ")); } f.render_widget(Paragraph::new(Line::from(spans)), tabs); @@ -221,7 +215,6 @@ 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]) { @@ -371,8 +364,7 @@ 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)) - .highlight_symbol(CURSOR); + .row_highlight_style(selected_style(app.accent)); f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); let sel = app.selected(); @@ -432,8 +424,8 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { if let Some(c) = app.count { pending.push_str(&c.to_string()); } - for key in &app.pending { - pending.push_str(&key.to_string()); + if let Some(p) = app.pending { + pending.push(p); } if !pending.is_empty() { block = block.title_bottom( @@ -702,7 +694,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(120); + let max_text = (body.width as usize).saturating_sub(8).min(70); 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::() + "…"; @@ -806,8 +798,6 @@ const CMD_INFO: &[(&str, &str)] = &[ ("shuffle", "toggle shuffle"), ("repeat", "repeat · off | all | one"), ("save", "save the queue as a playlist"), - ("savealbum", "save album as a playlist · savealbum [name]"), - ("saveresults", "save results as a playlist · saveresults [name]"), ("load", "load a playlist into the queue"), ("append", "append a playlist to the queue"), ("delpl", "delete a playlist"), @@ -941,7 +931,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!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent)) + Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) } else { Line::from(vec![ Span::styled( @@ -1011,7 +1001,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!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(accent)); + return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(accent)); } let danger = matches!( it.action, @@ -1132,15 +1122,7 @@ const HELP: &[(&str, &str)] = &[ (":clear", "empty the queue"), ( ":save n :load n", - "save the queue as a playlist / load one (:save! n replaces an existing one)", - ), - ( - ":savealbum [n]", - "save album or current album results as a playlist", - ), - ( - ":saveresults [n]", - "save current results as a playlist", + "save the queue as a playlist / load one", ), ( ":append n :delpl n", @@ -1236,7 +1218,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(cursor_text(&text), selected_style(app.accent)) + Line::styled(text, selected_style(app.accent)) } else { Line::raw(text) } @@ -1285,16 +1267,13 @@ 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!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent)) + Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) } else { Line::raw(text) } }) .collect(); - lines.push(Line::styled( - " Enter select · s save playlist · j k move · Esc cancel", - dim(), - )); + lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim())); f.render_widget(Clear, popup); f.render_widget( Paragraph::new(lines).block( @@ -1371,7 +1350,6 @@ 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 f356c95..97dd7f1 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -1,5 +1,4 @@ use std::process::{Command, Stdio}; -use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, bail, Context, Result}; @@ -177,7 +176,30 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) { } fn search_innertube(query: &str) -> Result { - let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?; + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(12)) + .user_agent(UA) + .build(); + let body = json!({ + "context": { "client": { + "clientName": "WEB_REMIX", + "clientVersion": client_version(), + "hl": "en", + "gl": "US", + }}, + "query": query, + "params": SONGS_FILTER, + }); + let resp = agent + .post(ENDPOINT) + .set("Origin", "https://music.youtube.com") + .set("Referer", "https://music.youtube.com/") + .set("Cookie", "SOCS=CAI") + .send_json(body) + .map_err(|e| anyhow!("request failed: {e}"))?; + let v: Value = resp + .into_json() + .context("invalid JSON from YouTube Music")?; Ok(page_from(&v)) } @@ -210,25 +232,18 @@ 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/") @@ -301,42 +316,13 @@ 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(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 + run_segments( + item.get("flexColumns") + .and_then(|c| c.get(col)) + .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) + .and_then(Value::as_array), + ) } fn run_segments(runs: Option<&Vec>) -> Vec { @@ -398,7 +384,6 @@ 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")) @@ -408,7 +393,6 @@ fn parse_item(item: &Value) -> Option { id: id.to_string(), title, artist, - artists, album, duration, }) @@ -513,13 +497,11 @@ fn parse_panel_item(item: &Value) -> Option { if title.is_empty() { return None; } - let byline = item.pointer("/longBylineText/runs").and_then(Value::as_array); - let meta = run_segments(byline); + let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array)); let artist = meta .first() .cloned() .unwrap_or_else(|| "Unknown artist".into()); - let artists = artist_names_from_runs(byline, &artist); let album = meta .get(1) .filter(|a| { @@ -538,7 +520,6 @@ fn parse_panel_item(item: &Value) -> Option { id: id.to_string(), title, artist, - artists, album, duration, }) @@ -604,24 +585,11 @@ 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, }) @@ -680,36 +648,6 @@ 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": {