Compare commits

...
Author SHA1 Message Date
8d24596cbd
update(readme): update readme with table of contents
Added a table of contents to the README for user friendlyness.
2026-11-03 14:18:23 +01:00
a1a3106926
change(cargo.toml): change license
Changed the license in Cargo.toml from MIT to GPL v3 or later :3
2026-11-03 14:14:49 +01:00
4570de2da7 feat(config): configurable keybinds via [keys] in config.toml, add
cookies_from_browser option

Mainview keys can now be remapped per action. vim keys remain the
default and anything left uset keeps its default. Supports ctrl+
keys, named keys and multi-key sequences like "gg". Invalid entries show
a warning on start :3
You're now able to use cookies from your browser to fetch the songs. See
config.toml.
2026-11-03 14:10:46 +01:00
8da1876835
Add GitHub Actions workflow for Rust project 2026-11-02 19:01:40 +01:00
5237a6bae8 feat(player): stream audio by default, add [audio] config for cookies and downloads
Long tracks (15 min to 1 hour+) didnt playbecause every song was fully
downloaded with yt-dlp into the cache before mpv was started.

Resolve the direct media URL with yt-dlp -g and hand it to mpv, so
playback starts within a couple of seconds. Cached files are still
used when present.

Add an [audio] section to config.toml:
- download: download songs to the cache before playing (default false,
  i.e. stream). Prefetch of the next track only runs in this mode.
- download_max_minutes: in download mode, songs longer than this (or of
  unknown length) are still streamed so 0 means no limit. default is le 15.
- cookies_from_browser: passed to yt-dlp as --cookies-from-browser for
  playback, for when YouTube asks to sign in or blocks a song.
2026-11-02 17:57:31 +01:00
f25b96d5fd feat(discord): show song name instead of application name 2026-10-30 14:02:04 +01:00
27353d3358
docs(readme): update features
meow >w<
2026-10-30 02:31:11 +01:00
43289e1c42
Merge pull request #2 from olivr2346:pr/save-album-as-playlist
feat: add native support for saving albums and results as playlists
2026-10-29 19:09:12 +01:00
0867ebe682
Merge branch 'main' into pr/save-album-as-playlist 2026-10-29 19:08:59 +01:00
fc21fbffd8
docs(readme): add install script distros 2026-10-29 18:47:25 +01:00
olivr
ad2a1462e8 fix(app): restore split_artists import after rebase onto upstream 1.4 2026-10-29 18:32:14 +01:00
olivr
d594184ff7 fix(ui): add savealbum and saveresults to command palette descriptions 2026-10-29 18:30:23 +01:00
olivr
1cd329d112 feat: add native support for saving albums and results as playlists 2026-10-29 18:30:23 +01:00
c1d75b1f7c
Update README with Discord client ID instructions
fuck you copilot
2026-10-29 17:21:45 +00:00
eb6b16e70b feat: discord presence, cached audio playback, multiinstance safety
Add
- Discord rich presence ("Listening to", cover, progress, Listen Along
  button). enable via [discord] client_id in config.toml : retries quietly
  if Discord isn't running. new dep: discord-rich-presence.
- :save! / :w! to overwrite a playlist. Plain :save now refuses to
  clobber an existing name.

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

Fix
- Multiple drift instances: only the first (flock on session.lock) owns
  the session, and playlist saves merge with what other instances wrote.
- Collection picker no longer steals keys while typing.
- "Play next" now shifts `played` indices so shuffle history stays right.
- Leaving the queue for a playlist/result list marks the queue as done.
- Replace unwrap() in lyrics sorting with total_cmp (NaN-safe).
2026-10-29 18:19:00 +01:00
a7f101d1e7
Update README with new commands for similar songs
Added commands for similar songs, album, and playlist search to the README.
2026-10-20 13:42:50 +02:00
b3fbcfdfd6
Revise CHANGELOG.md with recent updates
Updated changelog to reflect new features, changes, and fixes in Drift.
2026-10-20 13:37:54 +02:00
468ba7ee96 add(nix): add flake.lock 2026-10-20 13:33:17 +02:00
da5b76ab06 feat(mpris, similar search, playlist)
- added feature to search for similar songs using YouTube API
   ; can go to similar songs by pressing (gs)
- added mpris support (added support for playerctl)
   ; this means you can show the current playing song on waybar, shells, etc
- added playlist search
- added go to album
2026-10-20 13:28:39 +02:00
71c2ef6410 cleanup(app,ui): remove unused imports 2026-10-18 21:47:23 +02:00
c17152f449 add(flake): added as a nix flake 2026-10-18 17:12:01 +02:00
25fe357d57 add(nix): Added as a nix flake 2026-10-18 17:11:42 +02:00
ae399991a0
update(readme): updated readme :3 2026-10-17 21:19:06 +02:00
11e0b42b50 update(showcase): update showcase.gif 2026-10-17 21:10:48 +02:00
8f650f8820 feat: add synced lyrics panel and fullscreen view
- fetch lyrics from lrclib in the background, only while visible
- L toggles side panel, F opens fullscreen, :lyrics as a command
- highlight current line with a progress fill, fade surrounding lines
- fall back to scrollable plain lyrics when no synced version exists
2026-10-17 21:04:28 +02:00
bf11685a03 add: accent color option in config 2026-10-11 18:39:28 +02:00
b31d0226d3 update: showcase gif 2026-10-11 18:06:33 +02:00
14b23f7e50
Delete archive.zip 2026-10-11 15:56:52 +00:00
c0780d88d0 add: CHANGELOG.md 2026-10-11 17:56:25 +02:00
1d579f5311 UI Overhaul
- Windows support: drift now runs natively on Windows, using named pipes instead of Unix sockets.
- Song context menu: press G on any song for quick actions like play, queue, add to playlist, and song info
- Redesined command menu: the : prompt has a new, cleaner design
- Visualizer: a live audio visualizer now sits in the bottomleft corner
- Keybind hints: the most useful shortcuts are showen next to the volume control
2026-10-11 17:56:25 +02:00
9c6b54b705 yes 2026-10-11 17:56:25 +02:00
3f8e7ab05f
Add installation script for media tools and dependencies
Install mpv, yt-dlp, and cargo based on the package manager.
2026-10-11 10:27:26 +02:00
ca03d20d92
Update installation instructions in README.md
Added installation instructions for Arch Linux and other distros, including dependencies and build steps.
2026-10-11 10:09:51 +02:00
8a049b6fca Commit 10/11/26
- Added [artist] in config
- Automatically lists new songs when scrolled on :search
- You can view most popular songs on artist profile
- By adding this to your config:

[artist]
popular_first = true
2026-10-11 09:54:07 +02:00
24 changed files with 6388 additions and 503 deletions

22
.github/workflows/rust.yml vendored Normal file
View file

@ -0,0 +1,22 @@
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

40
CHANGELOG.md Normal file
View file

@ -0,0 +1,40 @@
# Changelog
Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased
### Added
- Discord rich presence. Your current song shows up in your Discord status as "Listening to ...", with cover art, album, a progress bar and a "Listen Along" button. Paused songs show `(paused)` and drop the timer. Off by default: make an application at https://discord.com/developers/applications (its name is what shows after "Listening to"), paste the Application ID into `client_id` under the new `[discord]` section of `config.toml`. If discord isnt running drift keeps retrying quietly in the background.
- `:save!` / `:w!` to overwrite an existing playlist. Plain `:save` now refuses if the name is taken and tells you to use `:save!` (replace) or `:append` (add to the end).
- MPRIS on linux. media keys, `playerctl` and waybar can now see and control drift (play/pause/next/prev/seek/stop), with title, artist, album and cover art. Shows up as `org.mpris.MediaPlayer2.drift`. If theres no dbus session it just doesnt start, nothing breaks.
- `:album <query>` to search albums, `:playlist <query>` to search youtube music playlists. Results show up in a picker, enter loads the tracks. (`:playlists` is still your own playlists tab.) Big playlists stop at ~300 tracks.
- `gb` / `:album` / `G` then `b` opens the album of the selected song. It finds it by album + artist name so it can guess wrong sometimes, and the song needs album info to begin with.
- `gs` / `:similar` / `:radio` loads ~50 songs similar to the selected one and starts playing them, like youtube music radio.
- Synced lyrics from LRCLIB. `L` toggles the side panel, `F` for fullscreen. Current line gets a progress fill, lines around it fade.
- Plain lyrics fallback when theres no synced version, scroll with `j`/`k`.
### Changed
- Playback now downloads the audio with yt-dlp first and plays the local file instead of letting mpv stream it (`--ytdl=no`). Files live in the cache dir under `drift/audio` (last 40 are kept). The next queued song is fetched in the background while the current one plays, so track changes are quicker. Songs that are already cached start instantly.
- Playback errors show the actual reason from yt-dlp/mpv (e.g. "Sign in to confirm you're not a bot") instead of a generic "update yt-dlp" hint. Status messages can be wider too (70 -> 120 chars).
- Artists are split using the credits youtube gives us (linked artist names) instead of guessing from the text, so names like "Tyler, The Creator" stay in one piece. yt-dlp's `artists` list is used too. The text-based fallback (old saves, plain yt-dlp) knows a list of common band names with `,` / `&` in them, and matches separators case-insensitively. Affects `ga`, `gb`, `gs` and the lyrics lookup.
- Selected rows use an accent colored bold text + `▎` bar instead of a filled background.
- Tab bar went back to plain text: the active tab is accent colored, bold and underlined, inactive ones are gray with dimmed counts. (replaces the pills.)
- Tab bar only reserves space for the spinner while busy, fits narrow terminals better.
- Album tracks now get the artist/album/length filled in when youtube leaves them out.
- Tracks from a playlist get their album too, so `gb` works on them.
- Search/radio requests share one http connection instead of making a new one every time.
- New dependencies: `discord-rich-presence` (everywhere) and, on linux, `souvlaki` (zbus backend, pure rust, no system libs). `Cargo.lock` needs a regen, just run `cargo build` once. `--locked` builds, the flake and the AUR package need the new lock.
### Fixed
- Running several drifts at once no longer clobbers stuff. Only the first one restores and saves the session (a `session.lock` file, unix only), and saving playlists merges with whatever the other instance wrote instead of overwriting it, so playlists made in another window dont disappear.
- Collection picker (`:album`, `:playlist`) no longer pops up in the middle of typing and steals your keys, it waits until you're back in normal mode.
- "Play next" (song menu) didnt shift the "already played" marks, so shuffle could replay or skip the wrong songs after it. fixed.
- Starting a song from a playlist/results while a queue is playing no longer drags the old queue along: whats queued counts as done and only songs you add afterwards play after it.
- Playing from a playlist no longer replaces the queue. Playlists play as their own list, queue only has what you add, queued songs go first and then the playlist continues.
- Shuffle and repeat-all work for playlists and results now. repeat-all used to loop the queue instead of the list you were actualy playing.
- `P` / `:playall` doesnt wipe the queue anymore.
- Lyrics sorting no longer has an `unwrap()` that could panic on a NaN time drift

1380
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 = "MIT" license = "GPL-3.0-or-later"
[workspace] [workspace]
@ -16,6 +16,10 @@ 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

323
README.md
View file

@ -1,81 +1,322 @@
<div align="center"> <div align="center">
<h1>Drift</h1> # Drift
### 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)
![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square) [![Build](https://img.shields.io/github/actions/workflow/status/socatlolmeow/drift/rust.yml?branch=main&style=flat-square&label=build)](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml)
[![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square)](LICENSE)
![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust)
### A music streaming client using Vim keybinds built in Rust. <img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
Stream your favourite music from YouTube Music — all in one client, in your terminal.
</div> </div>
--- ---
<div align="center"> ## Table of contents
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
</div> - [Features](#features)
- [Installation](#installation)
- [Arch Linux (AUR)](#arch-linux-aur)
- [Install script](#install-script)
- [From source](#from-source)
- [Nix](#nix)
- [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually)
- [Usage](#usage)
- [Keybinds](#keybinds)
- [Commands](#commands)
- [Lyrics](#lyrics)
- [Configuration](#configuration)
- [Streaming or downloading](#streaming-or-downloading)
- [Discord rich presence](#discord-rich-presence)
- [Custom keys](#custom-keys)
- [Environment variables](#environment-variables)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
- [License](#license)
--- ---
## Features ## Features
- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv` - Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line
- **Vim-style keybinds** — navigate with `j`/`k`, enter commands with `:`, quit with `q` - Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung
- **Queue management** — add, remove, reorder, and clear tracks in your queue - Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL
- **Playlists** — save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs - Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists
- **Shuffle & repeat** — toggle shuffle and cycle through off / repeat-all / repeat-one modes - A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info
- **Volume & seek control** — adjust volume and seek with configurable step sizes - A command palette with completion and history
- **Session restore** — automatically restores your queue and playback position on next launch - Your queue and position are restored the next time you start Drift
- **Artist search** — drill into an artist's discography from the results view - Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence
- **Command palette** — full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more) - One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds
- **Autoplay** — optionally keep playing through results when the queue ends
- **TOML config** — persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved
---
## Installation ## Installation
### Dependencies 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.
- [mpv](https://mpv.io/) — audio playback ### Arch Linux (AUR)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube stream extraction
- [Rust](https://rustup.rs/) (build only)
Install them with your package manager, e.g. on Arch: ```bash
``` yay -S driftmusic-git
sudo pacman -S mpv yt-dlp
``` ```
### Build & install Any AUR helper works. You can also build it by hand:
```bash
git clone https://aur.archlinux.org/driftmusic-git.git
cd driftmusic-git
makepkg -si
```
`mpv` and `yt-dlp` are pulled in automatically.
### Install script
Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo.
```bash ```bash
git clone https://github.com/socatlolmeow/drift.git git clone https://github.com/socatlolmeow/drift.git
cd drift cd drift
cargo build --release ./install.sh
``` ```
Then copy the binary to somewhere on your `$PATH`: ### From source
```bash
sudo cp target/release/drift /usr/local/bin/ Install the dependencies and [Rust](https://rustup.rs/), then:
```
Or install directly with cargo:
```bash ```bash
git clone https://github.com/socatlolmeow/drift.git
cd drift
cargo install --path . cargo install --path .
``` ```
This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/). This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup.
--- ### 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 FOSS, 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 free software, 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: 5.7 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before After
Before After

27
flake.lock generated Normal file
View file

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

26
flake.nix Normal file
View file

@ -0,0 +1,26 @@
{
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";
};
});
};
}

76
install.sh Normal file
View file

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

1528
src/app.rs

File diff suppressed because it is too large Load diff

View file

@ -30,12 +30,19 @@ 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,
} }
@ -62,12 +69,18 @@ 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",
]; ];
@ -164,6 +177,9 @@ 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")?),
@ -175,6 +191,15 @@ 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)")),
}; };
@ -238,6 +263,20 @@ 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]
@ -247,5 +286,22 @@ 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,13 +1,35 @@
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 serde::Deserialize; use ratatui::style::Color;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer};
use crate::keys::{Keymap, Keys};
use crate::model::{RepeatMode, Track}; use crate::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 {
@ -48,10 +70,85 @@ impl Playback {
} }
} }
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(default)]
pub struct Artist {
pub popular_first: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Discord {
pub client_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Audio {
pub download: bool,
pub download_max_minutes: u32,
pub cookies_from_browser: String,
}
impl Default for Audio {
fn default() -> Self {
Audio {
download: false,
download_max_minutes: 15,
cookies_from_browser: String::new(),
}
}
}
impl Audio {
/// Browser name for yt-dlp's --cookies-from-browser, if one is set.
pub fn cookies(&self) -> Option<&str> {
let c = self.cookies_from_browser.trim();
(!c.is_empty()).then_some(c)
}
/// Whether a track should be downloaded to the cache before playing,
/// as opposed to streamed. Unknown lengths are streamed when a limit is set.
pub fn should_download(&self, duration: Option<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 ui: Ui,
pub discord: Discord,
pub audio: Audio,
pub keys: HashMap<String, Keys>,
#[serde(skip)]
pub keymap: Keymap,
} }
impl Config { impl Config {
@ -59,7 +156,27 @@ impl Config {
Ok(Store::dir()?.join("config.toml")) Ok(Store::dir()?.join("config.toml"))
} }
pub fn render(p: &Playback) -> String { pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> 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
@ -73,6 +190,48 @@ autoplay = {autoplay}
restore_session = {restore} restore_session = {restore}
seek_step = {seek:?} seek_step = {seek:?}
volume_step = {vstep:?} volume_step = {vstep:?}
[artist]
# :artist and ga list the artist's most popular songs first, then more of their songs below
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,
@ -81,6 +240,12 @@ volume_step = {vstep:?}
restore = p.restore_session, restore = p.restore_session,
seek = p.seek_step, seek = p.seek_step,
vstep = p.volume_step, vstep = p.volume_step,
popular = a.popular_first,
accent = accent,
client_id = d.client_id,
download = au.download,
dl_max = au.download_max_minutes,
cookies = au.cookies_from_browser.replace(['\\', '"'], ""),
) )
} }
@ -95,7 +260,7 @@ volume_step = {vstep:?}
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())); let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
return (Config::default(), None); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
@ -108,7 +273,14 @@ volume_step = {vstep:?}
match toml::from_str::<Config>(&text) { match toml::from_str::<Config>(&text) {
Ok(mut c) => { Ok(mut c) => {
c.playback.sanitize(); c.playback.sanitize();
(c, None) let warning = match Keymap::build(&c.keys) {
Ok(keymap) => {
c.keymap = keymap;
None
}
Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")),
};
(c, warning)
} }
Err(e) => { Err(e) => {
let bak = path.with_extension("toml.bak"); let bak = path.with_extension("toml.bak");
@ -174,7 +346,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s); let _ = Session::write(&s);
} }
} }
let _ = Config::write(&Config::render(&p)); let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
} }
#[cfg(test)] #[cfg(test)]
@ -183,13 +355,22 @@ mod tests {
#[test] #[test]
fn default_file_parses_back() { fn default_file_parses_back() {
let text = Config::render(&Playback::default()); let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::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);
assert_eq!(c.playback.repeat, d.repeat); assert_eq!(c.playback.repeat, d.repeat);
assert_eq!(c.playback.seek_step, 5.0); assert_eq!(c.playback.seek_step, 5.0);
assert!(c.playback.restore_session); assert!(c.playback.restore_session);
assert!(!c.artist.popular_first);
}
#[test]
fn artist_section() {
let c: Config = toml::from_str("[artist]\npopular_first = true").unwrap();
assert!(c.artist.popular_first);
let c: Config = toml::from_str("[playback]\nvolume = 10").unwrap();
assert!(!c.artist.popular_first);
} }
#[test] #[test]
@ -217,4 +398,76 @@ 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"));
}
} }

174
src/discord.rs Normal file
View file

@ -0,0 +1,174 @@
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()
}
}
}

353
src/keys.rs Normal file
View file

@ -0,0 +1,353 @@
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());
}
}

201
src/lyrics.rs Normal file
View file

@ -0,0 +1,201 @@
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,10 +1,15 @@
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;
mod sys;
mod ui; mod ui;
mod ytmusic; mod ytmusic;
@ -30,12 +35,9 @@ USAGE:
Inside the player press ? for key bindings and :help for commands."; Inside the player press ? for key bindings and :help for commands.";
fn tool_version(name: &str) -> Option<String> { fn tool_version(name: &str) -> Option<String> {
let out = Command::new(name) let mut cmd = Command::new(name);
.arg("--version") cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null());
.stdin(Stdio::null()) let out = sys::no_window(&mut cmd).output().ok()?;
.stderr(Stdio::null())
.output()
.ok()?;
out.status.success().then(|| { out.status.success().then(|| {
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout)
.lines() .lines()
@ -51,11 +53,11 @@ fn missing_dependencies(verbose: bool) -> Vec<String> {
for (tool, hint) in [ for (tool, hint) in [
( (
"mpv", "mpv",
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv", "sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv.net / scoop install mpv",
), ),
( (
"yt-dlp", "yt-dlp",
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp", "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp.yt-dlp / scoop install yt-dlp",
), ),
] { ] {
match tool_version(tool) { match tool_version(tool) {
@ -129,6 +131,12 @@ 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,6 +5,9 @@ pub struct Track {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub artist: String, pub artist: String,
/* individual credited artists when the source gave them to us */
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)] #[serde(default)]
pub album: Option<String>, pub album: Option<String>,
#[serde(default)] #[serde(default)]
@ -16,26 +19,98 @@ 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 struct Collection {
pub browse_id: String,
pub title: String,
pub subtitle: String,
pub artist: String,
}
/* names that contain a separator and must stay in one piece */
const WHOLE_NAMES: &[&str] = &[
"Tyler, The Creator",
"Earth, Wind & Fire",
"Emerson, Lake & Palmer",
"Crosby, Stills, Nash & Young",
"Crosby, Stills & Nash",
"Simon & Garfunkel",
"Hall & Oates",
"Brooks & Dunn",
"Mumford & Sons",
"Belle & Sebastian",
"Sly & The Family Stone",
"Ike & Tina Turner",
"Bob Seger & The Silver Bullet Band",
"Hootie & the Blowfish",
"Florence & The Machine",
"Years & Years",
"Kool & The Gang",
"Gladys Knight & The Pips",
"Diana Ross & The Supremes",
"Smokey Robinson & The Miracles",
"Martha Reeves & The Vandellas",
"Huey Lewis & The News",
"Echo & the Bunnymen",
"Prince & The Revolution",
"Bruce Springsteen & The E Street Band",
"Tom Petty & The Heartbreakers",
];
/* credit separators, matched case-insensitively */
const SEPARATORS: &[&str] = &[" & ", ", ", " feat. ", " feat ", " ft. ", " × "];
fn starts_with_ci(s: &str, prefix: &str) -> bool {
s.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
fn push_name(out: &mut Vec<String>, raw: &str) {
let name = raw.trim();
if !name.is_empty() && !out.iter().any(|o| o == name) {
out.push(name.to_string());
}
}
/* Best-effort split of a flattened credit string. Only a fallback: prefer
* Track::artists, which comes straight from the source's own boundaries. */
pub fn split_artists(credit: &str) -> Vec<String> { pub fn split_artists(credit: &str) -> Vec<String> {
const MARK: char = '\u{1}';
let mut s = credit.to_string();
for sep in [
" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ",
] {
s = s.replace(sep, &MARK.to_string());
}
let mut out: Vec<String> = Vec::new(); let mut out: Vec<String> = Vec::new();
for part in s.split(MARK) { let mut start = 0;
let name = part.trim(); let mut i = 0;
if !name.is_empty() && !out.iter().any(|o| o == name) { while i < credit.len() {
out.push(name.to_string()); 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
} }
@ -125,9 +200,45 @@ mod tests {
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
assert_eq!(split_artists("A feat. B"), ["A", "B"]); assert_eq!(split_artists("A feat. B"), ["A", "B"]);
assert_eq!(split_artists("A, A"), ["A"]); assert_eq!(split_artists("A, A"), ["A"]);
assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]);
assert_eq!(
split_artists("Tyler, The Creator & Kali Uchis"),
["Tyler, The Creator", "Kali Uchis"]
);
assert_eq!(
split_artists("Earth, Wind & Fire, Someone"),
["Earth, Wind & Fire", "Someone"]
);
assert!(split_artists(" ").is_empty()); assert!(split_artists(" ").is_empty());
} }
#[test]
fn artist_splitting_edge_cases() {
assert_eq!(split_artists("hall & oates"), ["hall & oates"]);
assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]);
assert_eq!(split_artists("A Feat. B"), ["A", "B"]);
assert_eq!(
split_artists("Drake, Hall & Oates"),
["Drake", "Hall & Oates"]
);
assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]);
}
#[test]
fn artist_names_prefers_structured_list() {
let mut t = Track {
id: "abcdefghijk".into(),
title: "T".into(),
artist: "Blood, Sweat & Tears".into(),
artists: vec!["Blood, Sweat & Tears".into()],
album: None,
duration: None,
};
assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]);
t.artists.clear();
assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]);
}
#[test] #[test]
fn ids() { fn ids() {
assert!(is_valid_id("dQw4w9WgXcQ")); assert!(is_valid_id("dQw4w9WgXcQ"));

162
src/mpris.rs Normal file
View file

@ -0,0 +1,162 @@
/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Cmd {
Play,
Pause,
Toggle,
Stop,
Next,
Prev,
SeekBy(f64),
SeekTo(f64),
Quit,
}
#[cfg(target_os = "linux")]
mod imp {
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
PlatformConfig, SeekDirection,
};
use super::Cmd;
use crate::app::PlayState;
use crate::model::Track;
pub struct Mpris {
controls: MediaControls,
track: Option<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

@ -1,12 +1,116 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, 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.
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
#[cfg(unix)]
mod ipc {
use std::io;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
pub type Reader = UnixStream;
pub type Writer = UnixStream;
pub fn endpoint() -> PathBuf {
let dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
let path = dir.join(format!("drift-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&path);
path
}
pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> {
let stream = UnixStream::connect(path)?;
let reader = stream.try_clone()?;
Ok((reader, stream))
}
pub fn cleanup(path: &Path) {
let _ = std::fs::remove_file(path);
}
}
#[cfg(windows)]
mod ipc {
use std::ffi::c_void;
use std::fs::{File, OpenOptions};
use std::io::{self, Read};
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub type Writer = File;
extern "system" {
fn PeekNamedPipe(
pipe: *mut c_void,
buffer: *mut c_void,
buffer_size: u32,
bytes_read: *mut u32,
total_bytes_avail: *mut u32,
bytes_left_this_message: *mut u32,
) -> i32;
}
/// Reader half of the pipe.
///
/// A blocking `ReadFile` on a synchronous pipe handle also blocks any
/// `WriteFile` on a clone of that handle, which would deadlock the player.
/// So we poll with `PeekNamedPipe` and only read when data is waiting.
pub struct Reader(File);
impl Read for Reader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
loop {
let mut avail: u32 = 0;
let ok = unsafe {
PeekNamedPipe(
self.0.as_raw_handle() as *mut c_void,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
&mut avail,
std::ptr::null_mut(),
)
};
if ok == 0 {
// mpv exited / pipe closed: report EOF.
return Ok(0);
}
if avail > 0 {
let n = (avail as usize).min(buf.len());
return self.0.read(&mut buf[..n]);
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
pub fn endpoint() -> PathBuf {
PathBuf::from(format!(r"\\.\pipe\drift-{}", std::process::id()))
}
pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> {
let file = OpenOptions::new().read(true).write(true).open(path)?;
let reader = file.try_clone()?;
Ok((Reader(reader), file))
}
pub fn cleanup(_path: &Path) {
// Named pipes disappear when mpv exits.
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum PlayerEvent { pub enum PlayerEvent {
Position(Option<f64>), Position(Option<f64>),
@ -21,15 +125,13 @@ pub enum PlayerEvent {
pub struct Player { pub struct Player {
child: Child, child: Child,
writer: UnixStream, writer: ipc::Writer,
sock_path: PathBuf, sock_path: PathBuf,
} }
impl Player { impl Player {
pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<Player> { pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<Player> {
let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir); let sock_path = ipc::endpoint();
let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&sock_path);
let mut cmd = Command::new("mpv"); let mut cmd = Command::new("mpv");
cmd.args([ cmd.args([
@ -40,8 +142,7 @@ impl Player {
"--really-quiet", "--really-quiet",
"--audio-display=no", "--audio-display=no",
"--force-window=no", "--force-window=no",
"--ytdl=yes", "--ytdl=no",
"--ytdl-format=bestaudio/best",
"--cache=yes", "--cache=yes",
"--demuxer-max-bytes=32MiB", "--demuxer-max-bytes=32MiB",
]) ])
@ -56,7 +157,9 @@ impl Player {
cmd.stdin(Stdio::null()) cmd.stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()); .stderr(Stdio::null());
crate::sys::no_window(&mut cmd);
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe { unsafe {
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@ -74,9 +177,12 @@ impl Player {
} }
})?; })?;
// Windows: tie mpv's lifetime to ours.
crate::sys::kill_on_exit(&child);
let start = Instant::now(); let start = Instant::now();
let stream = loop { let (reader, writer) = loop {
match UnixStream::connect(&sock_path) { match ipc::connect(&sock_path) {
Ok(s) => break s, Ok(s) => break s,
Err(e) => { Err(e) => {
if let Ok(Some(status)) = child.try_wait() { if let Ok(Some(status)) = child.try_wait() {
@ -92,10 +198,9 @@ impl Player {
} }
}; };
let reader = stream.try_clone().context("cloning mpv socket")?;
let mut player = Player { let mut player = Player {
child, child,
writer: stream, writer,
sock_path, sock_path,
}; };
@ -107,16 +212,44 @@ impl Player {
] { ] {
player.send(json!(["observe_property", id, prop]))?; player.send(json!(["observe_property", id, prop]))?;
} }
player.send(json!(["request_log_messages", "error"]))?;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut reason: Option<String> = None;
for line in BufReader::new(reader).lines() { for line in BufReader::new(reader).lines() {
let Ok(line) = line else { break }; let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<Value>(&line) else { let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue; continue;
}; };
if let Some(ev) = parse_event(&v) { match v.get("event").and_then(Value::as_str) {
on_event(ev); Some("log-message") => {
let prefix = v.get("prefix").and_then(Value::as_str).unwrap_or("");
let text = v.get("text").and_then(Value::as_str).unwrap_or("").trim();
match text.strip_prefix("ERROR: ") {
Some(t) if prefix == "ytdl_hook" => reason = Some(ytdl_reason(t)),
_ if reason.is_none() && !text.is_empty() && !text.starts_with("Failed to open") => {
reason = Some(text.lines().next().unwrap_or(text).to_string());
}
_ => {}
}
continue;
}
Some("start-file") => reason = None,
_ => {}
} }
let Some(mut ev) = parse_event(&v) else {
continue;
};
match &mut ev {
PlayerEvent::LoadError(msg) => {
if let Some(r) = reason.take() {
*msg = r;
}
}
PlayerEvent::FileLoaded => reason = None,
_ => {}
}
on_event(ev);
} }
on_event(PlayerEvent::Died); on_event(PlayerEvent::Died);
}); });
@ -137,9 +270,9 @@ impl Player {
matches!(self.child.try_wait(), Ok(None)) matches!(self.child.try_wait(), Ok(None))
} }
pub fn load(&mut self, url: &str) -> Result<()> { pub fn load(&mut self, path: &str) -> Result<()> {
self.send(json!(["set_property", "pause", false]))?; self.send(json!(["set_property", "pause", false]))?;
self.send(json!(["loadfile", url, "replace"])) self.send(json!(["loadfile", path, "replace"]))
} }
pub fn set_pause(&mut self, pause: bool) -> Result<()> { pub fn set_pause(&mut self, pause: bool) -> Result<()> {
@ -174,10 +307,124 @@ impl Drop for Player {
} }
let _ = self.child.kill(); let _ = self.child.kill();
let _ = self.child.wait(); let _ = self.child.wait();
let _ = std::fs::remove_file(&self.sock_path); ipc::cleanup(&self.sock_path);
} }
} }
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" => {
@ -255,6 +502,19 @@ mod tests {
assert!(eof, "end-of-file not seen"); assert!(eof, "end-of-file not seen");
} }
#[test]
fn ytdl_reason_is_trimmed() {
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"),
"Sign in to confirm you're not a bot. Use --cookies."
);
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"),
"Failed to extract any player response"
);
assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo");
}
#[test] #[test]
fn event_parsing() { fn event_parsing() {
let v: Value = serde_json::from_str( let v: Value = serde_json::from_str(

View file

@ -17,12 +17,42 @@ pub struct Session {
impl Session { impl Session {
fn path() -> Result<PathBuf> { fn path() -> Result<PathBuf> {
let dir = dirs::state_dir() let dir = crate::store::state_base()
.or_else(dirs::data_local_dir)
.ok_or_else(|| anyhow!("cannot determine state directory"))?; .ok_or_else(|| anyhow!("cannot determine state directory"))?;
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())
} }
@ -76,6 +106,7 @@ mod tests {
id: id.into(), id: id.into(),
title: "T".into(), title: "T".into(),
artist: "A".into(), artist: "A".into(),
artists: Vec::new(),
album: None, album: None,
duration: Some(200), duration: Some(200),
} }

View file

@ -10,6 +10,32 @@ use crate::model::Track;
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct Store { pub struct Store {
pub playlists: BTreeMap<String, Vec<Track>>, pub playlists: BTreeMap<String, Vec<Track>>,
/* what the file looked like when we last read or wrote it */
#[serde(skip)]
base: BTreeMap<String, Vec<Track>>,
}
/* keep whatever another drift wrote, but apply the playlists this one changed */
fn merge(
base: &BTreeMap<String, Vec<Track>>,
mine: &BTreeMap<String, Vec<Track>>,
disk: BTreeMap<String, Vec<Track>>,
) -> BTreeMap<String, Vec<Track>> {
let mut out = disk;
for name in base.keys().chain(mine.keys()) {
if base.get(name) == mine.get(name) {
continue;
}
match mine.get(name) {
Some(list) => {
out.insert(name.clone(), list.clone());
}
None => {
out.remove(name);
}
}
}
out
} }
pub fn write_atomic(path: &Path, text: &str) -> Result<()> { pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
@ -18,13 +44,44 @@ pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
} }
let tmp = path.with_extension("tmp"); let tmp = path.with_extension("tmp");
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; // On Windows the rename can fail briefly while an antivirus scanner or
Ok(()) // indexer holds the destination open, so retry a few times.
let mut last = None;
for attempt in 0..6 {
match fs::rename(&tmp, path) {
Ok(()) => return Ok(()),
Err(e) => {
last = Some(e);
if attempt < 5 {
std::thread::sleep(std::time::Duration::from_millis(40));
}
}
}
}
let _ = fs::remove_file(&tmp);
Err(last.expect("at least one attempt"))
.with_context(|| format!("replacing {}", path.display()))
}
/// Base directory for settings and playlists (`DRIFT_CONFIG_DIR` overrides it).
pub fn config_base() -> Option<PathBuf> {
std::env::var_os("DRIFT_CONFIG_DIR")
.map(PathBuf::from)
.or_else(dirs::config_dir)
}
/// Base directory for the saved session (`DRIFT_STATE_DIR` overrides it).
/// `dirs::state_dir()` is `None` on Windows and macOS, so fall back to local data.
pub fn state_base() -> Option<PathBuf> {
std::env::var_os("DRIFT_STATE_DIR")
.map(PathBuf::from)
.or_else(dirs::state_dir)
.or_else(dirs::data_local_dir)
} }
impl Store { impl Store {
pub fn dir() -> Result<PathBuf> { pub fn dir() -> Result<PathBuf> {
let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?; let dir = config_base().ok_or_else(|| anyhow!("cannot determine config directory"))?;
let new_dir = dir.join("drift"); let new_dir = dir.join("drift");
let old_dir = dir.join("ytm-tui"); let old_dir = dir.join("ytm-tui");
if !new_dir.exists() && old_dir.exists() { if !new_dir.exists() && old_dir.exists() {
@ -53,7 +110,10 @@ impl Store {
} }
}; };
match serde_json::from_str::<Store>(&text) { match serde_json::from_str::<Store>(&text) {
Ok(s) => (s, None), Ok(mut s) => {
s.base = s.playlists.clone();
(s, None)
}
Err(e) => { Err(e) => {
let bak = path.with_extension("json.bak"); let bak = path.with_extension("json.bak");
let _ = fs::rename(&path, &bak); let _ = fs::rename(&path, &bak);
@ -68,8 +128,18 @@ impl Store {
} }
} }
pub fn save(&self) -> Result<()> { fn read_disk() -> Option<BTreeMap<String, Vec<Track>>> {
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) let text = fs::read_to_string(Self::path().ok()?).ok()?;
serde_json::from_str::<Store>(&text).ok().map(|s| s.playlists)
}
pub fn save(&mut self) -> Result<()> {
if let Some(disk) = Self::read_disk() {
self.playlists = merge(&self.base, &self.playlists, disk);
}
write_atomic(&Self::path()?, &serde_json::to_string_pretty(&*self)?)?;
self.base = self.playlists.clone();
Ok(())
} }
pub fn resolve_exact(&self, name: &str) -> Option<String> { pub fn resolve_exact(&self, name: &str) -> Option<String> {
@ -103,6 +173,33 @@ impl Store {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn merge_keeps_other_instances_changes() {
let t = |id: &str| Track {
id: id.into(),
title: "T".into(),
artist: "A".into(),
artists: Vec::new(),
album: None,
duration: None,
};
let base: BTreeMap<String, Vec<Track>> = BTreeMap::from([
("a".to_string(), vec![t("aaaaaaaaaaa")]),
("b".to_string(), vec![]),
]);
let mut mine = base.clone();
mine.insert("c".into(), vec![t("ccccccccccc")]);
mine.remove("b");
let mut disk = base.clone();
disk.insert("d".into(), vec![t("ddddddddddd")]);
disk.get_mut("a").unwrap().push(t("eeeeeeeeeee"));
let out = merge(&base, &mine, disk);
let names: Vec<&str> = out.keys().map(String::as_str).collect();
assert_eq!(names, ["a", "c", "d"]);
assert_eq!(out["a"].len(), 2);
}
#[test] #[test]
fn exact_ignores_prefixes() { fn exact_ignores_prefixes() {
let mut s = Store::default(); let mut s = Store::default();

91
src/sys.rs Normal file
View file

@ -0,0 +1,91 @@
//! Small platform shims so the rest of the app stays free of `cfg` noise.
use std::process::Command;
/// Keep child processes from flashing a console window on Windows.
pub fn no_window(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// Windows: make sure `child` is killed when drift exits, even if drift
/// crashes (the equivalent of Linux's PR_SET_PDEATHSIG).
#[cfg(windows)]
pub fn kill_on_exit(child: &std::process::Child) {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
use std::sync::OnceLock;
#[repr(C)]
struct BasicLimits {
per_process_user_time_limit: i64,
per_job_user_time_limit: i64,
limit_flags: u32,
minimum_working_set_size: usize,
maximum_working_set_size: usize,
active_process_limit: u32,
affinity: usize,
priority_class: u32,
scheduling_class: u32,
}
#[repr(C)]
struct IoCounters([u64; 6]);
#[repr(C)]
struct ExtendedLimits {
basic: BasicLimits,
io: IoCounters,
process_memory_limit: usize,
job_memory_limit: usize,
peak_process_memory_used: usize,
peak_job_memory_used: usize,
}
extern "system" {
fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> *mut c_void;
fn SetInformationJobObject(
job: *mut c_void,
class: i32,
info: *mut c_void,
len: u32,
) -> i32;
fn AssignProcessToJobObject(job: *mut c_void, process: *mut c_void) -> i32;
}
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9;
const KILL_ON_JOB_CLOSE: u32 = 0x2000;
// The job handle is intentionally kept open for the life of the process;
// when drift exits (or dies) Windows closes it and kills everything in it.
static JOB: OnceLock<usize> = OnceLock::new();
let job = *JOB.get_or_init(|| unsafe {
let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if job.is_null() {
return 0;
}
let mut info: ExtendedLimits = std::mem::zeroed();
info.basic.limit_flags = KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
&mut info as *mut _ as *mut c_void,
std::mem::size_of::<ExtendedLimits>() as u32,
);
if ok == 0 {
return 0;
}
job as usize
});
if job != 0 {
unsafe {
AssignProcessToJobObject(job as *mut c_void, child.as_raw_handle() as *mut c_void);
}
}
}
#[cfg(not(windows))]
pub fn kill_on_exit(_child: &std::process::Child) {}

1018
src/ui.rs

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,52 @@
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, Track}; use crate::model::{is_valid_id, parse_clock, Collection, 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 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 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 tracks: Vec<Track>,
pub next: Option<String>,
}
impl From<Vec<Track>> for Page {
fn from(tracks: Vec<Track>) -> Self {
Page { tracks, next: None }
}
}
pub fn search(query: &str) -> Result<Vec<Track>, String> { pub fn search(query: &str) -> Result<Vec<Track>, String> {
search_page(query).map(|p| p.tracks)
}
pub fn search_page(query: &str) -> Result<Page, String> {
match search_innertube(query) { match search_innertube(query) {
Ok(tracks) if !tracks.is_empty() => Ok(tracks), Ok(page) if !page.tracks.is_empty() => Ok(page),
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), Ok(_) => ytdlp_search(query)
Err(primary) => ytdlp_search(query).map_err(|fallback| { .map(Page::from)
.map_err(|e| format!("{e:#}")),
Err(primary) => ytdlp_search(query).map(Page::from).map_err(|fallback| {
format!( format!(
"YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})" "YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})"
) )
@ -23,6 +54,98 @@ pub fn search(query: &str) -> Result<Vec<Track>, String> {
} }
} }
pub fn search_more(token: &str) -> Result<Page, String> {
let url = format!("{ENDPOINT}&ctoken={token}&continuation={token}");
post(&url, json!({}))
.map(|v| page_from(&v))
.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());
@ -53,32 +176,100 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) {
(if m <= 2 { y + 1 } else { y }, m, d) (if m <= 2 { y + 1 } else { y }, m, d)
} }
fn search_innertube(query: &str) -> Result<Vec<Track>> { fn search_innertube(query: &str) -> Result<Page> {
let agent = ureq::AgentBuilder::new() let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?;
.timeout(Duration::from_secs(12)) Ok(page_from(&v))
.user_agent(UA) }
.build();
let body = json!({ fn page_from(v: &Value) -> Page {
"context": { "client": { Page {
"clientName": "WEB_REMIX", tracks: parse_search_response(v),
"clientVersion": client_version(), next: find_continuation(v),
"hl": "en", }
"gl": "US", }
}},
"query": query, fn find_continuation(v: &Value) -> Option<String> {
"params": SONGS_FILTER, match v {
}); Value::Object(map) => {
let resp = agent let token = map
.post(ENDPOINT) .get("nextContinuationData")
.or_else(|| map.get("nextRadioContinuationData"))
.and_then(|c| c.get("continuation"))
.or_else(|| {
map.get("continuationCommand")
.and_then(|c| c.get("token"))
})
.and_then(Value::as_str);
match token {
Some(t) => Some(t.to_string()),
None => map.values().find_map(find_continuation),
}
}
Value::Array(arr) => arr.iter().find_map(find_continuation),
_ => None,
}
}
/* one shared agent so paging and radio reuse the same connection */
fn agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12))
.user_agent(UA)
.build()
})
}
fn post(url: &str, mut body: Value) -> Result<Value> {
body["context"] = json!({ "client": {
"clientName": "WEB_REMIX",
"clientVersion": client_version(),
"hl": "en",
"gl": "US",
}});
let resp = agent()
.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/")
.set("Cookie", "SOCS=CAI") .set("Cookie", "SOCS=CAI")
.send_json(body) .send_json(body)
.map_err(|e| anyhow!("request failed: {e}"))?; .map_err(|e| anyhow!("request failed: {e}"))?;
let v: Value = resp resp.into_json::<Value>()
.into_json() .context("invalid JSON from YouTube Music")
.context("invalid JSON from YouTube Music")?; }
Ok(parse_search_response(&v))
pub fn artist_top_songs(name: &str) -> Result<Vec<Track>, String> {
let fetch = || -> Result<Vec<Track>> {
let found = post(ENDPOINT, json!({ "query": name, "params": ARTISTS_FILTER }))?;
let Some(id) = pick_artist_id(&found, name) else {
return Ok(Vec::new());
};
let page = post(BROWSE_ENDPOINT, json!({ "browseId": id }))?;
Ok(parse_search_response(&page))
};
fetch().map_err(|e| format!("{e:#}"))
}
fn pick_artist_id(v: &Value, name: &str) -> Option<String> {
let mut items = Vec::new();
collect_items(v, &mut items);
let artists: Vec<(String, &str)> = items
.into_iter()
.filter_map(|item| {
let id = item
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
.as_str()?;
let title = column_segments(item, 0).into_iter().next()?;
id.starts_with("UC").then_some((title, id))
})
.collect();
let wanted = name.to_lowercase();
artists
.iter()
.find(|(title, _)| title.to_lowercase() == wanted)
.or(artists.first())
.map(|(_, id)| id.to_string())
} }
fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) { fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
@ -110,12 +301,45 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
} }
} }
fn column_segments(item: &Value, col: usize) -> Vec<String> { fn column_runs(item: &Value, col: usize) -> Option<&Vec<Value>> {
let runs = item item.get("flexColumns")
.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() {
@ -140,6 +364,13 @@ 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) {
@ -162,10 +393,12 @@ 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"))
@ -175,11 +408,69 @@ 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);
@ -191,13 +482,86 @@ 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}"))
} }
fn ytdlp_entries(target: &str) -> Result<Vec<Track>> { fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
let out = Command::new("yt-dlp") let mut cmd = Command::new("yt-dlp");
.args([ cmd.args([
"--flat-playlist", "--flat-playlist",
"--dump-json", "--dump-json",
"--no-warnings", "--no-warnings",
@ -206,7 +570,8 @@ fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
"15", "15",
]) ])
.arg(target) .arg(target)
.stdin(Stdio::null()) .stdin(Stdio::null());
let out = crate::sys::no_window(&mut cmd)
.output() .output()
.map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; .map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
@ -239,11 +604,24 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
.unwrap_or("Unknown artist") .unwrap_or("Unknown artist")
.trim_end_matches(" - Topic") .trim_end_matches(" - Topic")
.to_string(); .to_string();
/* newer yt-dlp lists each credited artist separately */
let artists: Vec<String> = v
.get("artists")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32); let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
Some(Track { Some(Track {
id: id.to_string(), id: id.to_string(),
title, title,
artist, artist,
artists,
album: None, album: None,
duration, duration,
}) })
@ -302,6 +680,36 @@ mod tests {
assert_eq!(t.duration, Some(241)); assert_eq!(t.duration, Some(241));
} }
#[test]
fn keeps_linked_artist_runs_whole() {
let link = |text: &str, id: &str| {
json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}})
};
let resp = json!([{"musicResponsiveListItemRenderer": {
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "},
link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "},
link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"}
]}}}
]
}}]);
let t = &parse_search_response(&resp)[0];
assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis");
assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]);
assert_eq!(t.album.as_deref(), Some("Album"));
}
#[test]
fn reads_ytdlp_artists_list() {
let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#;
let t = parse_ytdlp_lines(line);
assert_eq!(t[0].artists, ["A", "B"]);
assert_eq!(t[0].artist, "A, B");
}
#[test] #[test]
fn skips_items_without_video_id() { fn skips_items_without_video_id() {
let resp = json!([{"musicResponsiveListItemRenderer": { let resp = json!([{"musicResponsiveListItemRenderer": {
@ -323,6 +731,106 @@ not json
assert_eq!(t[0].duration, Some(213)); assert_eq!(t[0].duration, Some(213));
} }
#[test]
fn picks_artist_by_name() {
let artist = |name: &str, id: &str| {
json!({"musicResponsiveListItemRenderer": {
"navigationEndpoint": {"browseEndpoint": {"browseId": id}},
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run(name)}]
}})
};
let resp = json!({"contents": [
artist("Daft Punk Tribute", "UCaaa"),
artist("Daft Punk", "UCbbb"),
]});
assert_eq!(pick_artist_id(&resp, "daft punk").as_deref(), Some("UCbbb"));
assert_eq!(pick_artist_id(&resp, "Other").as_deref(), Some("UCaaa"));
assert_eq!(pick_artist_id(&json!([]), "x"), None);
}
#[test]
fn finds_continuation_tokens() {
let old = json!({"musicShelfRenderer": {"contents": [], "continuations": [
{"nextContinuationData": {"continuation": "abc%3D", "clickTrackingParams": "x"}}
]}});
assert_eq!(find_continuation(&old).as_deref(), Some("abc%3D"));
let new = json!([{"continuationItemRenderer": {"continuationEndpoint": {
"continuationCommand": {"token": "def", "request": "CONTINUATION_REQUEST_TYPE_SEARCH"}
}}}]);
assert_eq!(find_continuation(&new).as_deref(), Some("def"));
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));