mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Compare commits
24 changed files with 505 additions and 6390 deletions
22
.github/workflows/rust.yml
vendored
22
.github/workflows/rust.yml
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
name: Rust
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Build
|
|
||||||
run: cargo build --verbose
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --verbose
|
|
||||||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,40 +0,0 @@
|
||||||
# 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
|
|
||||||
1362
Cargo.lock
generated
1362
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@ name = "drift"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "A Vim-style terminal music player for YouTube Music (no login required)"
|
description = "A Vim-style terminal music player for YouTube Music (no login required)"
|
||||||
license = "GPL-3.0-or-later"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
|
|
||||||
|
|
@ -16,10 +16,6 @@ toml = "0.8"
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "2", features = ["json"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
discord-rich-presence = "1.1.0"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
323
README.md
323
README.md
|
|
@ -1,322 +1,81 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# Drift
|
<h1>Drift</h1>
|
||||||
|
|
||||||
### A YouTube Music player for your terminal, with Vim keybinds.
|
|
||||||
|
|
||||||
Search, queue and play music with no account, no login and no browser.
|
|
||||||
|
|
||||||
[](https://github.com/socatlolmeow/drift/stargazers)
|
[](https://github.com/socatlolmeow/drift/stargazers)
|
||||||
[](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml)
|

|
||||||
[](LICENSE)
|
|
||||||

|
|
||||||
|
|
||||||
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
|
### A music streaming client using Vim keybinds built in Rust.
|
||||||
|
|
||||||
|
Stream your favourite music from YouTube Music — all in one client, in your terminal.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of contents
|
<div align="center">
|
||||||
|
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
|
||||||
- [Features](#features)
|
</div>
|
||||||
- [Installation](#installation)
|
|
||||||
- [Arch Linux (AUR)](#arch-linux-aur)
|
|
||||||
- [Install script](#install-script)
|
|
||||||
- [From source](#from-source)
|
|
||||||
- [Nix](#nix)
|
|
||||||
- [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually)
|
|
||||||
- [Usage](#usage)
|
|
||||||
- [Keybinds](#keybinds)
|
|
||||||
- [Commands](#commands)
|
|
||||||
- [Lyrics](#lyrics)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Streaming or downloading](#streaming-or-downloading)
|
|
||||||
- [Discord rich presence](#discord-rich-presence)
|
|
||||||
- [Custom keys](#custom-keys)
|
|
||||||
- [Environment variables](#environment-variables)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
- [Contributing](#contributing)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line
|
- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv`
|
||||||
- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung
|
- **Vim-style keybinds** — navigate with `j`/`k`, enter commands with `:`, quit with `q`
|
||||||
- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL
|
- **Queue management** — add, remove, reorder, and clear tracks in your queue
|
||||||
- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists
|
- **Playlists** — save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs
|
||||||
- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info
|
- **Shuffle & repeat** — toggle shuffle and cycle through off / repeat-all / repeat-one modes
|
||||||
- A command palette with completion and history
|
- **Volume & seek control** — adjust volume and seek with configurable step sizes
|
||||||
- Your queue and position are restored the next time you start Drift
|
- **Session restore** — automatically restores your queue and playback position on next launch
|
||||||
- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence
|
- **Artist search** — drill into an artist's discography from the results view
|
||||||
- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds
|
- **Command palette** — full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more)
|
||||||
|
- **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
|
||||||
|
|
||||||
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.
|
### Dependencies
|
||||||
|
|
||||||
### Arch Linux (AUR)
|
- [mpv](https://mpv.io/) — audio playback
|
||||||
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube stream extraction
|
||||||
|
- [Rust](https://rustup.rs/) (build only)
|
||||||
|
|
||||||
```bash
|
Install them with your package manager, e.g. on Arch:
|
||||||
yay -S driftmusic-git
|
```
|
||||||
|
sudo pacman -S mpv yt-dlp
|
||||||
```
|
```
|
||||||
|
|
||||||
Any AUR helper works. You can also build it by hand:
|
### Build & install
|
||||||
|
|
||||||
```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
|
||||||
./install.sh
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
### From source
|
Then copy the binary to somewhere on your `$PATH`:
|
||||||
|
```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 puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup.
|
This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/).
|
||||||
|
|
||||||
### Nix
|
---
|
||||||
|
|
||||||
The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you:
|
## Star History
|
||||||
|
|
||||||
```bash
|
[](https://star-history.com/#socatlolmeow/drift)
|
||||||
nix run github:socatlolmeow/drift
|
|
||||||
```
|
|
||||||
|
|
||||||
### Installing mpv and yt-dlp manually
|
---
|
||||||
|
|
||||||
| Platform | Command |
|
|
||||||
| --- | --- |
|
|
||||||
| Debian / Ubuntu | `sudo apt install mpv` and `pipx install yt-dlp` |
|
|
||||||
| macOS | `brew install mpv yt-dlp` |
|
|
||||||
| Arch | `sudo pacman -S mpv yt-dlp` |
|
|
||||||
| Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` |
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
drift # start the player
|
|
||||||
drift "daft punk" # start with a search
|
|
||||||
drift --check # check that mpv and yt-dlp are installed
|
|
||||||
drift --help
|
|
||||||
drift --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Press `/` to search, `Enter` to play, `a` to add a song to the queue and `?` for the full list of keybinds. Inside the player, `:help` lists the commands.
|
|
||||||
|
|
||||||
## Keybinds
|
|
||||||
|
|
||||||
These are the defaults. You can change them, see [Custom keys](#custom-keys).
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --- | --- |
|
|
||||||
| `j` `k` / `↓` `↑` | move (counts work: `5j`) |
|
|
||||||
| `gg` `ge` | jump to top / bottom (`5gg` goes to row 5) |
|
|
||||||
| `Tab` `Shift-Tab` | cycle Results / Queue / Playlists |
|
|
||||||
| `gr` `gq` `gp` | jump to Results / Queue / Playlists |
|
|
||||||
| `Enter` | play the selected song |
|
|
||||||
| `Space` | pause / resume |
|
|
||||||
| `n` `N` | next / previous track |
|
|
||||||
| `h` `l` / `←` `→` | seek back / forward (counts: `6l`) |
|
|
||||||
| `+` `-` | volume up / down |
|
|
||||||
| `s` `r` | toggle shuffle / cycle repeat |
|
|
||||||
| `a` `A` `P` | queue the song / queue all results / play all results |
|
|
||||||
| `dd` `x` | remove from queue or playlist |
|
|
||||||
| `J` `K` | move a queue or playlist item down / up |
|
|
||||||
| `G` | song menu |
|
|
||||||
| `ga` | go to the artist |
|
|
||||||
| `gs` | see similar songs |
|
|
||||||
| `gb` | go to the album |
|
|
||||||
| `gB` | save the album as a playlist |
|
|
||||||
| `L` | toggle the lyrics panel |
|
|
||||||
| `F` | fullscreen lyrics |
|
|
||||||
| `/` `:` | search / command line |
|
|
||||||
| `?` | help |
|
|
||||||
| `q` | quit |
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on).
|
|
||||||
|
|
||||||
| Command | Action |
|
|
||||||
| --- | --- |
|
|
||||||
| `:search "query"` | search YouTube Music |
|
|
||||||
| `:play [N]` | play or resume, or play row `N` |
|
|
||||||
| `:pause` `:resume` `:toggle` `:stop` | control playback |
|
|
||||||
| `:next` `:prev` | next / previous track |
|
|
||||||
| `:seek 90` `1:30` `+10` | absolute or relative seek |
|
|
||||||
| `:vol 50` `+5` | set or change the volume |
|
|
||||||
| `:shuffle` `:repeat [off\|all\|one]` | playback modes |
|
|
||||||
| `:add [N]` `:addall` `:playall` | queue results |
|
|
||||||
| `:remove [N]` `:clear` | remove from the queue / clear it |
|
|
||||||
| `:save name` `:load name` `:append name` | work with playlists |
|
|
||||||
| `:save! name` | save and overwrite an existing playlist |
|
|
||||||
| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist |
|
|
||||||
| `:addto name` `:delpl name` | add a song to / delete a playlist |
|
|
||||||
| `:import URL` | import a YouTube / YouTube Music playlist |
|
|
||||||
| `:album [query]` | open the selected song's album, or search albums |
|
|
||||||
| `:playlist query` | search YouTube Music playlists |
|
|
||||||
| `:similar` | songs similar to the selected one (also `:radio`) |
|
|
||||||
| `:artist` | songs by the selected song's artist |
|
|
||||||
| `:results` `:queue` `:playlists` | switch view |
|
|
||||||
| `:lyrics` | toggle the lyrics panel |
|
|
||||||
| `:quit` | quit |
|
|
||||||
|
|
||||||
`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it.
|
|
||||||
|
|
||||||
## Lyrics
|
|
||||||
|
|
||||||
Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text that you can scroll with `j`/`k`.
|
|
||||||
|
|
||||||
Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Settings live in `config.toml` inside Drift's config directory (`~/.config/drift/` on Linux). Drift only reads the file and never rewrites it, so your comments are safe. Restart Drift after editing.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[playback]
|
|
||||||
volume = 80
|
|
||||||
shuffle = false
|
|
||||||
repeat = "off" # off, all or one
|
|
||||||
autoplay = false
|
|
||||||
restore_session = true
|
|
||||||
seek_step = 5.0
|
|
||||||
volume_step = 5.0
|
|
||||||
|
|
||||||
[artist]
|
|
||||||
popular_first = false
|
|
||||||
|
|
||||||
[ui]
|
|
||||||
accent = "cyan" # a color name or a hex value like "#FF8800"
|
|
||||||
|
|
||||||
[discord]
|
|
||||||
client_id = "" # set this to enable Discord rich presence
|
|
||||||
|
|
||||||
[audio]
|
|
||||||
download = false # false: stream songs; true: download to the cache first
|
|
||||||
download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit)
|
|
||||||
cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback
|
|
||||||
|
|
||||||
[keys]
|
|
||||||
quit = "Q"
|
|
||||||
move_down = ["j", "down", "ctrl+n"]
|
|
||||||
move_up = ["k", "up", "ctrl+p"]
|
|
||||||
go_top = ["gg", "home"]
|
|
||||||
shuffle = [] # an empty list unbinds the action
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file.
|
|
||||||
|
|
||||||
### Streaming or downloading
|
|
||||||
|
|
||||||
By default Drift streams songs, so playback starts right away, even for hour-long tracks. With `download = true` songs are cached on disk first, and the next song in the queue is fetched in the background while the current one plays.
|
|
||||||
|
|
||||||
### Discord rich presence
|
|
||||||
|
|
||||||
This is off by default.
|
|
||||||
|
|
||||||
1. Create an application at https://discord.com/developers/applications. Its name is what shows up after "Listening to".
|
|
||||||
2. Copy the Application ID into `client_id` under `[discord]` in `config.toml`.
|
|
||||||
3. Restart Drift.
|
|
||||||
|
|
||||||
Your status then shows the current song with cover art, album, a progress bar and a "Listen Along" button. If Discord isn't running, Drift keeps retrying quietly in the background.
|
|
||||||
|
|
||||||
### Custom keys
|
|
||||||
|
|
||||||
To change keys, add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys. Everything you leave out keeps its default.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[keys]
|
|
||||||
quit = "Q"
|
|
||||||
move_down = ["j", "down", "ctrl+n"]
|
|
||||||
move_up = ["k", "up", "ctrl+p"]
|
|
||||||
go_top = ["gg", "home"]
|
|
||||||
shuffle = [] # an empty list unbinds the action
|
|
||||||
```
|
|
||||||
|
|
||||||
A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys.
|
|
||||||
|
|
||||||
| Action | Default keys |
|
|
||||||
| --- | --- |
|
|
||||||
| `move_down` `move_up` | `j` `down` / `k` `up` |
|
|
||||||
| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` |
|
|
||||||
| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` |
|
|
||||||
| `go_top` `go_bottom` | `gg` `home` / `ge` `end` |
|
|
||||||
| `next_view` `prev_view` | `tab` / `backtab` |
|
|
||||||
| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` |
|
|
||||||
| `activate` | `enter` |
|
|
||||||
| `pause` | `space` |
|
|
||||||
| `next` `prev` | `n` `>` / `N` `<` |
|
|
||||||
| `seek_back` `seek_forward` | `h` `left` / `l` `right` |
|
|
||||||
| `volume_up` `volume_down` | `+` `=` / `-` `_` |
|
|
||||||
| `add` `add_all` `play_all` | `a` / `A` / `P` |
|
|
||||||
| `delete` | `x` `delete` `dd` |
|
|
||||||
| `move_item_down` `move_item_up` | `J` / `K` |
|
|
||||||
| `shuffle` `repeat` | `s` / `r` |
|
|
||||||
| `menu` | `G` `menu` |
|
|
||||||
| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` |
|
|
||||||
| `lyrics_panel` `lyrics` | `L` / `F` |
|
|
||||||
| `search` `command` | `/` / `:` |
|
|
||||||
| `help` | `?` |
|
|
||||||
| `quit` | `q` |
|
|
||||||
| `back` | `backspace` `esc` |
|
|
||||||
|
|
||||||
In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable. Popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults.
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | What it does |
|
|
||||||
| --- | --- |
|
|
||||||
| `DRIFT_CONFIG_DIR` | where Drift keeps `config.toml` and your playlists |
|
|
||||||
| `DRIFT_STATE_DIR` | where Drift keeps the saved session |
|
|
||||||
| `DRIFT_MPV_ARGS` | extra arguments passed to mpv |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Drift says mpv or yt-dlp is missing.** Run `drift --check` to see which one, and install it with the table in [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually).
|
|
||||||
|
|
||||||
**Playback fails with "Sign in to confirm you're not a bot".** YouTube wants a login. Set `cookies_from_browser` under `[audio]` to a browser you're logged into YouTube with (for example `"firefox"`), then restart Drift. Updating yt-dlp also helps, since YouTube changes things often.
|
|
||||||
|
|
||||||
**The lyrics panel doesn't show up.** It needs a terminal at least 70 columns wide. Make the window wider or use `F` for fullscreen lyrics.
|
|
||||||
|
|
||||||
**`drift: command not found`.** Make sure `~/.cargo/bin` is in your `$PATH`.
|
|
||||||
|
|
||||||
**`gb` opens the wrong album.** Drift looks albums up by album and artist name, so it can guess wrong, and the song needs album info to begin with. `:album <query>` lets you search for it yourself.
|
|
||||||
|
|
||||||
You can run several Drift windows at once. Only the first one restores and saves the session, and playlists saved from different windows are merged instead of overwritten.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
Recent changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Drift](https://github.com/socatlolmeow/drift) is free software, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html).
|
[Drift](https://github.com/socatlolmeow/drift) is FOSS, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html).
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 5.7 MiB |
27
flake.lock
generated
27
flake.lock
generated
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1792488032,
|
|
||||||
"narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "e513db7b0a65def6f6e17bb55195dfe510871944",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
26
flake.nix
26
flake.nix
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
|
||||||
let
|
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
|
||||||
forAll = nixpkgs.lib.genAttrs systems;
|
|
||||||
in {
|
|
||||||
packages = forAll (system:
|
|
||||||
let pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
in {
|
|
||||||
default = pkgs.rustPlatform.buildRustPackage {
|
|
||||||
pname = "drift";
|
|
||||||
version = "1.3";
|
|
||||||
src = ./.;
|
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
|
||||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
|
||||||
postInstall = ''
|
|
||||||
wrapProgram $out/bin/drift \
|
|
||||||
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.mpv pkgs.yt-dlp ]}
|
|
||||||
'';
|
|
||||||
meta.mainProgram = "drift";
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
76
install.sh
76
install.sh
|
|
@ -1,76 +0,0 @@
|
||||||
#!/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"
|
|
||||||
1518
src/app.rs
1518
src/app.rs
File diff suppressed because it is too large
Load diff
|
|
@ -30,19 +30,12 @@ pub enum Command {
|
||||||
Shuffle,
|
Shuffle,
|
||||||
Repeat(Option<RepeatMode>),
|
Repeat(Option<RepeatMode>),
|
||||||
Save(String),
|
Save(String),
|
||||||
SaveForce(String),
|
|
||||||
SaveAlbum(Option<String>),
|
|
||||||
SaveResults(Option<String>),
|
|
||||||
Load(String),
|
Load(String),
|
||||||
Append(String),
|
Append(String),
|
||||||
DeletePlaylist(String),
|
DeletePlaylist(String),
|
||||||
AddTo(String),
|
AddTo(String),
|
||||||
Import(String),
|
Import(String),
|
||||||
Artist,
|
Artist,
|
||||||
Similar,
|
|
||||||
Album(Option<String>),
|
|
||||||
PlaylistSearch(String),
|
|
||||||
Lyrics,
|
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,18 +62,12 @@ pub const COMMAND_NAMES: &[&str] = &[
|
||||||
"shuffle",
|
"shuffle",
|
||||||
"repeat",
|
"repeat",
|
||||||
"save",
|
"save",
|
||||||
"savealbum",
|
|
||||||
"saveresults",
|
|
||||||
"load",
|
"load",
|
||||||
"append",
|
"append",
|
||||||
"delpl",
|
"delpl",
|
||||||
"addto",
|
"addto",
|
||||||
"import",
|
"import",
|
||||||
"artist",
|
"artist",
|
||||||
"similar",
|
|
||||||
"album",
|
|
||||||
"playlist",
|
|
||||||
"lyrics",
|
|
||||||
"help",
|
"help",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -177,9 +164,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
||||||
other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
|
other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
|
||||||
},
|
},
|
||||||
"save" | "w" => Save(name_arg(arg, "save")?),
|
"save" | "w" => Save(name_arg(arg, "save")?),
|
||||||
"save!" | "w!" => SaveForce(name_arg(arg, "save!")?),
|
|
||||||
"savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())),
|
|
||||||
"saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())),
|
|
||||||
"load" | "e" => Load(name_arg(arg, "load")?),
|
"load" | "e" => Load(name_arg(arg, "load")?),
|
||||||
"append" => Append(name_arg(arg, "append")?),
|
"append" => Append(name_arg(arg, "append")?),
|
||||||
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
|
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
|
||||||
|
|
@ -191,15 +175,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
||||||
Import(arg.to_string())
|
Import(arg.to_string())
|
||||||
}
|
}
|
||||||
"artist" | "ar" => Artist,
|
"artist" | "ar" => Artist,
|
||||||
"similar" | "radio" | "sim" => Similar,
|
|
||||||
"album" | "albums" | "al" => Album((!arg.is_empty()).then(|| arg.to_string())),
|
|
||||||
"playlist" => {
|
|
||||||
if arg.is_empty() {
|
|
||||||
return Err("usage: :playlist \"query\"".into());
|
|
||||||
}
|
|
||||||
PlaylistSearch(arg.to_string())
|
|
||||||
}
|
|
||||||
"lyrics" | "ly" => Lyrics,
|
|
||||||
"help" | "h" => Help,
|
"help" | "h" => Help,
|
||||||
other => return Err(format!("unknown command: :{other} (try :help)")),
|
other => return Err(format!("unknown command: :{other} (try :help)")),
|
||||||
};
|
};
|
||||||
|
|
@ -263,20 +238,6 @@ mod tests {
|
||||||
fn artist_command() {
|
fn artist_command() {
|
||||||
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
||||||
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
||||||
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
|
|
||||||
assert_eq!(parse("similar"), Ok(Some(Command::Similar)));
|
|
||||||
assert_eq!(parse(":radio"), Ok(Some(Command::Similar)));
|
|
||||||
assert_eq!(parse("album"), Ok(Some(Command::Album(None))));
|
|
||||||
assert_eq!(
|
|
||||||
parse("album daft punk"),
|
|
||||||
Ok(Some(Command::Album(Some("daft punk".into()))))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse("playlist lofi"),
|
|
||||||
Ok(Some(Command::PlaylistSearch("lofi".into())))
|
|
||||||
);
|
|
||||||
assert!(parse("playlist").is_err());
|
|
||||||
assert_eq!(parse("playlists"), Ok(Some(Command::ShowPlaylists)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -286,22 +247,5 @@ mod tests {
|
||||||
Ok(Some(Command::Save("my mix".into())))
|
Ok(Some(Command::Save("my mix".into())))
|
||||||
);
|
);
|
||||||
assert!(parse("save").is_err());
|
assert!(parse("save").is_err());
|
||||||
assert_eq!(
|
|
||||||
parse("save! my mix"),
|
|
||||||
Ok(Some(Command::SaveForce("my mix".into())))
|
|
||||||
);
|
|
||||||
assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None))));
|
|
||||||
assert_eq!(
|
|
||||||
parse(r#"savealbum "Abbey Road""#),
|
|
||||||
Ok(Some(Command::SaveAlbum(Some("Abbey Road".into()))))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse("saveresults"),
|
|
||||||
Ok(Some(Command::SaveResults(None)))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(r#"saveresults "My Hits""#),
|
|
||||||
Ok(Some(Command::SaveResults(Some("My Hits".into()))))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
265
src/config.rs
265
src/config.rs
|
|
@ -1,35 +1,13 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use ratatui::style::Color;
|
use serde::Deserialize;
|
||||||
use serde::de::{self, Visitor};
|
|
||||||
use serde::{Deserialize, Deserializer};
|
|
||||||
|
|
||||||
use crate::keys::{Keymap, Keys};
|
|
||||||
use crate::model::{RepeatMode, Track};
|
use crate::model::{RepeatMode, Track};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
use crate::store::{write_atomic, Store};
|
use crate::store::{write_atomic, Store};
|
||||||
|
|
||||||
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
struct ColorVisitor;
|
|
||||||
impl<'de> Visitor<'de> for ColorVisitor {
|
|
||||||
type Value = Color;
|
|
||||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
write!(f, "a color name (e.g. \"cyan\") or hex value (e.g. \"#FF8800\")")
|
|
||||||
}
|
|
||||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Color, E> {
|
|
||||||
Color::from_str(v).map_err(de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deserializer.deserialize_str(ColorVisitor)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Playback {
|
pub struct Playback {
|
||||||
|
|
@ -70,85 +48,10 @@ 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 {
|
||||||
|
|
@ -156,27 +59,7 @@ impl Config {
|
||||||
Ok(Store::dir()?.join("config.toml"))
|
Ok(Store::dir()?.join("config.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String {
|
pub fn render(p: &Playback) -> 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
|
||||||
|
|
@ -190,48 +73,6 @@ 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,
|
||||||
|
|
@ -240,12 +81,6 @@ cookies_from_browser = \"{cookies}\"
|
||||||
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(['\\', '"'], ""),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +95,7 @@ cookies_from_browser = \"{cookies}\"
|
||||||
let text = match fs::read_to_string(&path) {
|
let text = match fs::read_to_string(&path) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
|
let _ = Self::write(&Self::render(&Playback::default()));
|
||||||
return (Config::default(), None);
|
return (Config::default(), None);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -273,14 +108,7 @@ cookies_from_browser = \"{cookies}\"
|
||||||
match toml::from_str::<Config>(&text) {
|
match toml::from_str::<Config>(&text) {
|
||||||
Ok(mut c) => {
|
Ok(mut c) => {
|
||||||
c.playback.sanitize();
|
c.playback.sanitize();
|
||||||
let warning = match Keymap::build(&c.keys) {
|
(c, None)
|
||||||
Ok(keymap) => {
|
|
||||||
c.keymap = keymap;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")),
|
|
||||||
};
|
|
||||||
(c, warning)
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let bak = path.with_extension("toml.bak");
|
let bak = path.with_extension("toml.bak");
|
||||||
|
|
@ -346,7 +174,7 @@ pub fn migrate_legacy() {
|
||||||
let _ = Session::write(&s);
|
let _ = Session::write(&s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
|
let _ = Config::write(&Config::render(&p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -355,22 +183,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_file_parses_back() {
|
fn default_file_parses_back() {
|
||||||
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default());
|
let text = Config::render(&Playback::default());
|
||||||
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]
|
||||||
|
|
@ -398,76 +217,4 @@ mod tests {
|
||||||
p.sanitize();
|
p.sanitize();
|
||||||
assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0));
|
assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accent_color_parsing() {
|
|
||||||
// Default is cyan
|
|
||||||
let c: Config = toml::from_str("").unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Cyan);
|
|
||||||
|
|
||||||
// Named colors
|
|
||||||
let c: Config = toml::from_str("[ui]\naccent = \"red\"").unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Red);
|
|
||||||
let c: Config = toml::from_str("[ui]\naccent = \"green\"").unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Green);
|
|
||||||
let c: Config = toml::from_str("[ui]\naccent = \"magenta\"").unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Magenta);
|
|
||||||
|
|
||||||
// Hex value
|
|
||||||
let c: Config = toml::from_str("[ui]\naccent = \"#FF8800\"").unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Rgb(255, 136, 0));
|
|
||||||
|
|
||||||
// Invalid color is an error
|
|
||||||
assert!(toml::from_str::<Config>("[ui]\naccent = \"notacolor\"").is_err());
|
|
||||||
|
|
||||||
// Roundtrip: render then parse
|
|
||||||
let u = Ui { accent: Color::Blue };
|
|
||||||
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default());
|
|
||||||
let c: Config = toml::from_str(&text).unwrap();
|
|
||||||
assert_eq!(c.ui.accent, Color::Blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keys_section() {
|
|
||||||
let c: Config = toml::from_str("").unwrap();
|
|
||||||
assert!(c.keys.is_empty());
|
|
||||||
let c: Config =
|
|
||||||
toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap();
|
|
||||||
assert!(Keymap::build(&c.keys).is_ok());
|
|
||||||
assert_eq!(c.keys.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn audio_section() {
|
|
||||||
let c: Config = toml::from_str("").unwrap();
|
|
||||||
assert!(!c.audio.download);
|
|
||||||
assert_eq!(c.audio.cookies(), None);
|
|
||||||
let c: Config = toml::from_str(
|
|
||||||
"[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(c.audio.cookies(), Some("firefox"));
|
|
||||||
assert!(c.audio.should_download(Some(600)));
|
|
||||||
assert!(!c.audio.should_download(Some(601)));
|
|
||||||
assert!(!c.audio.should_download(None));
|
|
||||||
let mut a = c.audio.clone();
|
|
||||||
a.download_max_minutes = 0;
|
|
||||||
assert!(a.should_download(Some(10_000)));
|
|
||||||
a.download = false;
|
|
||||||
assert!(!a.should_download(Some(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn audio_roundtrip() {
|
|
||||||
let a = Audio {
|
|
||||||
download: true,
|
|
||||||
download_max_minutes: 30,
|
|
||||||
cookies_from_browser: "chrome".into(),
|
|
||||||
};
|
|
||||||
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a);
|
|
||||||
let c: Config = toml::from_str(&text).unwrap();
|
|
||||||
assert!(c.audio.download);
|
|
||||||
assert_eq!(c.audio.download_max_minutes, 30);
|
|
||||||
assert_eq!(c.audio.cookies(), Some("chrome"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
src/discord.rs
174
src/discord.rs
|
|
@ -1,174 +0,0 @@
|
||||||
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use discord_rich_presence::activity::{
|
|
||||||
Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps,
|
|
||||||
};
|
|
||||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
|
||||||
|
|
||||||
use crate::app::PlayState;
|
|
||||||
use crate::model::Track;
|
|
||||||
|
|
||||||
const RETRY: Duration = Duration::from_secs(5);
|
|
||||||
const DRIFT_TOLERANCE_MS: i64 = 2500;
|
|
||||||
|
|
||||||
enum Msg {
|
|
||||||
Show(Presence),
|
|
||||||
Clear,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Presence {
|
|
||||||
title: String,
|
|
||||||
artist: String,
|
|
||||||
album: Option<String>,
|
|
||||||
cover: String,
|
|
||||||
url: String,
|
|
||||||
paused: bool,
|
|
||||||
start: i64,
|
|
||||||
end: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Discord {
|
|
||||||
tx: Sender<Msg>,
|
|
||||||
track: Option<String>,
|
|
||||||
state: PlayState,
|
|
||||||
start: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Discord {
|
|
||||||
pub fn new(client_id: &str) -> Option<Discord> {
|
|
||||||
let id = client_id.trim();
|
|
||||||
if id.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = id.to_string();
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
thread::Builder::new()
|
|
||||||
.name("discord".into())
|
|
||||||
.spawn(move || run(&id, rx))
|
|
||||||
.ok()?;
|
|
||||||
Some(Discord {
|
|
||||||
tx,
|
|
||||||
track: None,
|
|
||||||
state: PlayState::Stopped,
|
|
||||||
start: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
|
|
||||||
let Some(track) = track.filter(|_| state != PlayState::Stopped) else {
|
|
||||||
if self.state != PlayState::Stopped {
|
|
||||||
self.state = PlayState::Stopped;
|
|
||||||
self.track = None;
|
|
||||||
let _ = self.tx.send(Msg::Clear);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let position = if position.is_finite() { position.max(0.0) } else { 0.0 };
|
|
||||||
let start = now_ms() - (position * 1000.0) as i64;
|
|
||||||
let paused = state == PlayState::Paused;
|
|
||||||
let same_track = self.track.as_deref() == Some(track.id.as_str());
|
|
||||||
let was_paused = self.state == PlayState::Paused;
|
|
||||||
let on_time = paused || (start - self.start).abs() < DRIFT_TOLERANCE_MS;
|
|
||||||
if same_track && paused == was_paused && self.state != PlayState::Stopped && on_time {
|
|
||||||
self.state = state;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.track = Some(track.id.clone());
|
|
||||||
self.state = state;
|
|
||||||
self.start = start;
|
|
||||||
let end = track
|
|
||||||
.duration
|
|
||||||
.map(|d| start + i64::from(d) * 1000)
|
|
||||||
.filter(|_| !paused);
|
|
||||||
let _ = self.tx.send(Msg::Show(Presence {
|
|
||||||
title: track.title.clone(),
|
|
||||||
artist: track.artist.clone(),
|
|
||||||
album: track.album.clone(),
|
|
||||||
cover: format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", track.id),
|
|
||||||
url: track.url(),
|
|
||||||
paused,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_ms() -> i64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as i64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(client_id: &str, rx: mpsc::Receiver<Msg>) {
|
|
||||||
let mut client: Option<DiscordIpcClient> = None;
|
|
||||||
let mut pending: Option<Msg> = None;
|
|
||||||
loop {
|
|
||||||
match rx.recv_timeout(RETRY) {
|
|
||||||
Ok(msg) => pending = Some(msg),
|
|
||||||
Err(RecvTimeoutError::Timeout) => {}
|
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
while let Ok(msg) = rx.try_recv() {
|
|
||||||
pending = Some(msg);
|
|
||||||
}
|
|
||||||
let Some(msg) = pending.take() else { continue };
|
|
||||||
|
|
||||||
if client.is_none() {
|
|
||||||
if matches!(msg, Msg::Clear) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut c = DiscordIpcClient::new(client_id);
|
|
||||||
if c.connect().is_ok() {
|
|
||||||
client = Some(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let sent = client.as_mut().is_some_and(|c| send(c, &msg));
|
|
||||||
if !sent {
|
|
||||||
client = None;
|
|
||||||
if matches!(msg, Msg::Show(_)) {
|
|
||||||
pending = Some(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(mut c) = client {
|
|
||||||
let _ = c.clear_activity();
|
|
||||||
let _ = c.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool {
|
|
||||||
match msg {
|
|
||||||
Msg::Clear => client.clear_activity().is_ok(),
|
|
||||||
Msg::Show(p) => {
|
|
||||||
let mut assets = Assets::new().large_image(&p.cover);
|
|
||||||
if let Some(album) = &p.album {
|
|
||||||
assets = assets.large_text(album);
|
|
||||||
}
|
|
||||||
let mut timestamps = Timestamps::new().start(p.start);
|
|
||||||
if let Some(end) = p.end {
|
|
||||||
timestamps = timestamps.end(end);
|
|
||||||
}
|
|
||||||
let artist = if p.paused {
|
|
||||||
format!("{} (paused)", p.artist)
|
|
||||||
} else {
|
|
||||||
p.artist.clone()
|
|
||||||
};
|
|
||||||
let mut activity = Activity::new()
|
|
||||||
.activity_type(ActivityType::Listening)
|
|
||||||
.status_display_type(StatusDisplayType::Details)
|
|
||||||
.details(&p.title)
|
|
||||||
.state(&artist)
|
|
||||||
.assets(assets)
|
|
||||||
.buttons(vec![Button::new("Listen Along", &p.url)]);
|
|
||||||
if !p.paused {
|
|
||||||
activity = activity.timestamps(timestamps);
|
|
||||||
}
|
|
||||||
client.set_activity(activity).is_ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
353
src/keys.rs
353
src/keys.rs
|
|
@ -1,353 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Action {
|
|
||||||
MoveDown,
|
|
||||||
MoveUp,
|
|
||||||
HalfPageDown,
|
|
||||||
HalfPageUp,
|
|
||||||
PageDown,
|
|
||||||
PageUp,
|
|
||||||
GoTop,
|
|
||||||
GoBottom,
|
|
||||||
Menu,
|
|
||||||
Delete,
|
|
||||||
NextView,
|
|
||||||
PrevView,
|
|
||||||
ViewResults,
|
|
||||||
ViewQueue,
|
|
||||||
ViewPlaylists,
|
|
||||||
Activate,
|
|
||||||
Pause,
|
|
||||||
Next,
|
|
||||||
Prev,
|
|
||||||
SeekBack,
|
|
||||||
SeekForward,
|
|
||||||
VolumeUp,
|
|
||||||
VolumeDown,
|
|
||||||
Add,
|
|
||||||
AddAll,
|
|
||||||
PlayAll,
|
|
||||||
MoveItemDown,
|
|
||||||
MoveItemUp,
|
|
||||||
Shuffle,
|
|
||||||
Repeat,
|
|
||||||
Search,
|
|
||||||
Command,
|
|
||||||
LyricsPanel,
|
|
||||||
Lyrics,
|
|
||||||
GoArtist,
|
|
||||||
GoSimilar,
|
|
||||||
GoAlbum,
|
|
||||||
SaveAlbum,
|
|
||||||
Help,
|
|
||||||
Quit,
|
|
||||||
Back,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS: &[(&str, Action, &[&str])] = &[
|
|
||||||
("move_down", Action::MoveDown, &["j", "down"]),
|
|
||||||
("move_up", Action::MoveUp, &["k", "up"]),
|
|
||||||
("half_page_down", Action::HalfPageDown, &["ctrl+d"]),
|
|
||||||
("half_page_up", Action::HalfPageUp, &["ctrl+u"]),
|
|
||||||
("page_down", Action::PageDown, &["ctrl+f", "pagedown"]),
|
|
||||||
("page_up", Action::PageUp, &["ctrl+b", "pageup"]),
|
|
||||||
("go_top", Action::GoTop, &["gg", "home"]),
|
|
||||||
("go_bottom", Action::GoBottom, &["ge", "end"]),
|
|
||||||
("menu", Action::Menu, &["G", "menu"]),
|
|
||||||
("delete", Action::Delete, &["x", "delete", "dd"]),
|
|
||||||
("next_view", Action::NextView, &["tab"]),
|
|
||||||
("prev_view", Action::PrevView, &["backtab"]),
|
|
||||||
("view_results", Action::ViewResults, &["gr"]),
|
|
||||||
("view_queue", Action::ViewQueue, &["gq"]),
|
|
||||||
("view_playlists", Action::ViewPlaylists, &["gp"]),
|
|
||||||
("activate", Action::Activate, &["enter"]),
|
|
||||||
("pause", Action::Pause, &["space"]),
|
|
||||||
("next", Action::Next, &["n", ">"]),
|
|
||||||
("prev", Action::Prev, &["N", "<"]),
|
|
||||||
("seek_back", Action::SeekBack, &["h", "left"]),
|
|
||||||
("seek_forward", Action::SeekForward, &["l", "right"]),
|
|
||||||
("volume_up", Action::VolumeUp, &["+", "="]),
|
|
||||||
("volume_down", Action::VolumeDown, &["-", "_"]),
|
|
||||||
("add", Action::Add, &["a"]),
|
|
||||||
("add_all", Action::AddAll, &["A"]),
|
|
||||||
("play_all", Action::PlayAll, &["P"]),
|
|
||||||
("move_item_down", Action::MoveItemDown, &["J"]),
|
|
||||||
("move_item_up", Action::MoveItemUp, &["K"]),
|
|
||||||
("shuffle", Action::Shuffle, &["s"]),
|
|
||||||
("repeat", Action::Repeat, &["r"]),
|
|
||||||
("search", Action::Search, &["/"]),
|
|
||||||
("command", Action::Command, &[":"]),
|
|
||||||
("lyrics_panel", Action::LyricsPanel, &["L"]),
|
|
||||||
("lyrics", Action::Lyrics, &["F"]),
|
|
||||||
("go_artist", Action::GoArtist, &["ga"]),
|
|
||||||
("go_similar", Action::GoSimilar, &["gs"]),
|
|
||||||
("go_album", Action::GoAlbum, &["gb"]),
|
|
||||||
("save_album", Action::SaveAlbum, &["gB"]),
|
|
||||||
("help", Action::Help, &["?"]),
|
|
||||||
("quit", Action::Quit, &["q"]),
|
|
||||||
("back", Action::Back, &["backspace", "esc"]),
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct Key {
|
|
||||||
code: KeyCode,
|
|
||||||
ctrl: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Key {
|
|
||||||
pub fn digit(&self) -> Option<usize> {
|
|
||||||
match self.code {
|
|
||||||
KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KeyEvent> for Key {
|
|
||||||
fn from(e: KeyEvent) -> Key {
|
|
||||||
Key {
|
|
||||||
code: e.code,
|
|
||||||
ctrl: e.modifiers.contains(KeyModifiers::CONTROL)
|
|
||||||
&& !e.modifiers.contains(KeyModifiers::ALT),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Key {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
if self.ctrl {
|
|
||||||
f.write_str("^")?;
|
|
||||||
}
|
|
||||||
match self.code {
|
|
||||||
KeyCode::Char(c) => write!(f, "{c}"),
|
|
||||||
other => write!(f, "{other}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum Keys {
|
|
||||||
One(String),
|
|
||||||
Many(Vec<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Keys {
|
|
||||||
fn as_slice(&self) -> &[String] {
|
|
||||||
match self {
|
|
||||||
Keys::One(s) => std::slice::from_ref(s),
|
|
||||||
Keys::Many(v) => v,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Lookup {
|
|
||||||
Run(Action),
|
|
||||||
Pending,
|
|
||||||
Nothing,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Keymap(Vec<(Vec<Key>, Action)>);
|
|
||||||
|
|
||||||
impl Default for Keymap {
|
|
||||||
fn default() -> Keymap {
|
|
||||||
Keymap::build(&HashMap::new()).expect("default keys are valid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Keymap {
|
|
||||||
pub fn build(custom: &HashMap<String, Keys>) -> Result<Keymap, String> {
|
|
||||||
if let Some(name) = custom
|
|
||||||
.keys()
|
|
||||||
.find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str()))
|
|
||||||
{
|
|
||||||
return Err(format!("unknown action \"{name}\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut map: Vec<(Vec<Key>, Action)> = Vec::new();
|
|
||||||
for (name, action, _) in DEFAULTS {
|
|
||||||
let Some(keys) = custom.get(*name) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for text in keys.as_slice() {
|
|
||||||
let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?;
|
|
||||||
if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) {
|
|
||||||
if other != action {
|
|
||||||
return Err(format!("\"{text}\" is bound to two actions"));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
map.push((seq, *action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (name, action, keys) in DEFAULTS {
|
|
||||||
if custom.contains_key(*name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for text in *keys {
|
|
||||||
let seq = parse_sequence(text).expect("default keys are valid");
|
|
||||||
if !map.iter().any(|(s, _)| *s == seq) {
|
|
||||||
map.push((seq, *action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Keymap(map))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn lookup(&self, seq: &[Key]) -> Lookup {
|
|
||||||
if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) {
|
|
||||||
Lookup::Run(*action)
|
|
||||||
} else if self.0.iter().any(|(s, _)| s.starts_with(seq)) {
|
|
||||||
Lookup::Pending
|
|
||||||
} else {
|
|
||||||
Lookup::Nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_sequence(text: &str) -> Result<Vec<Key>, String> {
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
for token in text.split_whitespace() {
|
|
||||||
match parse_key(token) {
|
|
||||||
Some(key) => keys.push(key),
|
|
||||||
None if !token.contains('+') => keys.extend(token.chars().map(|c| Key {
|
|
||||||
code: KeyCode::Char(c),
|
|
||||||
ctrl: false,
|
|
||||||
})),
|
|
||||||
None => return Err(format!("unknown key \"{token}\"")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if keys.is_empty() {
|
|
||||||
return Err("empty key".into());
|
|
||||||
}
|
|
||||||
Ok(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_key(token: &str) -> Option<Key> {
|
|
||||||
let (ctrl, name) = match token.get(..5) {
|
|
||||||
Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]),
|
|
||||||
_ => (false, token),
|
|
||||||
};
|
|
||||||
let mut chars = name.chars();
|
|
||||||
let code = match (chars.next()?, chars.next()) {
|
|
||||||
(c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()),
|
|
||||||
(c, None) => KeyCode::Char(c),
|
|
||||||
_ => match name.to_ascii_lowercase().as_str() {
|
|
||||||
"space" => KeyCode::Char(' '),
|
|
||||||
"enter" | "return" => KeyCode::Enter,
|
|
||||||
"esc" | "escape" => KeyCode::Esc,
|
|
||||||
"tab" => KeyCode::Tab,
|
|
||||||
"backtab" => KeyCode::BackTab,
|
|
||||||
"backspace" => KeyCode::Backspace,
|
|
||||||
"delete" | "del" => KeyCode::Delete,
|
|
||||||
"insert" => KeyCode::Insert,
|
|
||||||
"home" => KeyCode::Home,
|
|
||||||
"end" => KeyCode::End,
|
|
||||||
"pageup" => KeyCode::PageUp,
|
|
||||||
"pagedown" => KeyCode::PageDown,
|
|
||||||
"up" => KeyCode::Up,
|
|
||||||
"down" => KeyCode::Down,
|
|
||||||
"left" => KeyCode::Left,
|
|
||||||
"right" => KeyCode::Right,
|
|
||||||
"menu" => KeyCode::Menu,
|
|
||||||
f => match f.strip_prefix('f')?.parse::<u8>() {
|
|
||||||
Ok(n @ 1..=12) => KeyCode::F(n),
|
|
||||||
_ => return None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some(Key { code, ctrl })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn custom(entries: &[(&str, &[&str])]) -> HashMap<String, Keys> {
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.map(|(name, keys)| {
|
|
||||||
let keys = keys.iter().map(|k| k.to_string()).collect();
|
|
||||||
(name.to_string(), Keys::Many(keys))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seq(text: &str) -> Vec<Key> {
|
|
||||||
parse_sequence(text).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(map: &Keymap, text: &str) -> Option<Action> {
|
|
||||||
match map.lookup(&seq(text)) {
|
|
||||||
Lookup::Run(a) => Some(a),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_vim() {
|
|
||||||
let map = Keymap::default();
|
|
||||||
assert_eq!(run(&map, "j"), Some(Action::MoveDown));
|
|
||||||
assert_eq!(run(&map, "down"), Some(Action::MoveDown));
|
|
||||||
assert_eq!(run(&map, "gg"), Some(Action::GoTop));
|
|
||||||
assert_eq!(run(&map, "dd"), Some(Action::Delete));
|
|
||||||
assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown));
|
|
||||||
assert_eq!(run(&map, "G"), Some(Action::Menu));
|
|
||||||
assert_eq!(run(&map, "space"), Some(Action::Pause));
|
|
||||||
assert!(matches!(map.lookup(&seq("g")), Lookup::Pending));
|
|
||||||
assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parsing() {
|
|
||||||
assert_eq!(seq("ctrl+D"), seq("ctrl+d"));
|
|
||||||
assert_eq!(seq("gg"), seq("g g"));
|
|
||||||
assert_eq!(seq("+").len(), 1);
|
|
||||||
assert_eq!(seq("ctrl+x g").len(), 2);
|
|
||||||
assert_eq!(seq("f5")[0].code, KeyCode::F(5));
|
|
||||||
assert!(parse_sequence("").is_err());
|
|
||||||
assert!(parse_sequence("ctrl+nope").is_err());
|
|
||||||
assert!(parse_key("f13").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_replaces_default_keys() {
|
|
||||||
let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap();
|
|
||||||
assert_eq!(run(&map, "Q"), Some(Action::Quit));
|
|
||||||
assert_eq!(run(&map, "q"), None);
|
|
||||||
assert_eq!(run(&map, "e"), Some(Action::MoveDown));
|
|
||||||
assert_eq!(run(&map, "j"), None);
|
|
||||||
assert_eq!(run(&map, "k"), Some(Action::MoveUp));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_wins_over_other_defaults() {
|
|
||||||
let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap();
|
|
||||||
assert_eq!(run(&map, "x"), Some(Action::Quit));
|
|
||||||
assert_eq!(run(&map, "dd"), Some(Action::Delete));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_string_and_disable() {
|
|
||||||
let toml = "quit = \"Q\"\nshuffle = []";
|
|
||||||
let keys: HashMap<String, Keys> = toml::from_str(toml).unwrap();
|
|
||||||
let map = Keymap::build(&keys).unwrap();
|
|
||||||
assert_eq!(run(&map, "Q"), Some(Action::Quit));
|
|
||||||
assert_eq!(run(&map, "s"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bad_config_is_rejected() {
|
|
||||||
assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err());
|
|
||||||
assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err());
|
|
||||||
assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err());
|
|
||||||
assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
201
src/lyrics.rs
201
src/lyrics.rs
|
|
@ -1,201 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::model::{parse_clock, Track};
|
|
||||||
|
|
||||||
const SEARCH: &str = "https://lrclib.net/api/search";
|
|
||||||
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
|
|
||||||
const LEAD: f64 = 0.25;
|
|
||||||
const MAX_DRIFT: f64 = 8.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Line {
|
|
||||||
pub at: f64,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Lyrics {
|
|
||||||
pub lines: Vec<Line>,
|
|
||||||
pub synced: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Lyrics {
|
|
||||||
pub fn active(&self, position: f64) -> Option<usize> {
|
|
||||||
if !self.synced {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let n = self.lines.partition_point(|l| l.at <= position + LEAD);
|
|
||||||
n.checked_sub(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self, i: usize, position: f64) -> f64 {
|
|
||||||
let Some(line) = self.lines.get(i) else {
|
|
||||||
return 0.0;
|
|
||||||
};
|
|
||||||
let sing = line.text.chars().count() as f64 * 0.1 + 1.0;
|
|
||||||
let span = match self.lines.get(i + 1) {
|
|
||||||
Some(next) => (next.at - line.at).min(sing),
|
|
||||||
None => sing,
|
|
||||||
};
|
|
||||||
((position - line.at) / span.max(0.1)).clamp(0.0, 1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_lrc(text: &str) -> Vec<Line> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for raw in text.lines() {
|
|
||||||
let mut rest = raw.trim();
|
|
||||||
let mut stamps = Vec::new();
|
|
||||||
while let Some(tail) = rest.strip_prefix('[') {
|
|
||||||
let Some((tag, after)) = tail.split_once(']') else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let Some(t) = parse_clock(tag) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
stamps.push(t);
|
|
||||||
rest = after;
|
|
||||||
}
|
|
||||||
let text = rest.trim();
|
|
||||||
for at in stamps {
|
|
||||||
out.push(Line {
|
|
||||||
at,
|
|
||||||
text: text.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.at.total_cmp(&b.at));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_entry(entry: &Value) -> Option<Lyrics> {
|
|
||||||
let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty());
|
|
||||||
if let Some(s) = text("syncedLyrics") {
|
|
||||||
let lines = parse_lrc(s);
|
|
||||||
if !lines.is_empty() {
|
|
||||||
return Some(Lyrics { lines, synced: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let plain = text("plainLyrics")?;
|
|
||||||
let lines = plain
|
|
||||||
.lines()
|
|
||||||
.map(|l| Line {
|
|
||||||
at: 0.0,
|
|
||||||
text: l.trim().to_string(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Some(Lyrics {
|
|
||||||
lines,
|
|
||||||
synced: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
|
|
||||||
let drift = |e: &Value| match (duration, e["duration"].as_f64()) {
|
|
||||||
(Some(want), Some(got)) => (got - f64::from(want)).abs(),
|
|
||||||
_ => 0.0,
|
|
||||||
};
|
|
||||||
let mut found: Vec<(bool, f64, Lyrics)> = entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| drift(e) <= MAX_DRIFT)
|
|
||||||
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
|
|
||||||
.collect();
|
|
||||||
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
|
||||||
found.into_iter().next().map(|(_, _, l)| l)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
|
|
||||||
let agent = ureq::AgentBuilder::new()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.user_agent(UA)
|
|
||||||
.build();
|
|
||||||
let mut req = agent.get(SEARCH);
|
|
||||||
for (k, v) in params {
|
|
||||||
req = req.query(k, v);
|
|
||||||
}
|
|
||||||
req.call()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.into_json::<Vec<Value>>()
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
|
|
||||||
let artist = track
|
|
||||||
.artist_names()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?;
|
|
||||||
if let Some(l) = pick(&exact, track.duration) {
|
|
||||||
return Ok(Some(l));
|
|
||||||
}
|
|
||||||
let loose = query(&[("q", &format!("{} {}", track.title, artist))])?;
|
|
||||||
Ok(pick(&loose, track.duration))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_timestamps_and_skips_metadata() {
|
|
||||||
let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]");
|
|
||||||
let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect();
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
[
|
|
||||||
(12.5, "first"),
|
|
||||||
(30.25, "shared"),
|
|
||||||
(45.0, ""),
|
|
||||||
(62.0, "shared")
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finds_active_line() {
|
|
||||||
let l = Lyrics {
|
|
||||||
lines: parse_lrc("[00:10.00]a\n[00:20.00]b"),
|
|
||||||
synced: true,
|
|
||||||
};
|
|
||||||
assert_eq!(l.active(5.0), None);
|
|
||||||
assert_eq!(l.active(10.0), Some(0));
|
|
||||||
assert_eq!(l.active(19.9), Some(1));
|
|
||||||
assert_eq!(l.active(99.0), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_stays_in_range() {
|
|
||||||
let l = Lyrics {
|
|
||||||
lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"),
|
|
||||||
synced: true,
|
|
||||||
};
|
|
||||||
assert_eq!(l.progress(0, 9.0), 0.0);
|
|
||||||
assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6);
|
|
||||||
assert_eq!(l.progress(0, 30.0), 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefers_synced_and_close_duration() {
|
|
||||||
let entries = [
|
|
||||||
json!({"duration": 200.0, "plainLyrics": "plain"}),
|
|
||||||
json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}),
|
|
||||||
json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}),
|
|
||||||
json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}),
|
|
||||||
];
|
|
||||||
let l = pick(&entries, Some(200)).unwrap();
|
|
||||||
assert!(l.synced);
|
|
||||||
assert_eq!(l.lines[0].text, "nearest");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_plain_lyrics() {
|
|
||||||
let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})];
|
|
||||||
let l = pick(&entries, Some(200)).unwrap();
|
|
||||||
assert!(!l.synced);
|
|
||||||
assert_eq!(l.lines.len(), 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
src/main.rs
24
src/main.rs
|
|
@ -1,15 +1,10 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -35,9 +30,12 @@ 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 mut cmd = Command::new(name);
|
let out = Command::new(name)
|
||||||
cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null());
|
.arg("--version")
|
||||||
let out = sys::no_window(&mut cmd).output().ok()?;
|
.stdin(Stdio::null())
|
||||||
|
.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()
|
||||||
|
|
@ -53,11 +51,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 | winget install mpv.net / scoop install mpv",
|
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"yt-dlp",
|
"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",
|
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
match tool_version(tool) {
|
match tool_version(tool) {
|
||||||
|
|
@ -131,12 +129,6 @@ fn run(initial_query: Option<String>) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut terminal = ratatui::try_init()?;
|
let mut terminal = ratatui::try_init()?;
|
||||||
let hook = std::panic::take_hook();
|
|
||||||
std::panic::set_hook(Box::new(move |info| {
|
|
||||||
if std::thread::current().name() == Some("main") {
|
|
||||||
hook(info);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
let result = event_loop(&mut terminal, &mut app, &rx);
|
let result = event_loop(&mut terminal, &mut app, &rx);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
app.shutdown();
|
app.shutdown();
|
||||||
|
|
|
||||||
133
src/model.rs
133
src/model.rs
|
|
@ -5,9 +5,6 @@ pub struct Track {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
/* individual credited artists when the source gave them to us */
|
|
||||||
#[serde(default)]
|
|
||||||
pub artists: Vec<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -19,98 +16,26 @@ impl Track {
|
||||||
format!("https://music.youtube.com/watch?v={}", self.id)
|
format!("https://music.youtube.com/watch?v={}", self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Individual artists: the structured list if we have one, else a guess
|
|
||||||
* made by splitting the display string (old saves, plain yt-dlp). */
|
|
||||||
pub fn artist_names(&self) -> Vec<String> {
|
|
||||||
if self.artists.is_empty() {
|
|
||||||
split_artists(&self.artist)
|
|
||||||
} else {
|
|
||||||
self.artists.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(&self) -> String {
|
pub fn label(&self) -> String {
|
||||||
format!("{} — {}", self.title, self.artist)
|
format!("{} — {}", self.title, self.artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub 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();
|
||||||
let mut start = 0;
|
for part in s.split(MARK) {
|
||||||
let mut i = 0;
|
let name = part.trim();
|
||||||
while i < credit.len() {
|
if !name.is_empty() && !out.iter().any(|o| o == name) {
|
||||||
let rest = &credit[i..];
|
out.push(name.to_string());
|
||||||
let at_piece_start = credit[start..i].trim().is_empty();
|
|
||||||
if at_piece_start {
|
|
||||||
if let Some(name) = WHOLE_NAMES.iter().find(|n| starts_with_ci(rest, n)) {
|
|
||||||
i += name.len();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(sep) = SEPARATORS.iter().find(|sep| starts_with_ci(rest, sep)) {
|
|
||||||
push_name(&mut out, &credit[start..i]);
|
|
||||||
i += sep.len();
|
|
||||||
start = i;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
i += rest.chars().next().map_or(1, char::len_utf8);
|
|
||||||
}
|
}
|
||||||
push_name(&mut out, &credit[start..]);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,45 +125,9 @@ mod tests {
|
||||||
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
|
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
|
||||||
assert_eq!(split_artists("A feat. B"), ["A", "B"]);
|
assert_eq!(split_artists("A feat. B"), ["A", "B"]);
|
||||||
assert_eq!(split_artists("A, A"), ["A"]);
|
assert_eq!(split_artists("A, A"), ["A"]);
|
||||||
assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]);
|
|
||||||
assert_eq!(
|
|
||||||
split_artists("Tyler, The Creator & Kali Uchis"),
|
|
||||||
["Tyler, The Creator", "Kali Uchis"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
split_artists("Earth, Wind & Fire, Someone"),
|
|
||||||
["Earth, Wind & Fire", "Someone"]
|
|
||||||
);
|
|
||||||
assert!(split_artists(" ").is_empty());
|
assert!(split_artists(" ").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_splitting_edge_cases() {
|
|
||||||
assert_eq!(split_artists("hall & oates"), ["hall & oates"]);
|
|
||||||
assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]);
|
|
||||||
assert_eq!(split_artists("A Feat. B"), ["A", "B"]);
|
|
||||||
assert_eq!(
|
|
||||||
split_artists("Drake, Hall & Oates"),
|
|
||||||
["Drake", "Hall & Oates"]
|
|
||||||
);
|
|
||||||
assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_names_prefers_structured_list() {
|
|
||||||
let mut t = Track {
|
|
||||||
id: "abcdefghijk".into(),
|
|
||||||
title: "T".into(),
|
|
||||||
artist: "Blood, Sweat & Tears".into(),
|
|
||||||
artists: vec!["Blood, Sweat & Tears".into()],
|
|
||||||
album: None,
|
|
||||||
duration: None,
|
|
||||||
};
|
|
||||||
assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]);
|
|
||||||
t.artists.clear();
|
|
||||||
assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ids() {
|
fn ids() {
|
||||||
assert!(is_valid_id("dQw4w9WgXcQ"));
|
assert!(is_valid_id("dQw4w9WgXcQ"));
|
||||||
|
|
|
||||||
162
src/mpris.rs
162
src/mpris.rs
|
|
@ -1,162 +0,0 @@
|
||||||
/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum Cmd {
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Toggle,
|
|
||||||
Stop,
|
|
||||||
Next,
|
|
||||||
Prev,
|
|
||||||
SeekBy(f64),
|
|
||||||
SeekTo(f64),
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod imp {
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use souvlaki::{
|
|
||||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
|
|
||||||
PlatformConfig, SeekDirection,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::Cmd;
|
|
||||||
use crate::app::PlayState;
|
|
||||||
use crate::model::Track;
|
|
||||||
|
|
||||||
pub struct Mpris {
|
|
||||||
controls: MediaControls,
|
|
||||||
track: Option<String>,
|
|
||||||
state: PlayState,
|
|
||||||
secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Mpris {
|
|
||||||
/* None when there is no session bus. */
|
|
||||||
pub fn new(send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
|
|
||||||
let name = format!("drift.instance{}", std::process::id());
|
|
||||||
let config = PlatformConfig {
|
|
||||||
dbus_name: &name,
|
|
||||||
display_name: "drift",
|
|
||||||
hwnd: None,
|
|
||||||
};
|
|
||||||
let mut controls = MediaControls::new(config).ok()?;
|
|
||||||
controls
|
|
||||||
.attach(move |ev| {
|
|
||||||
if let Some(c) = convert(ev) {
|
|
||||||
send(c);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok()?;
|
|
||||||
Some(Mpris {
|
|
||||||
controls,
|
|
||||||
track: None,
|
|
||||||
state: PlayState::Stopped,
|
|
||||||
secs: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
|
|
||||||
let id = track.map(|t| t.id.as_str());
|
|
||||||
if id != self.track.as_deref() {
|
|
||||||
self.track = id.map(str::to_string);
|
|
||||||
let cover = track.map(|t| format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", t.id));
|
|
||||||
let meta = match track {
|
|
||||||
Some(t) => MediaMetadata {
|
|
||||||
title: Some(t.title.as_str()),
|
|
||||||
album: t.album.as_deref(),
|
|
||||||
artist: Some(t.artist.as_str()),
|
|
||||||
cover_url: cover.as_deref(),
|
|
||||||
duration: t.duration.map(|d| Duration::from_secs(u64::from(d))),
|
|
||||||
},
|
|
||||||
None => MediaMetadata::default(),
|
|
||||||
};
|
|
||||||
let _ = self.controls.set_metadata(meta);
|
|
||||||
self.secs = u64::MAX;
|
|
||||||
}
|
|
||||||
let pos = if position.is_finite() { position.max(0.0) } else { 0.0 };
|
|
||||||
let secs = pos as u64;
|
|
||||||
if state == self.state && secs == self.secs {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.state = state;
|
|
||||||
self.secs = secs;
|
|
||||||
let progress = Some(MediaPosition(Duration::from_secs_f64(pos)));
|
|
||||||
let _ = self.controls.set_playback(match state {
|
|
||||||
PlayState::Playing | PlayState::Loading => MediaPlayback::Playing { progress },
|
|
||||||
PlayState::Paused => MediaPlayback::Paused { progress },
|
|
||||||
PlayState::Stopped => MediaPlayback::Stopped,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dir(d: SeekDirection, secs: f64) -> f64 {
|
|
||||||
if d == SeekDirection::Backward {
|
|
||||||
-secs
|
|
||||||
} else {
|
|
||||||
secs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn convert(ev: MediaControlEvent) -> Option<Cmd> {
|
|
||||||
Some(match ev {
|
|
||||||
MediaControlEvent::Play => Cmd::Play,
|
|
||||||
MediaControlEvent::Pause => Cmd::Pause,
|
|
||||||
MediaControlEvent::Toggle => Cmd::Toggle,
|
|
||||||
MediaControlEvent::Next => Cmd::Next,
|
|
||||||
MediaControlEvent::Previous => Cmd::Prev,
|
|
||||||
MediaControlEvent::Stop => Cmd::Stop,
|
|
||||||
MediaControlEvent::Seek(d) => Cmd::SeekBy(dir(d, 5.0)),
|
|
||||||
MediaControlEvent::SeekBy(d, by) => Cmd::SeekBy(dir(d, by.as_secs_f64())),
|
|
||||||
MediaControlEvent::SetPosition(MediaPosition(p)) => Cmd::SeekTo(p.as_secs_f64()),
|
|
||||||
MediaControlEvent::Quit => Cmd::Quit,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_events() {
|
|
||||||
assert_eq!(convert(MediaControlEvent::Previous), Some(Cmd::Prev));
|
|
||||||
assert_eq!(
|
|
||||||
convert(MediaControlEvent::SeekBy(
|
|
||||||
SeekDirection::Backward,
|
|
||||||
Duration::from_secs(10)
|
|
||||||
)),
|
|
||||||
Some(Cmd::SeekBy(-10.0))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
convert(MediaControlEvent::SetPosition(MediaPosition(
|
|
||||||
Duration::from_secs(90)
|
|
||||||
))),
|
|
||||||
Some(Cmd::SeekTo(90.0))
|
|
||||||
);
|
|
||||||
assert_eq!(convert(MediaControlEvent::Raise), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
mod imp {
|
|
||||||
use super::Cmd;
|
|
||||||
use crate::app::PlayState;
|
|
||||||
use crate::model::Track;
|
|
||||||
|
|
||||||
pub struct Mpris;
|
|
||||||
|
|
||||||
impl Mpris {
|
|
||||||
pub fn new(_send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, _track: Option<&Track>, _state: PlayState, _position: f64) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use imp::Mpris;
|
|
||||||
294
src/player.rs
294
src/player.rs
|
|
@ -1,116 +1,12 @@
|
||||||
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, Result};
|
use anyhow::{anyhow, bail, Context, 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>),
|
||||||
|
|
@ -125,13 +21,15 @@ pub enum PlayerEvent {
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
child: Child,
|
child: Child,
|
||||||
writer: ipc::Writer,
|
writer: UnixStream,
|
||||||
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_path = ipc::endpoint();
|
let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
|
||||||
|
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([
|
||||||
|
|
@ -142,7 +40,8 @@ impl Player {
|
||||||
"--really-quiet",
|
"--really-quiet",
|
||||||
"--audio-display=no",
|
"--audio-display=no",
|
||||||
"--force-window=no",
|
"--force-window=no",
|
||||||
"--ytdl=no",
|
"--ytdl=yes",
|
||||||
|
"--ytdl-format=bestaudio/best",
|
||||||
"--cache=yes",
|
"--cache=yes",
|
||||||
"--demuxer-max-bytes=32MiB",
|
"--demuxer-max-bytes=32MiB",
|
||||||
])
|
])
|
||||||
|
|
@ -157,9 +56,7 @@ 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;
|
||||||
|
|
@ -177,12 +74,9 @@ impl Player {
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Windows: tie mpv's lifetime to ours.
|
|
||||||
crate::sys::kill_on_exit(&child);
|
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let (reader, writer) = loop {
|
let stream = loop {
|
||||||
match ipc::connect(&sock_path) {
|
match UnixStream::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() {
|
||||||
|
|
@ -198,9 +92,10 @@ impl Player {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let reader = stream.try_clone().context("cloning mpv socket")?;
|
||||||
let mut player = Player {
|
let mut player = Player {
|
||||||
child,
|
child,
|
||||||
writer,
|
writer: stream,
|
||||||
sock_path,
|
sock_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -212,44 +107,16 @@ impl Player {
|
||||||
] {
|
] {
|
||||||
player.send(json!(["observe_property", id, prop]))?;
|
player.send(json!(["observe_property", id, prop]))?;
|
||||||
}
|
}
|
||||||
player.send(json!(["request_log_messages", "error"]))?;
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut reason: Option<String> = None;
|
|
||||||
for line in BufReader::new(reader).lines() {
|
for line in BufReader::new(reader).lines() {
|
||||||
let Ok(line) = line else { break };
|
let Ok(line) = line else { break };
|
||||||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match v.get("event").and_then(Value::as_str) {
|
if let Some(ev) = parse_event(&v) {
|
||||||
Some("log-message") => {
|
on_event(ev);
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
@ -270,9 +137,9 @@ impl Player {
|
||||||
matches!(self.child.try_wait(), Ok(None))
|
matches!(self.child.try_wait(), Ok(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self, path: &str) -> Result<()> {
|
pub fn load(&mut self, url: &str) -> Result<()> {
|
||||||
self.send(json!(["set_property", "pause", false]))?;
|
self.send(json!(["set_property", "pause", false]))?;
|
||||||
self.send(json!(["loadfile", path, "replace"]))
|
self.send(json!(["loadfile", url, "replace"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_pause(&mut self, pause: bool) -> Result<()> {
|
pub fn set_pause(&mut self, pause: bool) -> Result<()> {
|
||||||
|
|
@ -307,124 +174,10 @@ impl Drop for Player {
|
||||||
}
|
}
|
||||||
let _ = self.child.kill();
|
let _ = self.child.kill();
|
||||||
let _ = self.child.wait();
|
let _ = self.child.wait();
|
||||||
ipc::cleanup(&self.sock_path);
|
let _ = std::fs::remove_file(&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" => {
|
||||||
|
|
@ -502,19 +255,6 @@ mod tests {
|
||||||
assert!(eof, "end-of-file not seen");
|
assert!(eof, "end-of-file not seen");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ytdl_reason_is_trimmed() {
|
|
||||||
assert_eq!(
|
|
||||||
ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"),
|
|
||||||
"Sign in to confirm you're not a bot. Use --cookies."
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"),
|
|
||||||
"Failed to extract any player response"
|
|
||||||
);
|
|
||||||
assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_parsing() {
|
fn event_parsing() {
|
||||||
let v: Value = serde_json::from_str(
|
let v: Value = serde_json::from_str(
|
||||||
|
|
|
||||||
|
|
@ -17,42 +17,12 @@ pub struct Session {
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
fn path() -> Result<PathBuf> {
|
fn path() -> Result<PathBuf> {
|
||||||
let dir = crate::store::state_base()
|
let dir = dirs::state_dir()
|
||||||
|
.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())
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +76,6 @@ mod tests {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
title: "T".into(),
|
title: "T".into(),
|
||||||
artist: "A".into(),
|
artist: "A".into(),
|
||||||
artists: Vec::new(),
|
|
||||||
album: None,
|
album: None,
|
||||||
duration: Some(200),
|
duration: Some(200),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/store.rs
109
src/store.rs
|
|
@ -10,32 +10,6 @@ use crate::model::Track;
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
pub playlists: BTreeMap<String, Vec<Track>>,
|
pub playlists: BTreeMap<String, Vec<Track>>,
|
||||||
/* what the file looked like when we last read or wrote it */
|
|
||||||
#[serde(skip)]
|
|
||||||
base: BTreeMap<String, Vec<Track>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* keep whatever another drift wrote, but apply the playlists this one changed */
|
|
||||||
fn merge(
|
|
||||||
base: &BTreeMap<String, Vec<Track>>,
|
|
||||||
mine: &BTreeMap<String, Vec<Track>>,
|
|
||||||
disk: BTreeMap<String, Vec<Track>>,
|
|
||||||
) -> BTreeMap<String, Vec<Track>> {
|
|
||||||
let mut out = disk;
|
|
||||||
for name in base.keys().chain(mine.keys()) {
|
|
||||||
if base.get(name) == mine.get(name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match mine.get(name) {
|
|
||||||
Some(list) => {
|
|
||||||
out.insert(name.clone(), list.clone());
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
out.remove(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
|
pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
|
||||||
|
|
@ -44,44 +18,13 @@ 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()))?;
|
||||||
// On Windows the rename can fail briefly while an antivirus scanner or
|
fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
|
||||||
// indexer holds the destination open, so retry a few times.
|
Ok(())
|
||||||
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 = config_base().ok_or_else(|| anyhow!("cannot determine config directory"))?;
|
let dir = dirs::config_dir().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() {
|
||||||
|
|
@ -110,10 +53,7 @@ impl Store {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match serde_json::from_str::<Store>(&text) {
|
match serde_json::from_str::<Store>(&text) {
|
||||||
Ok(mut s) => {
|
Ok(s) => (s, None),
|
||||||
s.base = s.playlists.clone();
|
|
||||||
(s, None)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let bak = path.with_extension("json.bak");
|
let bak = path.with_extension("json.bak");
|
||||||
let _ = fs::rename(&path, &bak);
|
let _ = fs::rename(&path, &bak);
|
||||||
|
|
@ -128,18 +68,8 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_disk() -> Option<BTreeMap<String, Vec<Track>>> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let text = fs::read_to_string(Self::path().ok()?).ok()?;
|
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?)
|
||||||
serde_json::from_str::<Store>(&text).ok().map(|s| s.playlists)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&mut self) -> Result<()> {
|
|
||||||
if let Some(disk) = Self::read_disk() {
|
|
||||||
self.playlists = merge(&self.base, &self.playlists, disk);
|
|
||||||
}
|
|
||||||
write_atomic(&Self::path()?, &serde_json::to_string_pretty(&*self)?)?;
|
|
||||||
self.base = self.playlists.clone();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_exact(&self, name: &str) -> Option<String> {
|
pub fn resolve_exact(&self, name: &str) -> Option<String> {
|
||||||
|
|
@ -173,33 +103,6 @@ impl Store {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_keeps_other_instances_changes() {
|
|
||||||
let t = |id: &str| Track {
|
|
||||||
id: id.into(),
|
|
||||||
title: "T".into(),
|
|
||||||
artist: "A".into(),
|
|
||||||
artists: Vec::new(),
|
|
||||||
album: None,
|
|
||||||
duration: None,
|
|
||||||
};
|
|
||||||
let base: BTreeMap<String, Vec<Track>> = BTreeMap::from([
|
|
||||||
("a".to_string(), vec![t("aaaaaaaaaaa")]),
|
|
||||||
("b".to_string(), vec![]),
|
|
||||||
]);
|
|
||||||
let mut mine = base.clone();
|
|
||||||
mine.insert("c".into(), vec![t("ccccccccccc")]);
|
|
||||||
mine.remove("b");
|
|
||||||
let mut disk = base.clone();
|
|
||||||
disk.insert("d".into(), vec![t("ddddddddddd")]);
|
|
||||||
disk.get_mut("a").unwrap().push(t("eeeeeeeeeee"));
|
|
||||||
|
|
||||||
let out = merge(&base, &mine, disk);
|
|
||||||
let names: Vec<&str> = out.keys().map(String::as_str).collect();
|
|
||||||
assert_eq!(names, ["a", "c", "d"]);
|
|
||||||
assert_eq!(out["a"].len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exact_ignores_prefixes() {
|
fn exact_ignores_prefixes() {
|
||||||
let mut s = Store::default();
|
let mut s = Store::default();
|
||||||
|
|
|
||||||
91
src/sys.rs
91
src/sys.rs
|
|
@ -1,91 +0,0 @@
|
||||||
//! 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) {}
|
|
||||||
572
src/ytmusic.rs
572
src/ytmusic.rs
|
|
@ -1,52 +1,21 @@
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::model::{is_valid_id, parse_clock, Collection, Track};
|
use crate::model::{is_valid_id, parse_clock, Track};
|
||||||
|
|
||||||
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
||||||
const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false";
|
|
||||||
const 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(page) if !page.tracks.is_empty() => Ok(page),
|
Ok(tracks) if !tracks.is_empty() => Ok(tracks),
|
||||||
Ok(_) => ytdlp_search(query)
|
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")),
|
||||||
.map(Page::from)
|
Err(primary) => ytdlp_search(query).map_err(|fallback| {
|
||||||
.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:#})"
|
||||||
)
|
)
|
||||||
|
|
@ -54,98 +23,6 @@ pub fn search_page(query: &str) -> Result<Page, 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());
|
||||||
|
|
@ -176,100 +53,32 @@ 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<Page> {
|
fn search_innertube(query: &str) -> Result<Vec<Track>> {
|
||||||
let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?;
|
let agent = ureq::AgentBuilder::new()
|
||||||
Ok(page_from(&v))
|
.timeout(Duration::from_secs(12))
|
||||||
}
|
.user_agent(UA)
|
||||||
|
.build();
|
||||||
fn page_from(v: &Value) -> Page {
|
let body = json!({
|
||||||
Page {
|
"context": { "client": {
|
||||||
tracks: parse_search_response(v),
|
"clientName": "WEB_REMIX",
|
||||||
next: find_continuation(v),
|
"clientVersion": client_version(),
|
||||||
}
|
"hl": "en",
|
||||||
}
|
"gl": "US",
|
||||||
|
}},
|
||||||
fn find_continuation(v: &Value) -> Option<String> {
|
"query": query,
|
||||||
match v {
|
"params": SONGS_FILTER,
|
||||||
Value::Object(map) => {
|
});
|
||||||
let token = map
|
let resp = agent
|
||||||
.get("nextContinuationData")
|
.post(ENDPOINT)
|
||||||
.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}"))?;
|
||||||
resp.into_json::<Value>()
|
let v: Value = resp
|
||||||
.context("invalid JSON from YouTube Music")
|
.into_json()
|
||||||
}
|
.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>) {
|
||||||
|
|
@ -301,45 +110,12 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn column_runs(item: &Value, col: usize) -> Option<&Vec<Value>> {
|
fn column_segments(item: &Value, col: usize) -> Vec<String> {
|
||||||
item.get("flexColumns")
|
let runs = item
|
||||||
|
.get("flexColumns")
|
||||||
.and_then(|c| c.get(col))
|
.and_then(|c| c.get(col))
|
||||||
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
|
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array);
|
||||||
}
|
|
||||||
|
|
||||||
fn column_segments(item: &Value, col: usize) -> Vec<String> {
|
|
||||||
run_segments(column_runs(item, col))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Artists are the runs that link to an artist/channel page ("UC..." browse
|
|
||||||
* ids); the "&" and "," between them are plain runs. Using the source's own
|
|
||||||
* boundaries means names that contain a separator stay in one piece. Names
|
|
||||||
* must also appear in `credit`, so a stray linked run can't leak in. Empty
|
|
||||||
* when the response carries no links (the caller falls back to guessing). */
|
|
||||||
fn artist_names_from_runs(runs: Option<&Vec<Value>>, credit: &str) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = Vec::new();
|
|
||||||
for run in runs.into_iter().flatten() {
|
|
||||||
let id = run
|
|
||||||
.pointer("/navigationEndpoint/browseEndpoint/browseId")
|
|
||||||
.and_then(Value::as_str);
|
|
||||||
let text = run.get("text").and_then(Value::as_str);
|
|
||||||
let (Some(id), Some(text)) = (id, text) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let text = text.trim();
|
|
||||||
if id.starts_with("UC")
|
|
||||||
&& !text.is_empty()
|
|
||||||
&& credit.contains(text)
|
|
||||||
&& !names.iter().any(|n| n == text)
|
|
||||||
{
|
|
||||||
names.push(text.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
|
|
||||||
let mut segments = Vec::new();
|
let mut segments = Vec::new();
|
||||||
let mut cur = String::new();
|
let mut cur = String::new();
|
||||||
for run in runs.into_iter().flatten() {
|
for run in runs.into_iter().flatten() {
|
||||||
|
|
@ -364,13 +140,6 @@ fn looks_like_duration(s: &str) -> bool {
|
||||||
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
|
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fixed_duration(item: &Value) -> Option<u32> {
|
|
||||||
let s = item
|
|
||||||
.pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")?
|
|
||||||
.as_str()?;
|
|
||||||
parse_clock(s).map(|s| s as u32)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_item(item: &Value) -> Option<Track> {
|
fn parse_item(item: &Value) -> Option<Track> {
|
||||||
let id = find_str(item, "videoId")?;
|
let id = find_str(item, "videoId")?;
|
||||||
if !is_valid_id(id) {
|
if !is_valid_id(id) {
|
||||||
|
|
@ -393,12 +162,10 @@ fn parse_item(item: &Value) -> Option<Track> {
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let duration = duration.or_else(|| fixed_duration(item));
|
|
||||||
let artist = meta
|
let artist = meta
|
||||||
.first()
|
.first()
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "Unknown artist".into());
|
.unwrap_or_else(|| "Unknown artist".into());
|
||||||
let artists = artist_names_from_runs(column_runs(item, 1), &artist);
|
|
||||||
let album = meta
|
let album = meta
|
||||||
.get(1)
|
.get(1)
|
||||||
.filter(|a| !a.contains(" views") && !a.contains(" plays"))
|
.filter(|a| !a.contains(" views") && !a.contains(" plays"))
|
||||||
|
|
@ -408,69 +175,11 @@ fn parse_item(item: &Value) -> Option<Track> {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
artists,
|
|
||||||
album,
|
album,
|
||||||
duration,
|
duration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_browse(v: &Value, playlist: bool) -> Vec<Track> {
|
|
||||||
let mut items = Vec::new();
|
|
||||||
collect_items(v, &mut items);
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
items
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
let mut t = parse_item(item)?;
|
|
||||||
if playlist && t.album.is_none() {
|
|
||||||
t.album = column_segments(item, 2).into_iter().next();
|
|
||||||
}
|
|
||||||
Some(t)
|
|
||||||
})
|
|
||||||
.filter(|t| seen.insert(t.id.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_collections(v: &Value, kind: Kind) -> Vec<Collection> {
|
|
||||||
let mut items = Vec::new();
|
|
||||||
collect_items(v, &mut items);
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
items
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
let id = item
|
|
||||||
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
|
|
||||||
.as_str()?;
|
|
||||||
let ok = match kind {
|
|
||||||
Kind::Album => id.starts_with("MPRE"),
|
|
||||||
Kind::Playlist => id.starts_with("VL"),
|
|
||||||
};
|
|
||||||
if !ok {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let title = column_segments(item, 0).into_iter().next()?;
|
|
||||||
let mut meta = column_segments(item, 1);
|
|
||||||
if matches!(
|
|
||||||
meta.first().map(String::as_str),
|
|
||||||
Some("Album" | "EP" | "Single" | "Playlist")
|
|
||||||
) {
|
|
||||||
meta.remove(0);
|
|
||||||
}
|
|
||||||
let artist = match kind {
|
|
||||||
Kind::Album => meta.first().cloned().unwrap_or_default(),
|
|
||||||
Kind::Playlist => String::new(),
|
|
||||||
};
|
|
||||||
Some(Collection {
|
|
||||||
browse_id: id.to_string(),
|
|
||||||
title,
|
|
||||||
subtitle: meta.join(" · "),
|
|
||||||
artist,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.filter(|c| seen.insert(c.browse_id.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_search_response(v: &Value) -> Vec<Track> {
|
pub fn parse_search_response(v: &Value) -> Vec<Track> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
collect_items(v, &mut items);
|
collect_items(v, &mut items);
|
||||||
|
|
@ -482,86 +191,13 @@ 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 mut cmd = Command::new("yt-dlp");
|
let out = Command::new("yt-dlp")
|
||||||
cmd.args([
|
.args([
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
|
|
@ -570,8 +206,7 @@ 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?"))?;
|
||||||
|
|
||||||
|
|
@ -604,24 +239,11 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
|
||||||
.unwrap_or("Unknown artist")
|
.unwrap_or("Unknown artist")
|
||||||
.trim_end_matches(" - Topic")
|
.trim_end_matches(" - Topic")
|
||||||
.to_string();
|
.to_string();
|
||||||
/* newer yt-dlp lists each credited artist separately */
|
|
||||||
let artists: Vec<String> = v
|
|
||||||
.get("artists")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|a| {
|
|
||||||
a.iter()
|
|
||||||
.filter_map(Value::as_str)
|
|
||||||
.map(|n| n.trim().to_string())
|
|
||||||
.filter(|n| !n.is_empty())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
|
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
|
||||||
Some(Track {
|
Some(Track {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
artists,
|
|
||||||
album: None,
|
album: None,
|
||||||
duration,
|
duration,
|
||||||
})
|
})
|
||||||
|
|
@ -680,36 +302,6 @@ mod tests {
|
||||||
assert_eq!(t.duration, Some(241));
|
assert_eq!(t.duration, Some(241));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keeps_linked_artist_runs_whole() {
|
|
||||||
let link = |text: &str, id: &str| {
|
|
||||||
json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}})
|
|
||||||
};
|
|
||||||
let resp = json!([{"musicResponsiveListItemRenderer": {
|
|
||||||
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
|
|
||||||
"flexColumns": [
|
|
||||||
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
|
|
||||||
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
|
|
||||||
link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "},
|
|
||||||
link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "},
|
|
||||||
link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"}
|
|
||||||
]}}}
|
|
||||||
]
|
|
||||||
}}]);
|
|
||||||
let t = &parse_search_response(&resp)[0];
|
|
||||||
assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis");
|
|
||||||
assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]);
|
|
||||||
assert_eq!(t.album.as_deref(), Some("Album"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_ytdlp_artists_list() {
|
|
||||||
let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#;
|
|
||||||
let t = parse_ytdlp_lines(line);
|
|
||||||
assert_eq!(t[0].artists, ["A", "B"]);
|
|
||||||
assert_eq!(t[0].artist, "A, B");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skips_items_without_video_id() {
|
fn skips_items_without_video_id() {
|
||||||
let resp = json!([{"musicResponsiveListItemRenderer": {
|
let resp = json!([{"musicResponsiveListItemRenderer": {
|
||||||
|
|
@ -731,106 +323,6 @@ 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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue