Compare commits

..
23 changed files with 406 additions and 4920 deletions

View file

@ -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

View file

@ -1,40 +1,32 @@
# Changelog # Changelog
Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). All notable changes to Drift are documented in this file.
## Unreleased The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added ### Added
- Discord rich presence. Your current song shows up in your Discord status as "Listening to ...", with cover art, album, a progress bar and a "Listen Along" button. Paused songs show `(paused)` and drop the timer. Off by default: make an application at https://discord.com/developers/applications (its name is what shows after "Listening to"), paste the Application ID into `client_id` under the new `[discord]` section of `config.toml`. If discord isnt running drift keeps retrying quietly in the background. - **Windows support.** Drift now runs natively on Windows. It talks to `mpv` over named pipes instead of Unix sockets.
- `: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). - **Song context menu.** Press `G` on a song or playlist for quick actions: play, play next, add to queue, add to playlist, show artist, remove, and song info.
- 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. - **Song info popup.** "Song info" in the `G` menu opens a centered popup with the title, artist, album, length, and ID. Press any key to close it.
- `:album <query>` to search albums, `:playlist <query>` to search youtube music playlists. Results show up in a picker, enter loads the tracks. (`:playlists` is still your own playlists tab.) Big playlists stop at ~300 tracks. - **Redesigned `:` command menu.**
- `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. - **Audio visualizer** in the bottomleft corner.
- `gs` / `:similar` / `:radio` loads ~50 songs similar to the selected one and starts playing them, like youtube music radio. - **Keybind hints** shown next to the volume control.
- Synced lyrics from LRCLIB. `L` toggles the side panel, `F` for fullscreen. Current line gets a progress fill, lines around it fade. - **Status toasts.** Messages such as "Queued…" and errors now appear as a small floating toast above the Now Playing bar. Errors are red and info messages are green.
- Plain lyrics fallback when theres no synced version, scroll with `j`/`k`. - `DRIFT_CONFIG_DIR` and `DRIFT_STATE_DIR` environment variables to override where Drift stores its config, playlists, and session.
- Install hints for Scoop and winget when `mpv` or `yt-dlp` is missing.
### Changed ### Changed
- Playback now downloads the audio with yt-dlp first and plays the local file instead of letting mpv stream it (`--ytdl=no`). Files live in the cache dir under `drift/audio` (last 40 are kept). The next queued song is fetched in the background while the current one plays, so track changes are quicker. Songs that are already cached start instantly. - Status messages no longer share the bottom border of the Now Playing bar. That border now only shows pending key presses.
- 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). - On Windows, `mpv` and `yt-dlp` start without flashing a console window.
- 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. - On Windows, `mpv` is stopped when Drift exits, including after a crash. This matches the existing Linux behavior.
- Selected rows use an accent colored bold text + `▎` bar instead of a filled background. - Saving playlists and the session retries a few times if the file is briefly locked, which can happen on Windows with antivirus software.
- 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.) - Ctrl+Alt is no longer treated as Ctrl. On Windows, AltGr is reported as Ctrl+Alt, so characters like `@`, `\`, `{`, `[`, and `~` can now be typed in search and `:` commands.
- 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.
### Fixed ### Fixed
- Running several drifts at once no longer clobbers stuff. Only the first one restores and saves the session (a `session.lock` file, unix only), and saving playlists merges with whatever the other instance wrote instead of overwriting it, so playlists made in another window dont disappear. - Song info no longer appears as a cramped line at the bottom of the screen.
- 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. - Tests no longer write to your real config folder on Windows.
- "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

1362
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ name = "drift"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "A Vim-style terminal music player for YouTube Music (no login required)" description = "A Vim-style terminal music player for YouTube Music (no login required)"
license = "GPL-3.0-or-later" license = "MIT"
[workspace] [workspace]
@ -16,10 +16,6 @@ toml = "0.8"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
dirs = "5" dirs = "5"
libc = "0.2" libc = "0.2"
discord-rich-presence = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
[profile.release] [profile.release]
lto = true lto = true

308
README.md
View file

@ -1,69 +1,46 @@
<div align="center"> <div align="center">
# Drift <h1>Drift</h1>
### 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) [![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](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)
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" /> ### A music streaming client using Vim keybinds built in Rust.
Stream your favourite music from YouTube Music — all in one client, in your terminal.
</div> </div>
--- ---
## Table of contents <div align="center">
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
- [Features](#features) </div>
- [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)
--- ---
## Features ## Features
- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line - **YouTube Music streaming** - search and play tracks straight from YouTube Music, with no account or login
- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung - **Vim-style keybinds** - move with `j`/`k`, jump with `gg`/`ge`, use counts like `5j`, and open the command line with `:`
- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL - **Song context menu** - press `G` on any song for quick actions: play, queue, add to playlist, show artist, and song info
- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists - **Command palette** - a redesigned `:` prompt with the full command set (`:search`, `:seek`, `:vol`, `:import`, and more)
- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info - **Audio visualizer** - a live visualizer in the bottom-left corner while music plays
- A command palette with completion and history - **Playlists & queue** - save, load, and reorder playlists, or import any YouTube / YouTube Music playlist by URL
- Your queue and position are restored the next time you start Drift - **Artist browsing** - press `ga` to jump to an artist's songs
- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence - **Session restore** - your queue and playback position come back the next time you launch
- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds
---
## Installation ## 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.
### Arch Linux (AUR) ### Arch Linux (AUR)
```bash ```bash
yay -S driftmusic-git yay -S driftmusic-git
``` ```
Any AUR helper works. You can also build it by hand: Any AUR helper works (`paru -S driftmusic-git`), or build it by hand:
```bash ```bash
git clone https://aur.archlinux.org/driftmusic-git.git git clone https://aur.archlinux.org/driftmusic-git.git
@ -71,252 +48,39 @@ cd driftmusic-git
makepkg -si makepkg -si
``` ```
`mpv` and `yt-dlp` are pulled in automatically. `mpv` and `yt-dlp` are installed automatically.
### Install script ### Other distros
Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo. Install the dependencies:
- [mpv](https://mpv.io/) — audio playback
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube stream extraction
- [Rust](https://rustup.rs/) (build only)
Then build from source:
```bash ```bash
git clone https://github.com/socatlolmeow/drift.git git clone https://github.com/socatlolmeow/drift.git
cd drift cd drift
./install.sh cargo build --release
sudo cp target/release/drift /usr/local/bin/
``` ```
### From source Or install directly with cargo:
Install the dependencies and [Rust](https://rustup.rs/), then:
```bash ```bash
git clone https://github.com/socatlolmeow/drift.git
cd drift
cargo install --path . cargo install --path .
``` ```
This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/).
### Nix ## Star History
The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you: [![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift)
```bash ---
nix run github:socatlolmeow/drift
```
### Installing mpv and yt-dlp manually
| Platform | Command |
| --- | --- |
| Debian / Ubuntu | `sudo apt install mpv` and `pipx install yt-dlp` |
| macOS | `brew install mpv yt-dlp` |
| 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
```
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.
## Keybinds
These are the defaults. You can change them, see [Custom keys](#custom-keys).
| Key | Action |
| --- | --- |
| `j` `k` / `↓` `↑` | move (counts work: `5j`) |
| `gg` `ge` | jump to top / bottom (`5gg` goes to row 5) |
| `Tab` `Shift-Tab` | cycle Results / Queue / Playlists |
| `gr` `gq` `gp` | jump to Results / Queue / Playlists |
| `Enter` | play the selected song |
| `Space` | pause / resume |
| `n` `N` | next / previous track |
| `h` `l` / `←` `→` | seek back / forward (counts: `6l`) |
| `+` `-` | volume up / down |
| `s` `r` | toggle shuffle / cycle repeat |
| `a` `A` `P` | queue the song / queue all results / play all results |
| `dd` `x` | remove from queue or playlist |
| `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).
| 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`.
Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open.
## Configuration
Settings live in `config.toml` inside Drift's config directory (`~/.config/drift/` on Linux). Drift only reads the file and never rewrites it, so your comments are safe. Restart Drift after editing.
```toml
[playback]
volume = 80
shuffle = false
repeat = "off" # off, all or one
autoplay = false
restore_session = true
seek_step = 5.0
volume_step = 5.0
[artist]
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 <query>` 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.
## Contributing
Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests:
```bash
cargo build
cargo test
```
Recent changes are listed in [CHANGELOG.md](CHANGELOG.md).
## License ## License
[Drift](https://github.com/socatlolmeow/drift) is free software, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html). [Drift](https://github.com/socatlolmeow/drift) is FOSS, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 5.7 MiB

Before After
Before After

27
flake.lock generated
View file

@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792488032,
"narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e513db7b0a65def6f6e17bb55195dfe510871944",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,26 +0,0 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAll = nixpkgs.lib.genAttrs systems;
in {
packages = forAll (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.rustPlatform.buildRustPackage {
pname = "drift";
version = "1.3";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.makeWrapper ];
postInstall = ''
wrapProgram $out/bin/drift \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.mpv pkgs.yt-dlp ]}
'';
meta.mainProgram = "drift";
};
});
};
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -30,19 +30,12 @@ pub enum Command {
Shuffle, Shuffle,
Repeat(Option<RepeatMode>), Repeat(Option<RepeatMode>),
Save(String), Save(String),
SaveForce(String),
SaveAlbum(Option<String>),
SaveResults(Option<String>),
Load(String), Load(String),
Append(String), Append(String),
DeletePlaylist(String), DeletePlaylist(String),
AddTo(String), AddTo(String),
Import(String), Import(String),
Artist, Artist,
Similar,
Album(Option<String>),
PlaylistSearch(String),
Lyrics,
Help, Help,
} }
@ -69,18 +62,12 @@ pub const COMMAND_NAMES: &[&str] = &[
"shuffle", "shuffle",
"repeat", "repeat",
"save", "save",
"savealbum",
"saveresults",
"load", "load",
"append", "append",
"delpl", "delpl",
"addto", "addto",
"import", "import",
"artist", "artist",
"similar",
"album",
"playlist",
"lyrics",
"help", "help",
]; ];
@ -177,9 +164,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
other => return Err(format!(":repeat expects off|all|one, got '{other}'")), other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
}, },
"save" | "w" => Save(name_arg(arg, "save")?), "save" | "w" => Save(name_arg(arg, "save")?),
"save!" | "w!" => SaveForce(name_arg(arg, "save!")?),
"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")?), "load" | "e" => Load(name_arg(arg, "load")?),
"append" => Append(name_arg(arg, "append")?), "append" => Append(name_arg(arg, "append")?),
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
@ -191,15 +175,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
Import(arg.to_string()) Import(arg.to_string())
} }
"artist" | "ar" => Artist, "artist" | "ar" => Artist,
"similar" | "radio" | "sim" => Similar,
"album" | "albums" | "al" => Album((!arg.is_empty()).then(|| arg.to_string())),
"playlist" => {
if arg.is_empty() {
return Err("usage: :playlist \"query\"".into());
}
PlaylistSearch(arg.to_string())
}
"lyrics" | "ly" => Lyrics,
"help" | "h" => Help, "help" | "h" => Help,
other => return Err(format!("unknown command: :{other} (try :help)")), other => return Err(format!("unknown command: :{other} (try :help)")),
}; };
@ -263,20 +238,6 @@ mod tests {
fn artist_command() { fn artist_command() {
assert_eq!(parse("artist"), Ok(Some(Command::Artist))); assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
assert_eq!(parse(":ar"), Ok(Some(Command::Artist))); assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
assert_eq!(parse("similar"), Ok(Some(Command::Similar)));
assert_eq!(parse(":radio"), Ok(Some(Command::Similar)));
assert_eq!(parse("album"), Ok(Some(Command::Album(None))));
assert_eq!(
parse("album daft punk"),
Ok(Some(Command::Album(Some("daft punk".into()))))
);
assert_eq!(
parse("playlist lofi"),
Ok(Some(Command::PlaylistSearch("lofi".into())))
);
assert!(parse("playlist").is_err());
assert_eq!(parse("playlists"), Ok(Some(Command::ShowPlaylists)));
} }
#[test] #[test]
@ -286,22 +247,5 @@ mod tests {
Ok(Some(Command::Save("my mix".into()))) Ok(Some(Command::Save("my mix".into())))
); );
assert!(parse("save").is_err()); assert!(parse("save").is_err());
assert_eq!(
parse("save! my mix"),
Ok(Some(Command::SaveForce("my mix".into())))
);
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()))))
);
} }
} }

View file

@ -1,35 +1,13 @@
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use anyhow::Result; use anyhow::Result;
use ratatui::style::Color; use serde::Deserialize;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer};
use crate::keys::{Keymap, Keys};
use crate::model::{RepeatMode, Track}; use crate::model::{RepeatMode, Track};
use crate::session::Session; use crate::session::Session;
use crate::store::{write_atomic, Store}; use crate::store::{write_atomic, Store};
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: Deserializer<'de>,
{
struct ColorVisitor;
impl<'de> Visitor<'de> for ColorVisitor {
type Value = Color;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a color name (e.g. \"cyan\") or hex value (e.g. \"#FF8800\")")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Color, E> {
Color::from_str(v).map_err(de::Error::custom)
}
}
deserializer.deserialize_str(ColorVisitor)
}
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Playback { pub struct Playback {
@ -76,79 +54,11 @@ pub struct Artist {
pub popular_first: bool, pub popular_first: bool,
} }
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Discord {
pub client_id: String,
}
#[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<u32>) -> 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
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(default)]
pub struct Ui {
#[serde(deserialize_with = "deserialize_color")]
pub accent: Color,
}
impl Default for Ui {
fn default() -> Self {
Ui { accent: default_accent() }
}
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub playback: Playback, pub playback: Playback,
pub artist: Artist, pub artist: Artist,
pub ui: Ui,
pub discord: Discord,
pub audio: Audio,
pub keys: HashMap<String, Keys>,
#[serde(skip)]
pub keymap: Keymap,
} }
impl Config { impl Config {
@ -156,27 +66,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml")) 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) -> String {
let accent = match u.accent {
Color::Reset => "reset",
Color::Black => "black",
Color::Red => "red",
Color::Green => "green",
Color::Yellow => "yellow",
Color::Blue => "blue",
Color::Magenta => "magenta",
Color::Cyan => "cyan",
Color::Gray => "gray",
Color::DarkGray => "dark-gray",
Color::LightRed => "light-red",
Color::LightGreen => "light-green",
Color::LightYellow => "light-yellow",
Color::LightBlue => "light-blue",
Color::LightMagenta => "light-magenta",
Color::LightCyan => "light-cyan",
Color::White => "white",
_ => "cyan",
};
format!( format!(
"\ "\
# Drift settings. Drift only reads this file and never rewrites it, so your # Drift settings. Drift only reads this file and never rewrites it, so your
@ -194,44 +84,6 @@ volume_step = {vstep:?}
[artist] [artist]
# :artist and ga list the artist's most popular songs first, then more of their songs below # :artist and ga list the artist's most popular songs first, then more of their songs below
popular_first = {popular} popular_first = {popular}
[ui]
# Accent color used throughout the UI.
# Named colors: black, red, green, yellow, blue, magenta, cyan, gray, dark-gray,
# light-red, light-green, light-yellow, light-blue, light-magenta,
# 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, volume = p.volume,
shuffle = p.shuffle, shuffle = p.shuffle,
@ -241,11 +93,6 @@ cookies_from_browser = \"{cookies}\"
seek = p.seek_step, seek = p.seek_step,
vstep = p.volume_step, vstep = p.volume_step,
popular = a.popular_first, 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 +107,7 @@ cookies_from_browser = \"{cookies}\"
let text = match fs::read_to_string(&path) { let text = match fs::read_to_string(&path) {
Ok(t) => t, Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); let _ = Self::write(&Self::render(&Playback::default(), &Artist::default()));
return (Config::default(), None); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
@ -273,14 +120,7 @@ cookies_from_browser = \"{cookies}\"
match toml::from_str::<Config>(&text) { match toml::from_str::<Config>(&text) {
Ok(mut c) => { Ok(mut c) => {
c.playback.sanitize(); c.playback.sanitize();
let warning = match Keymap::build(&c.keys) { (c, None)
Ok(keymap) => {
c.keymap = keymap;
None
}
Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")),
};
(c, warning)
} }
Err(e) => { Err(e) => {
let bak = path.with_extension("toml.bak"); let bak = path.with_extension("toml.bak");
@ -346,7 +186,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s); 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()));
} }
#[cfg(test)] #[cfg(test)]
@ -355,7 +195,7 @@ mod tests {
#[test] #[test]
fn default_file_parses_back() { fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()); let text = Config::render(&Playback::default(), &Artist::default());
let c: Config = toml::from_str(&text).unwrap(); let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default(); let d = Playback::default();
assert_eq!(c.playback.volume, d.volume); assert_eq!(c.playback.volume, d.volume);
@ -398,76 +238,4 @@ mod tests {
p.sanitize(); p.sanitize();
assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0));
} }
#[test]
fn accent_color_parsing() {
// Default is cyan
let c: Config = toml::from_str("").unwrap();
assert_eq!(c.ui.accent, Color::Cyan);
// Named colors
let c: Config = toml::from_str("[ui]\naccent = \"red\"").unwrap();
assert_eq!(c.ui.accent, Color::Red);
let c: Config = toml::from_str("[ui]\naccent = \"green\"").unwrap();
assert_eq!(c.ui.accent, Color::Green);
let c: Config = toml::from_str("[ui]\naccent = \"magenta\"").unwrap();
assert_eq!(c.ui.accent, Color::Magenta);
// Hex value
let c: Config = toml::from_str("[ui]\naccent = \"#FF8800\"").unwrap();
assert_eq!(c.ui.accent, Color::Rgb(255, 136, 0));
// Invalid color is an error
assert!(toml::from_str::<Config>("[ui]\naccent = \"notacolor\"").is_err());
// Roundtrip: render then parse
let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default());
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"));
}
} }

View file

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

View file

@ -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<usize> {
match self.code {
KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize),
_ => None,
}
}
}
impl From<KeyEvent> 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<String>),
}
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<Key>, Action)>);
impl Default for Keymap {
fn default() -> Keymap {
Keymap::build(&HashMap::new()).expect("default keys are valid")
}
}
impl Keymap {
pub fn build(custom: &HashMap<String, Keys>) -> Result<Keymap, String> {
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<Key>, 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<Vec<Key>, 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<Key> {
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::<u8>() {
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<String, Keys> {
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<Key> {
parse_sequence(text).unwrap()
}
fn run(map: &Keymap, text: &str) -> Option<Action> {
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<String, Keys> = 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());
}
}

View file

@ -1,201 +0,0 @@
use std::time::Duration;
use serde_json::Value;
use crate::model::{parse_clock, Track};
const SEARCH: &str = "https://lrclib.net/api/search";
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
const LEAD: f64 = 0.25;
const MAX_DRIFT: f64 = 8.0;
#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub at: f64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Lyrics {
pub lines: Vec<Line>,
pub synced: bool,
}
impl Lyrics {
pub fn active(&self, position: f64) -> Option<usize> {
if !self.synced {
return None;
}
let n = self.lines.partition_point(|l| l.at <= position + LEAD);
n.checked_sub(1)
}
pub fn progress(&self, i: usize, position: f64) -> f64 {
let Some(line) = self.lines.get(i) else {
return 0.0;
};
let sing = line.text.chars().count() as f64 * 0.1 + 1.0;
let span = match self.lines.get(i + 1) {
Some(next) => (next.at - line.at).min(sing),
None => sing,
};
((position - line.at) / span.max(0.1)).clamp(0.0, 1.0)
}
}
pub fn parse_lrc(text: &str) -> Vec<Line> {
let mut out = Vec::new();
for raw in text.lines() {
let mut rest = raw.trim();
let mut stamps = Vec::new();
while let Some(tail) = rest.strip_prefix('[') {
let Some((tag, after)) = tail.split_once(']') else {
break;
};
let Some(t) = parse_clock(tag) else {
break;
};
stamps.push(t);
rest = after;
}
let text = rest.trim();
for at in stamps {
out.push(Line {
at,
text: text.to_string(),
});
}
}
out.sort_by(|a, b| a.at.total_cmp(&b.at));
out
}
fn from_entry(entry: &Value) -> Option<Lyrics> {
let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty());
if let Some(s) = text("syncedLyrics") {
let lines = parse_lrc(s);
if !lines.is_empty() {
return Some(Lyrics { lines, synced: true });
}
}
let plain = text("plainLyrics")?;
let lines = plain
.lines()
.map(|l| Line {
at: 0.0,
text: l.trim().to_string(),
})
.collect();
Some(Lyrics {
lines,
synced: false,
})
}
fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
let drift = |e: &Value| match (duration, e["duration"].as_f64()) {
(Some(want), Some(got)) => (got - f64::from(want)).abs(),
_ => 0.0,
};
let mut found: Vec<(bool, f64, Lyrics)> = entries
.iter()
.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.into_iter().next().map(|(_, _, l)| l)
}
fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(10))
.user_agent(UA)
.build();
let mut req = agent.get(SEARCH);
for (k, v) in params {
req = req.query(k, v);
}
req.call()
.map_err(|e| e.to_string())?
.into_json::<Vec<Value>>()
.map_err(|e| e.to_string())
}
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
let artist = track
.artist_names()
.into_iter()
.next()
.unwrap_or_default();
let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?;
if let Some(l) = pick(&exact, track.duration) {
return Ok(Some(l));
}
let loose = query(&[("q", &format!("{} {}", track.title, artist))])?;
Ok(pick(&loose, track.duration))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_timestamps_and_skips_metadata() {
let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]");
let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect();
assert_eq!(
got,
[
(12.5, "first"),
(30.25, "shared"),
(45.0, ""),
(62.0, "shared")
]
);
}
#[test]
fn finds_active_line() {
let l = Lyrics {
lines: parse_lrc("[00:10.00]a\n[00:20.00]b"),
synced: true,
};
assert_eq!(l.active(5.0), None);
assert_eq!(l.active(10.0), Some(0));
assert_eq!(l.active(19.9), Some(1));
assert_eq!(l.active(99.0), Some(1));
}
#[test]
fn progress_stays_in_range() {
let l = Lyrics {
lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"),
synced: true,
};
assert_eq!(l.progress(0, 9.0), 0.0);
assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6);
assert_eq!(l.progress(0, 30.0), 1.0);
}
#[test]
fn prefers_synced_and_close_duration() {
let entries = [
json!({"duration": 200.0, "plainLyrics": "plain"}),
json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}),
json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}),
json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}),
];
let l = pick(&entries, Some(200)).unwrap();
assert!(l.synced);
assert_eq!(l.lines[0].text, "nearest");
}
#[test]
fn falls_back_to_plain_lyrics() {
let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})];
let l = pick(&entries, Some(200)).unwrap();
assert!(!l.synced);
assert_eq!(l.lines.len(), 2);
}
}

View file

@ -1,11 +1,7 @@
mod app; mod app;
mod command; mod command;
mod config; mod config;
mod discord;
mod keys;
mod lyrics;
mod model; mod model;
mod mpris;
mod player; mod player;
mod session; mod session;
mod store; mod store;
@ -131,12 +127,6 @@ fn run(initial_query: Option<String>) -> Result<()> {
} }
let mut terminal = ratatui::try_init()?; let mut terminal = ratatui::try_init()?;
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name() == Some("main") {
hook(info);
}
}));
let result = event_loop(&mut terminal, &mut app, &rx); let result = event_loop(&mut terminal, &mut app, &rx);
ratatui::restore(); ratatui::restore();
app.shutdown(); app.shutdown();

View file

@ -5,9 +5,6 @@ pub struct Track {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub artist: String, pub artist: String,
/* individual credited artists when the source gave them to us */
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)] #[serde(default)]
pub album: Option<String>, pub album: Option<String>,
#[serde(default)] #[serde(default)]
@ -19,98 +16,26 @@ impl Track {
format!("https://music.youtube.com/watch?v={}", self.id) format!("https://music.youtube.com/watch?v={}", self.id)
} }
/* Individual artists: the structured list if we have one, else a guess
* made by splitting the display string (old saves, plain yt-dlp). */
pub fn artist_names(&self) -> Vec<String> {
if self.artists.is_empty() {
split_artists(&self.artist)
} else {
self.artists.clone()
}
}
pub fn label(&self) -> String { pub fn label(&self) -> String {
format!("{} — {}", self.title, self.artist) format!("{} — {}", self.title, self.artist)
} }
} }
#[derive(Debug, Clone, PartialEq)] pub fn split_artists(credit: &str) -> Vec<String> {
pub struct Collection { const MARK: char = '\u{1}';
pub browse_id: String, let mut s = credit.to_string();
pub title: String, for sep in [
pub subtitle: String, " & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ",
pub artist: String, ] {
s = s.replace(sep, &MARK.to_string());
} }
let mut out: Vec<String> = Vec::new();
/* names that contain a separator and must stay in one piece */ for part in s.split(MARK) {
const WHOLE_NAMES: &[&str] = &[ let name = part.trim();
"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<String>, raw: &str) {
let name = raw.trim();
if !name.is_empty() && !out.iter().any(|o| o == name) { if !name.is_empty() && !out.iter().any(|o| o == name) {
out.push(name.to_string()); out.push(name.to_string());
} }
} }
/* Best-effort split of a flattened credit string. Only a fallback: prefer
* Track::artists, which comes straight from the source's own boundaries. */
pub fn split_artists(credit: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut start = 0;
let mut i = 0;
while i < credit.len() {
let rest = &credit[i..];
let at_piece_start = credit[start..i].trim().is_empty();
if at_piece_start {
if let Some(name) = WHOLE_NAMES.iter().find(|n| starts_with_ci(rest, n)) {
i += name.len();
continue;
}
}
if let Some(sep) = SEPARATORS.iter().find(|sep| starts_with_ci(rest, sep)) {
push_name(&mut out, &credit[start..i]);
i += sep.len();
start = i;
continue;
}
i += rest.chars().next().map_or(1, char::len_utf8);
}
push_name(&mut out, &credit[start..]);
out out
} }
@ -200,45 +125,9 @@ mod tests {
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
assert_eq!(split_artists("A feat. B"), ["A", "B"]); assert_eq!(split_artists("A feat. B"), ["A", "B"]);
assert_eq!(split_artists("A, A"), ["A"]); assert_eq!(split_artists("A, A"), ["A"]);
assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]);
assert_eq!(
split_artists("Tyler, The Creator & Kali Uchis"),
["Tyler, The Creator", "Kali Uchis"]
);
assert_eq!(
split_artists("Earth, Wind & Fire, Someone"),
["Earth, Wind & Fire", "Someone"]
);
assert!(split_artists(" ").is_empty()); assert!(split_artists(" ").is_empty());
} }
#[test]
fn artist_splitting_edge_cases() {
assert_eq!(split_artists("hall & oates"), ["hall & oates"]);
assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]);
assert_eq!(split_artists("A Feat. B"), ["A", "B"]);
assert_eq!(
split_artists("Drake, Hall & Oates"),
["Drake", "Hall & Oates"]
);
assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]);
}
#[test]
fn artist_names_prefers_structured_list() {
let mut t = Track {
id: "abcdefghijk".into(),
title: "T".into(),
artist: "Blood, Sweat & Tears".into(),
artists: vec!["Blood, Sweat & Tears".into()],
album: None,
duration: None,
};
assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]);
t.artists.clear();
assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]);
}
#[test] #[test]
fn ids() { fn ids() {
assert!(is_valid_id("dQw4w9WgXcQ")); assert!(is_valid_id("dQw4w9WgXcQ"));

View file

@ -1,162 +0,0 @@
/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Cmd {
Play,
Pause,
Toggle,
Stop,
Next,
Prev,
SeekBy(f64),
SeekTo(f64),
Quit,
}
#[cfg(target_os = "linux")]
mod imp {
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
PlatformConfig, SeekDirection,
};
use super::Cmd;
use crate::app::PlayState;
use crate::model::Track;
pub struct Mpris {
controls: MediaControls,
track: Option<String>,
state: PlayState,
secs: u64,
}
impl Mpris {
/* None when there is no session bus. */
pub fn new(send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
let name = format!("drift.instance{}", std::process::id());
let config = PlatformConfig {
dbus_name: &name,
display_name: "drift",
hwnd: None,
};
let mut controls = MediaControls::new(config).ok()?;
controls
.attach(move |ev| {
if let Some(c) = convert(ev) {
send(c);
}
})
.ok()?;
Some(Mpris {
controls,
track: None,
state: PlayState::Stopped,
secs: 0,
})
}
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
let id = track.map(|t| t.id.as_str());
if id != self.track.as_deref() {
self.track = id.map(str::to_string);
let cover = track.map(|t| format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", t.id));
let meta = match track {
Some(t) => MediaMetadata {
title: Some(t.title.as_str()),
album: t.album.as_deref(),
artist: Some(t.artist.as_str()),
cover_url: cover.as_deref(),
duration: t.duration.map(|d| Duration::from_secs(u64::from(d))),
},
None => MediaMetadata::default(),
};
let _ = self.controls.set_metadata(meta);
self.secs = u64::MAX;
}
let pos = if position.is_finite() { position.max(0.0) } else { 0.0 };
let secs = pos as u64;
if state == self.state && secs == self.secs {
return;
}
self.state = state;
self.secs = secs;
let progress = Some(MediaPosition(Duration::from_secs_f64(pos)));
let _ = self.controls.set_playback(match state {
PlayState::Playing | PlayState::Loading => MediaPlayback::Playing { progress },
PlayState::Paused => MediaPlayback::Paused { progress },
PlayState::Stopped => MediaPlayback::Stopped,
});
}
}
fn dir(d: SeekDirection, secs: f64) -> f64 {
if d == SeekDirection::Backward {
-secs
} else {
secs
}
}
pub(super) fn convert(ev: MediaControlEvent) -> Option<Cmd> {
Some(match ev {
MediaControlEvent::Play => Cmd::Play,
MediaControlEvent::Pause => Cmd::Pause,
MediaControlEvent::Toggle => Cmd::Toggle,
MediaControlEvent::Next => Cmd::Next,
MediaControlEvent::Previous => Cmd::Prev,
MediaControlEvent::Stop => Cmd::Stop,
MediaControlEvent::Seek(d) => Cmd::SeekBy(dir(d, 5.0)),
MediaControlEvent::SeekBy(d, by) => Cmd::SeekBy(dir(d, by.as_secs_f64())),
MediaControlEvent::SetPosition(MediaPosition(p)) => Cmd::SeekTo(p.as_secs_f64()),
MediaControlEvent::Quit => Cmd::Quit,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_events() {
assert_eq!(convert(MediaControlEvent::Previous), Some(Cmd::Prev));
assert_eq!(
convert(MediaControlEvent::SeekBy(
SeekDirection::Backward,
Duration::from_secs(10)
)),
Some(Cmd::SeekBy(-10.0))
);
assert_eq!(
convert(MediaControlEvent::SetPosition(MediaPosition(
Duration::from_secs(90)
))),
Some(Cmd::SeekTo(90.0))
);
assert_eq!(convert(MediaControlEvent::Raise), None);
}
}
}
#[cfg(not(target_os = "linux"))]
mod imp {
use super::Cmd;
use crate::app::PlayState;
use crate::model::Track;
pub struct Mpris;
impl Mpris {
pub fn new(_send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
None
}
pub fn update(&mut self, _track: Option<&Track>, _state: PlayState, _position: f64) {}
}
}
pub use imp::Mpris;

View file

@ -6,8 +6,6 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::config::Audio;
/// Platform-specific transport for mpv's JSON IPC. /// Platform-specific transport for mpv's JSON IPC.
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). /// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
#[cfg(unix)] #[cfg(unix)]
@ -142,7 +140,8 @@ impl Player {
"--really-quiet", "--really-quiet",
"--audio-display=no", "--audio-display=no",
"--force-window=no", "--force-window=no",
"--ytdl=no", "--ytdl=yes",
"--ytdl-format=bestaudio/best",
"--cache=yes", "--cache=yes",
"--demuxer-max-bytes=32MiB", "--demuxer-max-bytes=32MiB",
]) ])
@ -212,45 +211,17 @@ impl Player {
] { ] {
player.send(json!(["observe_property", id, prop]))?; player.send(json!(["observe_property", id, prop]))?;
} }
player.send(json!(["request_log_messages", "error"]))?;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut reason: Option<String> = None;
for line in BufReader::new(reader).lines() { for line in BufReader::new(reader).lines() {
let Ok(line) = line else { break }; let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<Value>(&line) else { let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue; continue;
}; };
match v.get("event").and_then(Value::as_str) { if let Some(ev) = parse_event(&v) {
Some("log-message") => {
let prefix = v.get("prefix").and_then(Value::as_str).unwrap_or("");
let text = v.get("text").and_then(Value::as_str).unwrap_or("").trim();
match text.strip_prefix("ERROR: ") {
Some(t) if prefix == "ytdl_hook" => reason = Some(ytdl_reason(t)),
_ if reason.is_none() && !text.is_empty() && !text.starts_with("Failed to open") => {
reason = Some(text.lines().next().unwrap_or(text).to_string());
}
_ => {}
}
continue;
}
Some("start-file") => reason = None,
_ => {}
}
let Some(mut ev) = parse_event(&v) else {
continue;
};
match &mut ev {
PlayerEvent::LoadError(msg) => {
if let Some(r) = reason.take() {
*msg = r;
}
}
PlayerEvent::FileLoaded => reason = None,
_ => {}
}
on_event(ev); on_event(ev);
} }
}
on_event(PlayerEvent::Died); on_event(PlayerEvent::Died);
}); });
@ -270,9 +241,9 @@ impl Player {
matches!(self.child.try_wait(), Ok(None)) 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!(["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<()> { 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<PathBuf> {
std::fs::read_dir(audio_dir())
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
}
pub fn fetch_audio(id: &str, audio: &Audio) -> Result<PathBuf, String> {
if let Some(path) = cached_audio(id) {
return Ok(path);
}
let dir = audio_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut cmd = 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<String, String> {
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<PlayerEvent> { fn parse_event(v: &Value) -> Option<PlayerEvent> {
match v.get("event")?.as_str()? { match v.get("event")?.as_str()? {
"property-change" => { "property-change" => {
@ -502,19 +359,6 @@ mod tests {
assert!(eof, "end-of-file not seen"); assert!(eof, "end-of-file not seen");
} }
#[test]
fn ytdl_reason_is_trimmed() {
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"),
"Sign in to confirm you're not a bot. Use --cookies."
);
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"),
"Failed to extract any player response"
);
assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo");
}
#[test] #[test]
fn event_parsing() { fn event_parsing() {
let v: Value = serde_json::from_str( let v: Value = serde_json::from_str(

View file

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

View file

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

394
src/ui.rs
View file

@ -1,28 +1,20 @@
use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Wrap}; use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs};
use ratatui::Frame; use ratatui::Frame;
use crate::app::{App, Level, LyricsState, MenuAction, Mode, PlayState, View, NO_KEY}; use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY};
use crate::lyrics::Lyrics;
use crate::model::{fmt_time, Track}; use crate::model::{fmt_time, Track};
const ACCENT: Color = Color::Cyan;
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/* Cursor row: accent-colored bold text plus a bar, no filled background. */ fn selected_style() -> Style {
const CURSOR: &str = "▎"; Style::new()
.fg(Color::Black)
fn selected_style(accent: Color) -> Style { .bg(ACCENT)
Style::new().fg(accent).add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
}
/* Swap the leading space of a menu line for the cursor bar. */
fn cursor_text(text: &str) -> String {
match text.strip_prefix(' ') {
Some(rest) => format!("{CURSOR}{rest}"),
None => text.to_string(),
}
} }
fn header_style() -> Style { fn header_style() -> Style {
@ -42,24 +34,11 @@ pub fn draw(f: &mut Frame, app: &mut App) {
]) ])
.areas(area); .areas(area);
if app.mode == Mode::Lyrics {
draw_lyrics_header(f, app, top);
draw_lyrics(f, app, body, true);
} else {
draw_tabs(f, app, top); draw_tabs(f, app, top);
let list = if app.lyrics_panel && body.width >= 70 {
let [list, side] =
Layout::horizontal([Constraint::Min(40), Constraint::Percentage(40)]).areas(body);
draw_lyrics(f, app, side, false);
list
} else {
body
};
match app.view { match app.view {
View::Results => draw_tracks(f, app, list, View::Results), View::Results => draw_tracks(f, app, body, View::Results),
View::Queue => draw_tracks(f, app, list, View::Queue), View::Queue => draw_tracks(f, app, body, View::Queue),
View::Playlists => draw_playlists(f, app, list), View::Playlists => draw_playlists(f, app, body),
}
} }
draw_now_playing(f, app, playing); draw_now_playing(f, app, playing);
if app.mode == Mode::Command { if app.mode == Mode::Command {
@ -77,9 +56,6 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if app.mode == Mode::ArtistPick { if app.mode == Mode::ArtistPick {
draw_artist_picker(f, app, area); draw_artist_picker(f, app, area);
} }
if app.mode == Mode::CollectionPick {
draw_collection_picker(f, app, area);
}
if app.mode == Mode::Info { if app.mode == Mode::Info {
draw_info(f, app, area); draw_info(f, app, area);
} }
@ -104,40 +80,30 @@ fn hints(items: &[(&str, &str)], width: u16) -> Option<Line<'static>> {
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
let [name, tabs, right] = Layout::horizontal([ let [name, tabs, right] = Layout::horizontal([
Constraint::Length(10), Constraint::Length(9),
Constraint::Min(10), Constraint::Min(10),
Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)), Constraint::Length(34),
]) ])
.areas(area); .areas(area);
f.render_widget( f.render_widget(
Paragraph::new(Line::from(vec![ Paragraph::new(" Drift").style(Style::new().fg(ACCENT).bold()),
Span::styled(" ♪ ", Style::new().fg(app.accent)),
Span::styled("Drift", Style::new().fg(app.accent).bold()),
])),
name, name,
); );
let titles = [ let titles = [
("Results", app.results.len()), format!(" Results {} ", app.results.len()),
("Queue", app.queue.len()), format!(" Queue {} ", app.queue.len()),
("Playlists", app.store.playlists.len()), format!(" Playlists {} ", app.store.playlists.len()),
]; ];
let active = Style::new() f.render_widget(
.fg(app.accent) Tabs::new(titles)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); .select(app.view.idx())
let mut spans: Vec<Span> = Vec::new(); .style(Style::new().fg(Color::Gray))
for (i, (title, count)) in titles.into_iter().enumerate() { .highlight_style(Style::new().fg(Color::Black).bg(ACCENT).bold())
spans.push(Span::raw(" ")); .divider(" "),
if i == app.view.idx() { tabs,
spans.push(Span::styled(format!("{title} {count}"), active)); );
} else {
spans.push(Span::styled(title, Style::new().fg(Color::Gray)));
spans.push(Span::styled(format!(" {count}"), dim()));
}
spans.push(Span::raw(" "));
}
f.render_widget(Paragraph::new(Line::from(spans)), tabs);
if let Some(busy) = &app.busy { if let Some(busy) = &app.busy {
let spin = SPINNER[app.tick % SPINNER.len()]; let spin = SPINNER[app.tick % SPINNER.len()];
@ -201,7 +167,7 @@ fn track_rows<'a>(
.collect() .collect()
} }
fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>, accent: Color) -> Table<'a> { fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>) -> Table<'a> {
let mut header = vec!["", " #", "Title", "Artist"]; let mut header = vec!["", " #", "Title", "Artist"];
let mut widths = vec![ let mut widths = vec![
Constraint::Length(2), Constraint::Length(2),
@ -220,8 +186,7 @@ fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>, accen
.header(Row::new(header).style(header_style())) .header(Row::new(header).style(header_style()))
.block(block) .block(block)
.column_spacing(1) .column_spacing(1)
.row_highlight_style(selected_style(accent)) .row_highlight_style(selected_style())
.highlight_symbol(CURSOR)
} }
fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) {
@ -245,7 +210,7 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
}; };
let mut block = Block::bordered() let mut block = Block::bordered()
.title(format!("{title}({}) ", tracks.len())) .title(format!("{title}({}) ", tracks.len()))
.border_style(Style::new().fg(app.accent)); .border_style(Style::new().fg(ACCENT));
if !tracks.is_empty() { if !tracks.is_empty() {
let keys: &[(&str, &str)] = if view == View::Queue { let keys: &[(&str, &str)] = if view == View::Queue {
&[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")] &[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")]
@ -274,7 +239,7 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
return empty_hint(f, block, area, hint); return empty_hint(f, block, area, hint);
} }
let rows = track_rows(tracks, playing, show_album); let rows = track_rows(tracks, playing, show_album);
let table = track_table(rows, show_album, block, app.accent); let table = track_table(rows, show_album, block);
let idx = view.idx(); let idx = view.idx();
f.render_stateful_widget(table, area, &mut app.states[idx]); f.render_stateful_widget(table, area, &mut app.states[idx]);
} }
@ -288,7 +253,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
}; };
let mut block = Block::bordered() let mut block = Block::bordered()
.title(format!(" {name} ({}) ", tracks.len())) .title(format!(" {name} ({}) ", tracks.len()))
.border_style(Style::new().fg(app.accent)); .border_style(Style::new().fg(ACCENT));
if !tracks.is_empty() { if !tracks.is_empty() {
let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")]; let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")];
if let Some(h) = hints(&keys, area.width) { if let Some(h) = hints(&keys, area.width) {
@ -309,7 +274,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
} }
let show_album = area.width >= 76; let show_album = area.width >= 76;
let rows = track_rows(tracks, playing, show_album); let rows = track_rows(tracks, playing, show_album);
let table = track_table(rows, show_album, block, app.accent); let table = track_table(rows, show_album, block);
f.render_stateful_widget(table, area, &mut app.pl_state); f.render_stateful_widget(table, area, &mut app.pl_state);
} }
@ -324,7 +289,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
let mut block = Block::bordered() let mut block = Block::bordered()
.title(format!(" Playlists ({}) ", app.store.playlists.len())) .title(format!(" Playlists ({}) ", app.store.playlists.len()))
.border_style(Style::new().fg(app.accent)); .border_style(Style::new().fg(ACCENT));
if !app.store.playlists.is_empty() { if !app.store.playlists.is_empty() {
if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) { if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) {
block = block.title_bottom(h); block = block.title_bottom(h);
@ -371,8 +336,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
) )
.header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style()))
.block(block) .block(block)
.row_highlight_style(selected_style(app.accent)) .row_highlight_style(selected_style());
.highlight_symbol(CURSOR);
f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]);
let sel = app.selected(); let sel = app.selected();
@ -384,7 +348,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
Some(tracks) if !tracks.is_empty() => { Some(tracks) if !tracks.is_empty() => {
let show_album = right.width >= 70; let show_album = right.width >= 70;
let rows = track_rows(tracks, None, show_album); let rows = track_rows(tracks, None, show_album);
f.render_widget(track_table(rows, show_album, preview_block, app.accent), right); f.render_widget(track_table(rows, show_album, preview_block), right);
} }
_ => f.render_widget(preview_block, right), _ => f.render_widget(preview_block, right),
} }
@ -392,8 +356,8 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
fn gradient(_t: f64, accent: Color) -> Color { fn gradient(_t: f64) -> Color {
accent ACCENT
} }
fn equalizer(app: &App, n: usize) -> Vec<Span<'static>> { fn equalizer(app: &App, n: usize) -> Vec<Span<'static>> {
@ -409,7 +373,7 @@ fn equalizer(app: &App, n: usize) -> Vec<Span<'static>> {
_ => 0, _ => 0,
}; };
let color = if app.play_state == PlayState::Playing { let color = if app.play_state == PlayState::Playing {
gradient(x / n as f64, app.accent) gradient(x / n as f64)
} else { } else {
Color::DarkGray Color::DarkGray
}; };
@ -422,18 +386,18 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
let active = app.play_state != PlayState::Stopped; let active = app.play_state != PlayState::Stopped;
let mut block = Block::bordered() let mut block = Block::bordered()
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::new().fg(if active { app.accent } else { Color::DarkGray })) .border_style(Style::new().fg(if active { ACCENT } else { Color::DarkGray }))
.title(Line::from(vec![ .title(Line::from(vec![
Span::styled(" ♪ ", Style::new().fg(app.accent).bold()), Span::styled(" ♪ ", Style::new().fg(ACCENT).bold()),
Span::styled("Now Playing ", Style::new().fg(app.accent).bold()), Span::styled("Now Playing ", Style::new().fg(ACCENT).bold()),
])); ]));
let mut pending = String::new(); let mut pending = String::new();
if let Some(c) = app.count { if let Some(c) = app.count {
pending.push_str(&c.to_string()); pending.push_str(&c.to_string());
} }
for key in &app.pending { if let Some(p) = app.pending {
pending.push_str(&key.to_string()); pending.push(p);
} }
if !pending.is_empty() { if !pending.is_empty() {
block = block.title_bottom( block = block.title_bottom(
@ -446,7 +410,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(block, area); f.render_widget(block, area);
let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
let on = Style::new().fg(app.accent).bold(); let on = Style::new().fg(ACCENT).bold();
let vol_cells = 5usize; let vol_cells = 5usize;
let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100; let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100;
let mut flags = vec![ let mut flags = vec![
@ -468,7 +432,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
]; ];
for i in 0..vol_cells { for i in 0..vol_cells {
flags.push(if i < vol_fill.min(vol_cells) { flags.push(if i < vol_fill.min(vol_cells) {
Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64, app.accent))) Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64)))
} else { } else {
Span::styled("─", dim()) Span::styled("─", dim())
}); });
@ -502,7 +466,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
(Some(t), true) => { (Some(t), true) => {
spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold())); spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold()));
spans.push(Span::styled(" · ", dim())); spans.push(Span::styled(" · ", dim()));
spans.push(Span::styled(t.artist.clone(), Style::new().fg(app.accent))); spans.push(Span::styled(t.artist.clone(), Style::new().fg(ACCENT)));
} }
(Some(t), false) => spans.push(Span::styled( (Some(t), false) => spans.push(Span::styled(
format!("Last played: {} — {}", t.title, t.artist), format!("Last played: {} — {}", t.title, t.artist),
@ -533,7 +497,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
line.push(if !active || app.duration <= 0.0 { line.push(if !active || app.duration <= 0.0 {
Span::styled("─", dim()) Span::styled("─", dim())
} else if i < head { } else if i < head {
Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64, app.accent))) Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64)))
} else if i == head { } else if i == head {
Span::styled("●", Style::new().fg(Color::White).bold()) Span::styled("●", Style::new().fg(Color::White).bold())
} else { } else {
@ -544,153 +508,6 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(Paragraph::new(Line::from(line)), l2); f.render_widget(Paragraph::new(Line::from(line)), l2);
} }
fn draw_lyrics_header(f: &mut Frame, app: &App, area: Rect) {
let unsynced = matches!(&app.lyrics, LyricsState::Ready(l) if !l.synced);
let mut title = vec![
Span::styled(" Drift", Style::new().fg(app.accent).bold()),
Span::styled(" · Lyrics", dim()),
];
if unsynced {
title.push(Span::styled(" · not synced", dim()));
}
f.render_widget(Paragraph::new(Line::from(title)), area);
let mut keys = vec![("space", "pause"), ("h l", "seek"), ("n N", "skip")];
if unsynced {
keys.insert(0, ("j k", "scroll"));
}
keys.push(("Esc", "close"));
if let Some(h) = hints(&keys, area.width.saturating_sub(30)) {
f.render_widget(Paragraph::new(h), area);
}
}
fn lyric_rows(
lyrics: &Lyrics,
position: f64,
width: usize,
roomy: bool,
accent: Color,
) -> (Vec<Line<'static>>, Option<usize>) {
let active = lyrics.active(position);
let mut rows: Vec<Line<'static>> = Vec::new();
let mut focus = None;
for (i, line) in lyrics.lines.iter().enumerate() {
let parts = wrap_words(&line.text, width);
let is_active = active == Some(i);
if is_active {
focus = Some(rows.len() + parts.len().max(1) / 2);
}
if parts.is_empty() {
rows.push(if is_active {
Line::styled("♪", Style::new().fg(accent).bold())
} else {
Line::raw("")
});
continue;
}
if is_active {
let total: usize = parts.iter().map(|p| p.chars().count()).sum();
let mut fill = (lyrics.progress(i, position) * total as f64).round() as usize;
for part in parts {
let done = fill.min(part.chars().count());
fill -= done;
let sung: String = part.chars().take(done).collect();
let rest: String = part.chars().skip(done).collect();
rows.push(Line::from(vec![
Span::styled(sung, Style::new().fg(accent).bold()),
Span::styled(rest, Style::new().fg(Color::White).bold()),
]));
}
} else {
let ahead = active.map_or(i as isize + 1, |a| i as isize - a as isize);
let color = match (lyrics.synced, ahead) {
(false, _) | (_, 1..=2) => Color::Gray,
_ => Color::DarkGray,
};
rows.extend(parts.into_iter().map(|p| Line::styled(p, Style::new().fg(color))));
}
if roomy {
rows.push(Line::raw(""));
}
}
(rows, focus)
}
fn draw_lyrics(f: &mut Frame, app: &mut App, area: Rect, full: bool) {
let inner = if full {
let w = area.width.saturating_sub(4).min(76);
Rect {
x: area.x + (area.width - w) / 2,
width: w,
..area
}
} else {
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(dim())
.title(Span::styled(" ♪ Lyrics ", Style::new().fg(app.accent).bold()));
let inner = block.inner(area);
f.render_widget(block, area);
inner.inner(Margin::new(1, 0))
};
if inner.width < 4 || inner.height == 0 {
return;
}
let now = app.now_track().map(|t| t.id.clone());
let playing = now.is_some() && app.play_state != PlayState::Stopped;
let fresh = playing && app.lyrics_id == now;
let notice: Option<Vec<Line>> = match &app.lyrics {
_ if !playing => Some(vec![Line::styled("Nothing playing", dim())]),
LyricsState::Ready(_) if fresh => None,
LyricsState::Missing if fresh => Some(vec![
Line::styled("No lyrics found", Style::new().fg(Color::Gray)),
Line::styled("not every song has them", dim()),
]),
LyricsState::Failed(e) if fresh => Some(vec![
Line::styled("Couldn't load lyrics", Style::new().fg(Color::Red)),
Line::styled(e.clone(), dim()),
]),
_ => Some(vec![Line::styled(
format!("{} Searching lyrics…", SPINNER[app.tick % SPINNER.len()]),
Style::new().fg(Color::Yellow),
)]),
};
if let Some(lines) = notice {
let h = (lines.len() as u16 + 1).min(inner.height);
let rect = Rect {
y: inner.y + (inner.height - h) / 2,
height: h,
..inner
};
f.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
rect,
);
return;
}
let LyricsState::Ready(lyrics) = &app.lyrics else {
return;
};
let (rows, focus) = lyric_rows(lyrics, app.position, inner.width as usize, full, app.accent);
let height = inner.height as usize;
let max_start = rows.len().saturating_sub(height);
let start = if lyrics.synced {
focus.map_or(0, |r| r.saturating_sub(height / 2))
} else {
app.lyrics_scroll
}
.min(max_start);
app.lyrics_scroll = start;
let visible: Vec<Line> = rows.into_iter().skip(start).take(height).collect();
let align = if full { Alignment::Center } else { Alignment::Left };
f.render_widget(Paragraph::new(visible).alignment(align), inner);
}
fn draw_toast(f: &mut Frame, app: &App, body: Rect) { fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
let Some(s) = app.visible_status() else { let Some(s) = app.visible_status() else {
return; return;
@ -702,7 +519,7 @@ fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
Level::Error => ("✖", Color::Red), Level::Error => ("✖", Color::Red),
Level::Info => ("●", Color::Green), Level::Info => ("●", Color::Green),
}; };
let max_text = (body.width as usize).saturating_sub(8).min(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(); let mut text: String = s.text.lines().next().unwrap_or("").to_string();
if text.chars().count() > max_text { if text.chars().count() > max_text {
text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…"; text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…";
@ -763,8 +580,8 @@ fn draw_info(f: &mut Frame, app: &App, area: Rect) {
Paragraph::new(lines).block( Paragraph::new(lines).block(
Block::bordered() Block::bordered()
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::new().fg(app.accent)) .border_style(Style::new().fg(ACCENT))
.title(Span::styled(" ♪ Song info ", Style::new().fg(app.accent).bold())) .title(Span::styled(" ♪ Song info ", Style::new().fg(ACCENT).bold()))
.title_bottom(Line::styled(" any key to close ", dim()).right_aligned()), .title_bottom(Line::styled(" any key to close ", dim()).right_aligned()),
), ),
popup, popup,
@ -806,18 +623,12 @@ const CMD_INFO: &[(&str, &str)] = &[
("shuffle", "toggle shuffle"), ("shuffle", "toggle shuffle"),
("repeat", "repeat · off | all | one"), ("repeat", "repeat · off | all | one"),
("save", "save the queue as a playlist"), ("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"), ("load", "load a playlist into the queue"),
("append", "append a playlist to the queue"), ("append", "append a playlist to the queue"),
("delpl", "delete a playlist"), ("delpl", "delete a playlist"),
("addto", "add the selected track to a playlist"), ("addto", "add the selected track to a playlist"),
("import", "import a YouTube / YT Music playlist URL"), ("import", "import a YouTube / YT Music playlist URL"),
("artist", "songs by the selected artist"), ("artist", "songs by the selected artist"),
("similar", "radio: songs similar to the selected one"),
("album", "open the selected song's album, or :album <query> to search"),
("playlist", "search YouTube Music playlists"),
("lyrics", "show or hide the lyrics panel"),
("help", "show key bindings"), ("help", "show key bindings"),
]; ];
@ -865,11 +676,11 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
}; };
let block = Block::bordered() let block = Block::bordered()
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::new().fg(if search { app.accent } else { app.accent })) .border_style(Style::new().fg(if search { ACCENT } else { ACCENT }))
.title(Span::styled( .title(Span::styled(
title, title,
Style::new() Style::new()
.fg(if search { app.accent } else { app.accent }) .fg(if search { ACCENT } else { ACCENT })
.bold(), .bold(),
)) ))
.title_bottom(Line::styled(footer, dim()).right_aligned()); .title_bottom(Line::styled(footer, dim()).right_aligned());
@ -893,7 +704,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
Paragraph::new(Line::from(vec![ Paragraph::new(Line::from(vec![
Span::styled( Span::styled(
prompt, prompt,
Style::new().fg(Color::Black).bg(if search { app.accent } else { app.accent }).bold(), Style::new().fg(Color::Black).bg(if search { ACCENT } else { ACCENT }).bold(),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled(shown, Style::new().fg(Color::White)), Span::styled(shown, Style::new().fg(Color::White)),
@ -926,7 +737,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
Line::styled(" ⏎ run", dim()) Line::styled(" ⏎ run", dim())
} else { } else {
Line::from(vec![ Line::from(vec![
Span::styled(format!(" {first_word} "), Style::new().fg(app.accent).bold()), Span::styled(format!(" {first_word} "), Style::new().fg(ACCENT).bold()),
Span::styled(d, dim()), Span::styled(d, dim()),
]) ])
}); });
@ -941,12 +752,12 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
let text: String = text.chars().take(width).collect(); let text: String = text.chars().take(width).collect();
lines.push(if Some(i) == sel { lines.push(if Some(i) == sel {
let pad = width.saturating_sub(text.chars().count()); let pad = width.saturating_sub(text.chars().count());
Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent)) Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style())
} else { } else {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
text.chars().take(11).collect::<String>(), text.chars().take(11).collect::<String>(),
Style::new().fg(app.accent), Style::new().fg(ACCENT),
), ),
Span::styled(text.chars().skip(11).collect::<String>(), dim()), Span::styled(text.chars().skip(11).collect::<String>(), dim()),
]) ])
@ -988,15 +799,14 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) {
}; };
let block = Block::bordered() let block = Block::bordered()
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::new().fg(app.accent)) .border_style(Style::new().fg(ACCENT))
.title(Span::styled(title, Style::new().fg(app.accent).bold())) .title(Span::styled(title, Style::new().fg(ACCENT).bold()))
.title_bottom(Line::styled(footer, dim()).right_aligned()); .title_bottom(Line::styled(footer, dim()).right_aligned());
let inner = block.inner(popup); let inner = block.inner(popup);
f.render_widget(Clear, popup); f.render_widget(Clear, popup);
f.render_widget(block, popup); f.render_widget(block, popup);
let width = inner.width as usize; let width = inner.width as usize;
let accent = app.accent;
let lines: Vec<Line> = items let lines: Vec<Line> = items
.iter() .iter()
.enumerate() .enumerate()
@ -1011,7 +821,7 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) {
} }
let text: String = text.chars().take(width).collect(); let text: String = text.chars().take(width).collect();
let pad = width.saturating_sub(text.chars().count()); let pad = width.saturating_sub(text.chars().count());
return Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(accent)); return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style());
} }
let danger = matches!( let danger = matches!(
it.action, it.action,
@ -1086,24 +896,14 @@ const HELP: &[(&str, &str)] = &[
("h l / ← →", "seek -5s / +5s (counts: 6l)"), ("h l / ← →", "seek -5s / +5s (counts: 6l)"),
("+ -", "volume up / down"), ("+ -", "volume up / down"),
("s r", "toggle shuffle / cycle repeat"), ("s r", "toggle shuffle / cycle repeat"),
("L F", "lyrics side panel / fullscreen lyrics (Esc closes)"),
( (
"ga or :artist", "ga or :artist",
"songs by the artist of the selected song; a popup asks which one when there are several", "songs by the artist of the selected song; a popup asks which one when there are several",
), ),
(
"gb or :album",
"open the album of the selected song; :album <query> searches albums",
),
(":playlist <query>", "search YouTube Music playlists (pick one, then ⏎)"),
(
"gs or :similar",
"radio: load songs similar to the selected one and start playing them",
),
("SONG MENU ( G )", "#"), ("SONG MENU ( G )", "#"),
("G", "menu for the highlighted song or playlist"), ("G", "menu for the highlighted song or playlist"),
("j k ⏎", "move / choose (the yellow letter is a shortcut)"), ("j k ⏎", "move / choose (the yellow letter is a shortcut)"),
("p n a g s b l", "play · next · queue · artist · similar · album · playlist"), ("p n a g l", "play · play next · add to queue · artist · playlist"),
("x i", "remove · song info"), ("x i", "remove · song info"),
("Esc q G", "close (Esc goes back from a sub-menu)"), ("Esc q G", "close (Esc goes back from a sub-menu)"),
("QUEUE", "#"), ("QUEUE", "#"),
@ -1132,15 +932,7 @@ const HELP: &[(&str, &str)] = &[
(":clear", "empty the queue"), (":clear", "empty the queue"),
( (
":save n :load n", ":save n :load n",
"save the queue as a playlist / load one (:save! n replaces an existing one)", "save the queue as a playlist / load one",
),
(
":savealbum [n]",
"save album or current album results as a playlist",
),
(
":saveresults [n]",
"save current results as a playlist",
), ),
( (
":append n :delpl n", ":append n :delpl n",
@ -1149,9 +941,6 @@ const HELP: &[(&str, &str)] = &[
(":addto n", "add the selected track to playlist n"), (":addto n", "add the selected track to playlist n"),
(":import URL", "load a YouTube / YT Music playlist"), (":import URL", "load a YouTube / YT Music playlist"),
(":artist", "same as ga"), (":artist", "same as ga"),
(":similar", "same as gs (also :radio)"),
(":album [q]", "same as gb, or search albums"),
(":lyrics", "same as L"),
(":quit (or q, Ctrl-c)", ""), (":quit (or q, Ctrl-c)", ""),
]; ];
@ -1177,10 +966,10 @@ fn wrap_words(text: &str, width: usize) -> Vec<String> {
out out
} }
fn help_lines(inner_w: usize, accent: Color) -> Vec<Line<'static>> { fn help_lines(inner_w: usize) -> Vec<Line<'static>> {
let key_w = HELP_KEY_W.min(inner_w / 2).max(1); let key_w = HELP_KEY_W.min(inner_w / 2).max(1);
let desc_w = inner_w.saturating_sub(key_w); let desc_w = inner_w.saturating_sub(key_w);
let key_style = Style::new().fg(accent); let key_style = Style::new().fg(ACCENT);
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
for (k, d) in HELP { for (k, d) in HELP {
@ -1236,7 +1025,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
.map(|(i, name)| { .map(|(i, name)| {
let text = format!(" {} {name}", i + 1); let text = format!(" {} {name}", i + 1);
if i == app.artist_sel { if i == app.artist_sel {
Line::styled(cursor_text(&text), selected_style(app.accent)) Line::styled(text, selected_style())
} else { } else {
Line::raw(text) Line::raw(text)
} }
@ -1254,61 +1043,9 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
); );
} }
fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
let n = app.picks.len();
let w = area.width.min(78);
let h = (n as u16).saturating_add(3).min(area.height);
let visible = h.saturating_sub(3) as usize;
let start = if app.pick_sel < visible {
0
} else {
app.pick_sel + 1 - visible
};
let popup = Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
};
let inner = w.saturating_sub(2) as usize;
let mut lines: Vec<Line> = app
.picks
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(i, c)| {
let mut text = format!(" {:>2} {}", i + 1, c.title);
if !c.subtitle.is_empty() {
text.push_str(&format!(" · {}", c.subtitle));
}
let text: String = text.chars().take(inner).collect();
if i == app.pick_sel {
let pad = inner.saturating_sub(text.chars().count());
Line::styled(format!("{}{}", cursor_text(&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(),
));
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.title(format!(" {} ", app.pick_title))
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
}
fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
let w = area.width.min(80); let w = area.width.min(80);
let lines = help_lines(w.saturating_sub(2) as usize, app.accent); let lines = help_lines(w.saturating_sub(2) as usize);
let h = (lines.len() as u16).saturating_add(2).min(area.height); let h = (lines.len() as u16).saturating_add(2).min(area.height);
let popup = Rect { let popup = Rect {
x: area.x + (area.width - w) / 2, x: area.x + (area.width - w) / 2,
@ -1371,7 +1108,6 @@ mod tests {
id: id.into(), id: id.into(),
title: title.into(), title: title.into(),
artist: "Someone".into(), artist: "Someone".into(),
artists: Vec::new(),
album: None, album: None,
duration: Some(90), duration: Some(90),
}; };

View file

@ -1,30 +1,18 @@
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::model::{is_valid_id, parse_clock, Collection, Track}; use crate::model::{is_valid_id, parse_clock, Track};
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false"; const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false";
const NEXT_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/next?prettyPrint=false";
const ALBUMS_FILTER: &str = "EgWKAQIYAWoMEA4QChADEAQQCRAF";
const PLAYLISTS_FILTER: &str = "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D";
const RADIO_TARGET: usize = 50;
const RADIO_PAGES: usize = 4;
const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF"; const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF"; const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF";
const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
const FALLBACK_COUNT: usize = 25; const FALLBACK_COUNT: usize = 25;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Album,
Playlist,
}
pub struct Page { pub struct Page {
pub tracks: Vec<Track>, pub tracks: Vec<Track>,
pub next: Option<String>, pub next: Option<String>,
@ -61,91 +49,6 @@ pub fn search_more(token: &str) -> Result<Page, String> {
.map_err(|e| format!("{e:#}")) .map_err(|e| format!("{e:#}"))
} }
pub fn search_collections(query: &str, kind: Kind) -> Result<Vec<Collection>, String> {
let params = match kind {
Kind::Album => ALBUMS_FILTER,
Kind::Playlist => PLAYLISTS_FILTER,
};
post(ENDPOINT, json!({ "query": query, "params": params }))
.map(|v| parse_collections(&v, kind))
.map_err(|e| format!("{e:#}"))
}
/* browse_id: MPREb_... for an album, VL... for a playlist */
pub fn browse_tracks(browse_id: &str, album: &str, artist: &str) -> Result<Vec<Track>, String> {
let playlist = browse_id.starts_with("VL");
let v = post(BROWSE_ENDPOINT, json!({ "browseId": browse_id })).map_err(|e| format!("{e:#}"))?;
let mut tracks = parse_browse(&v, playlist);
let mut next = if playlist { find_continuation(&v) } else { None };
let mut pages = 0;
while let Some(tok) = next.take() {
if pages >= 5 || tracks.len() >= 300 {
break;
}
pages += 1;
let url = format!("{BROWSE_ENDPOINT}&ctoken={tok}&continuation={tok}");
let Ok(v) = post(&url, json!({})) else { break };
let more = parse_browse(&v, true);
if more.is_empty() {
break;
}
let known: std::collections::HashSet<String> =
tracks.iter().map(|t| t.id.clone()).collect();
tracks.extend(more.into_iter().filter(|t| !known.contains(&t.id)));
next = find_continuation(&v);
}
if tracks.is_empty() {
return Err("nothing playable found".into());
}
if !playlist {
for t in &mut tracks {
if !album.is_empty() && t.album.is_none() {
t.album = Some(album.to_string());
}
if !artist.is_empty() && t.artist == "Unknown artist" {
t.artist = artist.to_string();
}
}
}
Ok(tracks)
}
pub fn radio(seed: &str) -> Result<Vec<Track>, String> {
fetch_radio(seed).map_err(|e| format!("{e:#}"))
}
fn fetch_radio(seed: &str) -> Result<Vec<Track>> {
let body = json!({
"videoId": seed,
"playlistId": format!("RDAMVM{seed}"),
"params": "wAEB",
"isAudioOnly": true,
"enablePersistentPlaylistPanel": true,
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
});
let mut url = NEXT_ENDPOINT.to_string();
let mut tracks: Vec<Track> = Vec::new();
for _ in 0..RADIO_PAGES {
let v = post(&url, body.clone())?;
let before = tracks.len();
for t in parse_radio_response(&v) {
if !tracks.iter().any(|x| x.id == t.id) {
tracks.push(t);
}
}
match find_continuation(&v) {
Some(tok) if tracks.len() < RADIO_TARGET && tracks.len() > before => {
url = format!("{NEXT_ENDPOINT}&ctoken={tok}&continuation={tok}");
}
_ => break,
}
}
if tracks.is_empty() {
bail!("no similar songs found");
}
Ok(tracks)
}
pub fn import(url: &str) -> Result<Vec<Track>, String> { pub fn import(url: &str) -> Result<Vec<Track>, String> {
if !(url.starts_with("http://") || url.starts_with("https://")) { if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("import expects a full http(s) URL".into()); return Err("import expects a full http(s) URL".into());
@ -177,7 +80,30 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) {
} }
fn search_innertube(query: &str) -> Result<Page> { fn search_innertube(query: &str) -> Result<Page> {
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)) Ok(page_from(&v))
} }
@ -193,7 +119,6 @@ fn find_continuation(v: &Value) -> Option<String> {
Value::Object(map) => { Value::Object(map) => {
let token = map let token = map
.get("nextContinuationData") .get("nextContinuationData")
.or_else(|| map.get("nextRadioContinuationData"))
.and_then(|c| c.get("continuation")) .and_then(|c| c.get("continuation"))
.or_else(|| { .or_else(|| {
map.get("continuationCommand") map.get("continuationCommand")
@ -210,25 +135,18 @@ fn find_continuation(v: &Value) -> Option<String> {
} }
} }
/* one shared agent so paging and radio reuse the same connection */ fn post(url: &str, mut body: Value) -> Result<Value> {
fn agent() -> &'static ureq::Agent { let agent = ureq::AgentBuilder::new()
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12)) .timeout(Duration::from_secs(12))
.user_agent(UA) .user_agent(UA)
.build() .build();
})
}
fn post(url: &str, mut body: Value) -> Result<Value> {
body["context"] = json!({ "client": { body["context"] = json!({ "client": {
"clientName": "WEB_REMIX", "clientName": "WEB_REMIX",
"clientVersion": client_version(), "clientVersion": client_version(),
"hl": "en", "hl": "en",
"gl": "US", "gl": "US",
}}); }});
let resp = agent() let resp = agent
.post(url) .post(url)
.set("Origin", "https://music.youtube.com") .set("Origin", "https://music.youtube.com")
.set("Referer", "https://music.youtube.com/") .set("Referer", "https://music.youtube.com/")
@ -301,45 +219,12 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
} }
} }
fn column_runs(item: &Value, col: usize) -> Option<&Vec<Value>> { fn column_segments(item: &Value, col: usize) -> Vec<String> {
item.get("flexColumns") let runs = item
.get("flexColumns")
.and_then(|c| c.get(col)) .and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array) .and_then(Value::as_array);
}
fn column_segments(item: &Value, col: usize) -> Vec<String> {
run_segments(column_runs(item, col))
}
/* Artists are the runs that link to an artist/channel page ("UC..." browse
* ids); the "&" and "," between them are plain runs. Using the source's own
* boundaries means names that contain a separator stay in one piece. Names
* must also appear in `credit`, so a stray linked run can't leak in. Empty
* when the response carries no links (the caller falls back to guessing). */
fn artist_names_from_runs(runs: Option<&Vec<Value>>, credit: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for run in runs.into_iter().flatten() {
let id = run
.pointer("/navigationEndpoint/browseEndpoint/browseId")
.and_then(Value::as_str);
let text = run.get("text").and_then(Value::as_str);
let (Some(id), Some(text)) = (id, text) else {
continue;
};
let text = text.trim();
if id.starts_with("UC")
&& !text.is_empty()
&& credit.contains(text)
&& !names.iter().any(|n| n == text)
{
names.push(text.to_string());
}
}
names
}
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
let mut segments = Vec::new(); let mut segments = Vec::new();
let mut cur = String::new(); let mut cur = String::new();
for run in runs.into_iter().flatten() { for run in runs.into_iter().flatten() {
@ -364,13 +249,6 @@ fn looks_like_duration(s: &str) -> bool {
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':') s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
} }
fn fixed_duration(item: &Value) -> Option<u32> {
let s = item
.pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")?
.as_str()?;
parse_clock(s).map(|s| s as u32)
}
fn parse_item(item: &Value) -> Option<Track> { fn parse_item(item: &Value) -> Option<Track> {
let id = find_str(item, "videoId")?; let id = find_str(item, "videoId")?;
if !is_valid_id(id) { if !is_valid_id(id) {
@ -393,12 +271,10 @@ fn parse_item(item: &Value) -> Option<Track> {
} }
_ => None, _ => None,
}; };
let duration = duration.or_else(|| fixed_duration(item));
let artist = meta let artist = meta
.first() .first()
.cloned() .cloned()
.unwrap_or_else(|| "Unknown artist".into()); .unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(column_runs(item, 1), &artist);
let album = meta let album = meta
.get(1) .get(1)
.filter(|a| !a.contains(" views") && !a.contains(" plays")) .filter(|a| !a.contains(" views") && !a.contains(" plays"))
@ -408,69 +284,11 @@ fn parse_item(item: &Value) -> Option<Track> {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album, album,
duration, duration,
}) })
} }
fn parse_browse(v: &Value, playlist: bool) -> Vec<Track> {
let mut items = Vec::new();
collect_items(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(|item| {
let mut t = parse_item(item)?;
if playlist && t.album.is_none() {
t.album = column_segments(item, 2).into_iter().next();
}
Some(t)
})
.filter(|t| seen.insert(t.id.clone()))
.collect()
}
pub fn parse_collections(v: &Value, kind: Kind) -> Vec<Collection> {
let mut items = Vec::new();
collect_items(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(|item| {
let id = item
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
.as_str()?;
let ok = match kind {
Kind::Album => id.starts_with("MPRE"),
Kind::Playlist => id.starts_with("VL"),
};
if !ok {
return None;
}
let title = column_segments(item, 0).into_iter().next()?;
let mut meta = column_segments(item, 1);
if matches!(
meta.first().map(String::as_str),
Some("Album" | "EP" | "Single" | "Playlist")
) {
meta.remove(0);
}
let artist = match kind {
Kind::Album => meta.first().cloned().unwrap_or_default(),
Kind::Playlist => String::new(),
};
Some(Collection {
browse_id: id.to_string(),
title,
subtitle: meta.join(" · "),
artist,
})
})
.filter(|c| seen.insert(c.browse_id.clone()))
.collect()
}
pub fn parse_search_response(v: &Value) -> Vec<Track> { pub fn parse_search_response(v: &Value) -> Vec<Track> {
let mut items = Vec::new(); let mut items = Vec::new();
collect_items(v, &mut items); collect_items(v, &mut items);
@ -482,79 +300,6 @@ pub fn parse_search_response(v: &Value) -> Vec<Track> {
.collect() .collect()
} }
fn collect_panel<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
match v {
Value::Object(map) => {
if let Some(w) = map.get("playlistPanelVideoWrapperRenderer") {
out.extend(w.pointer("/primaryRenderer/playlistPanelVideoRenderer"));
} else if let Some(item) = map.get("playlistPanelVideoRenderer") {
out.push(item);
} else {
map.values().for_each(|c| collect_panel(c, out));
}
}
Value::Array(arr) => arr.iter().for_each(|c| collect_panel(c, out)),
_ => {}
}
}
fn parse_panel_item(item: &Value) -> Option<Track> {
let id = item.get("videoId")?.as_str()?;
if !is_valid_id(id) {
return None;
}
let title: String = item
.pointer("/title/runs")?
.as_array()?
.iter()
.filter_map(|r| r.get("text")?.as_str())
.collect();
let title = title.trim().to_string();
if title.is_empty() {
return None;
}
let byline = item.pointer("/longBylineText/runs").and_then(Value::as_array);
let meta = run_segments(byline);
let artist = meta
.first()
.cloned()
.unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(byline, &artist);
let album = meta
.get(1)
.filter(|a| {
!a.contains(" views")
&& !a.contains(" plays")
&& !(a.len() == 4 && a.bytes().all(|b| b.is_ascii_digit()))
})
.cloned();
let duration = item
.pointer("/lengthText/runs/0/text")
.or_else(|| item.pointer("/lengthText/simpleText"))
.and_then(Value::as_str)
.and_then(parse_clock)
.map(|s| s as u32);
Some(Track {
id: id.to_string(),
title,
artist,
artists,
album,
duration,
})
}
pub fn parse_radio_response(v: &Value) -> Vec<Track> {
let mut items = Vec::new();
collect_panel(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(parse_panel_item)
.filter(|t| seen.insert(t.id.clone()))
.collect()
}
fn ytdlp_search(query: &str) -> Result<Vec<Track>> { fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}")) ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"))
} }
@ -604,24 +349,11 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
.unwrap_or("Unknown artist") .unwrap_or("Unknown artist")
.trim_end_matches(" - Topic") .trim_end_matches(" - Topic")
.to_string(); .to_string();
/* newer yt-dlp lists each credited artist separately */
let artists: Vec<String> = v
.get("artists")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32); let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
Some(Track { Some(Track {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album: None, album: None,
duration, duration,
}) })
@ -680,36 +412,6 @@ mod tests {
assert_eq!(t.duration, Some(241)); assert_eq!(t.duration, Some(241));
} }
#[test]
fn keeps_linked_artist_runs_whole() {
let link = |text: &str, id: &str| {
json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}})
};
let resp = json!([{"musicResponsiveListItemRenderer": {
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "},
link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "},
link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"}
]}}}
]
}}]);
let t = &parse_search_response(&resp)[0];
assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis");
assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]);
assert_eq!(t.album.as_deref(), Some("Album"));
}
#[test]
fn reads_ytdlp_artists_list() {
let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#;
let t = parse_ytdlp_lines(line);
assert_eq!(t[0].artists, ["A", "B"]);
assert_eq!(t[0].artist, "A, B");
}
#[test] #[test]
fn skips_items_without_video_id() { fn skips_items_without_video_id() {
let resp = json!([{"musicResponsiveListItemRenderer": { let resp = json!([{"musicResponsiveListItemRenderer": {
@ -761,76 +463,6 @@ not json
assert_eq!(find_continuation(&json!({"a": [1, 2]})), None); assert_eq!(find_continuation(&json!({"a": [1, 2]})), None);
} }
#[test]
fn parses_radio_panel() {
let item = |id: &str, title: &str, by: Value, len: &str| {
json!({"playlistPanelVideoRenderer": {
"videoId": id,
"title": {"runs": [{"text": title}]},
"longBylineText": {"runs": by},
"lengthText": {"runs": [{"text": len}]},
}})
};
let by = json!([{"text": "A"}, {"text": " & "}, {"text": "B"},
{"text": " • "}, {"text": "Album"}, {"text": " • "}, {"text": "2019"}]);
let resp = json!({"contents": [
item("abcdefghijk", "One", by, "3:21"),
{"playlistPanelVideoWrapperRenderer": {
"primaryRenderer": item("bcdefghijkl", "Two", json!([{"text": "C"}]), "1:05"),
"counterpart": [item("zzzzzzzzzzz", "Video", json!([{"text": "C"}]), "1:05")],
}},
item("abcdefghijk", "dup", json!([{"text": "A"}]), "3:21"),
]});
let t = parse_radio_response(&resp);
assert_eq!(t.len(), 2);
assert_eq!(t[0].artist, "A & B");
assert_eq!(t[0].album.as_deref(), Some("Album"));
assert_eq!(t[0].duration, Some(201));
assert_eq!(t[1].id, "bcdefghijkl");
assert_eq!(t[1].album, None);
}
#[test]
fn parses_album_and_playlist_results() {
let row = |id: &str, title: &str, runs: Value| {
json!({"musicResponsiveListItemRenderer": {
"navigationEndpoint": {"browseEndpoint": {"browseId": id}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run(title)},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": runs}}},
]
}})
};
let resp = json!([
row("MPREb_abc", "Discovery", json!([{"text": "Album"}, {"text": " • "},
{"text": "Daft Punk"}, {"text": " • "}, {"text": "2001"}])),
row("VLPL123", "Mix", json!([{"text": "Playlist"}, {"text": " • "}, {"text": "Someone"}])),
row("UCartist", "Artist", json!([{"text": "Artist"}])),
]);
let a = parse_collections(&resp, Kind::Album);
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "Discovery");
assert_eq!(a[0].artist, "Daft Punk");
assert_eq!(a[0].subtitle, "Daft Punk · 2001");
let p = parse_collections(&resp, Kind::Playlist);
assert_eq!(p.len(), 1);
assert_eq!(p[0].browse_id, "VLPL123");
assert_eq!(p[0].artist, "");
}
#[test]
fn parses_browse_tracks() {
let resp = json!({"contents": [{"musicResponsiveListItemRenderer": {
"playlistItemData": {"videoId": "abcdefghijk"},
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("One More Time")}],
"fixedColumns": [{"musicResponsiveListItemFixedColumnRenderer": run("5:20")}],
}}]});
let t = parse_browse(&resp, false);
assert_eq!(t.len(), 1);
assert_eq!(t[0].duration, Some(320));
assert_eq!(t[0].artist, "Unknown artist");
}
#[test] #[test]
fn date_math() { fn date_math() {
assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(civil_from_days(0), (1970, 1, 1));