mirror of
https://github.com/talwat/lowfi
synced 2025-09-28 19:30:01 +00:00
Compare commits
158 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e702c1de00 | ||
|
8f837302c3 | ||
|
226c674295 | ||
|
66f2243b2c | ||
|
05fe8069ea | ||
|
9b61dffb12 | ||
|
c2530453fb | ||
|
e4fd542edf | ||
|
632b298de2 | ||
|
41ba98b9cf | ||
|
0162421db4 | ||
|
4d4f5e0920 | ||
|
d41bd16069 | ||
|
d2c8bdb8aa | ||
|
f6ec3bb1fe | ||
|
6f679055ea | ||
|
1bb3ee2e02 | ||
|
d60dc362ca | ||
|
3e0cbf9871 | ||
|
6f15f9226f | ||
|
bdd508bfbb | ||
|
19ca315509 | ||
|
620b568926 | ||
|
ad1fe84480 | ||
|
d81c4cced8 | ||
|
1884d2ebed | ||
|
9f7c895154 | ||
|
0b7638468c | ||
|
2c9a8229da | ||
|
f6bea9f048 | ||
|
cd4a371da1 | ||
|
05747b6537 | ||
|
399ee25ef5 | ||
|
3713056f70 | ||
|
0dc1f72d97 | ||
|
cb886b344e | ||
|
69747ff8b4 | ||
|
0b15ce8e1b | ||
|
6fadfe6304 | ||
|
b6a81c9634 | ||
|
1af976ad77 | ||
|
e8b4b17f98 | ||
|
1a76699afc | ||
|
2ccf073646 | ||
|
315fa105bf | ||
|
7cdd2e7694 | ||
|
a89854e46f | ||
|
f1c6cbf026 | ||
|
d24c6b1a74 | ||
|
a83a052ae9 | ||
|
a9cd30550c | ||
|
29dab7a77a | ||
|
fe70800502 | ||
|
d05f36a0bb | ||
|
5db5146b8e | ||
|
34577efe8f | ||
|
968c1ee670 | ||
|
bbdcfdd6f2 | ||
|
8e843c12a2 | ||
|
adcb20f2d0 | ||
|
27fc505830 | ||
|
66ccc44099 | ||
|
ca746c0902 | ||
|
768f976e89 | ||
|
84f386e0eb | ||
|
b68ce27d19 | ||
|
ed4b79d2bf | ||
|
a4dd55fb28 | ||
|
3db4f9d402 | ||
|
2a36bc72f3 | ||
|
ce8f8d2845 | ||
|
f0123fd2bc | ||
|
ece88de1ae | ||
|
a720e9d2cf | ||
|
503b4fe9db | ||
|
67a4c4f0ea | ||
|
2b20bf7709 | ||
|
945b420cd8 | ||
|
1e3c66679c | ||
|
840b1663e7 | ||
|
7502d1cd17 | ||
|
1480b62be9 | ||
|
923ac05cf8 | ||
|
6a6823d078 | ||
|
1e491bb36f | ||
|
b87a525c74 | ||
|
22a0851d40 | ||
|
3db0623a72 | ||
|
22a2e7f986 | ||
|
02a8e4f815 | ||
|
bc80a7e702 | ||
|
07a4fc26c1 | ||
|
5057721913 | ||
|
6ff41e0e34 | ||
|
a39d903c68 | ||
|
a6f3eb034d | ||
|
5f0f78f0e6 | ||
|
41a9e32184 | ||
|
a076c2b62f | ||
|
56b03290d0 | ||
|
b12a7077a8 | ||
|
e7ac0c9214 | ||
|
ab288ee0d4 | ||
|
fe9429bfb3 | ||
|
04d5ed335d | ||
|
083d6c473c | ||
|
0096eda4db | ||
|
cefb95e556 | ||
|
978dfbc5e2 | ||
|
9414b97d62 | ||
|
30af38a63a | ||
|
a64b10e20c | ||
|
71461a8d09 | ||
|
cb78b4909b | ||
|
ddc572b405 | ||
|
120ac3f972 | ||
|
6f9dab6aa8 | ||
|
e3e7c28ab0 | ||
|
301b831737 | ||
|
ac9b196675 | ||
|
10a3263c82 | ||
|
543aeee78c | ||
|
b2c225256f | ||
|
8d9d003dc9 | ||
|
fd8ecfcd05 | ||
|
79a2b7da9c | ||
|
5eeee8069c | ||
|
4207016e82 | ||
|
2a8754815c | ||
|
e47ed1da94 | ||
|
cc73fa5a37 | ||
|
41bba2d6e4 | ||
|
724afd6fa7 | ||
|
6f7c2dbcba | ||
|
5b546ea2de | ||
|
d9ba0c3b3b | ||
|
d01673e0a7 | ||
|
c7d46e9872 | ||
|
6b157dd457 | ||
|
901bf0e871 | ||
|
baa2e095d9 | ||
|
8f805d7119 | ||
|
d1a56403c2 | ||
|
f79390b574 | ||
|
badc93a4fa | ||
|
2d785ee47e | ||
|
0ef844a3f2 | ||
|
706ba97428 | ||
|
86f88ff34f | ||
|
b85d8c0be2 | ||
|
9741d4b0d5 | ||
|
49e7191369 | ||
|
a414c5e9f4 | ||
|
6b85a83749 | ||
|
c2baa53ded | ||
|
5d2e6c6d23 | ||
|
60a00f189e | ||
|
86f3f56edb |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
||||
/target
|
||||
/cache
|
||||
.DS_Store
|
29
CHILLHOP.md
Normal file
29
CHILLHOP.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Using the chillhop list
|
||||
|
||||
> [!WARNING]
|
||||
> As of lowfi 1.7.0, the chillhop list is included by default. For a more
|
||||
> detailed explanation, see [MUSIC.md](MUSIC.md). This document is included
|
||||
> to preserve any old links or references. The instructions are still valid.
|
||||
|
||||
## Linux
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/share/lowfi
|
||||
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir ~/.local/share/lowfi
|
||||
```
|
||||
|
||||
## MacOS
|
||||
|
||||
```sh
|
||||
mkdir -p "$HOME/Library/Application Support/lowfi"
|
||||
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir "$HOME/Library/Application Support/lowfi"
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
Go to `%appdata%` in Explorer, then `Roaming`, and make a folder called `lowfi`.
|
||||
Then just put [this file](https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt) in there.
|
||||
|
||||
## Launching lowfi
|
||||
|
||||
Once the list has been added, just launch `lowfi` with `-t chillhop`.
|
2228
Cargo.lock
generated
2228
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
52
Cargo.toml
52
Cargo.toml
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lowfi"
|
||||
version = "1.2.4"
|
||||
version = "1.7.2"
|
||||
edition = "2021"
|
||||
description = "An extremely simple lofi player."
|
||||
license = "MIT"
|
||||
@ -16,27 +16,49 @@ documentation = "https://github.com/talwat/lowfi"
|
||||
homepage = "https://github.com/talwat/lowfi"
|
||||
repository = "https://github.com/talwat/lowfi"
|
||||
|
||||
[features]
|
||||
mpris = ["dep:mpris-server"]
|
||||
extra-audio-formats = ["rodio/default"]
|
||||
scrape = ["dep:serde", "dep:serde_json", "dep:html-escape", "dep:scraper", "dep:indicatif"]
|
||||
|
||||
[dependencies]
|
||||
# Basics
|
||||
clap = { version = "4.5.18", features = ["derive", "cargo"] }
|
||||
eyre = { version = "0.6.12" }
|
||||
rand = "0.8.5"
|
||||
clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
||||
eyre = "0.6.12"
|
||||
fastrand = "2.3.0"
|
||||
thiserror = "2.0.12"
|
||||
color-eyre = { version = "0.6.5", default-features = false }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.40.0", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
], default-features = false }
|
||||
futures = "0.3.30"
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "fs"], default-features = false }
|
||||
futures = "0.3.31"
|
||||
arc-swap = "1.7.1"
|
||||
|
||||
# Data
|
||||
reqwest = "0.12.7"
|
||||
bytes = "1.7.2"
|
||||
reqwest = { version = "0.12.9", features = ["stream"] }
|
||||
bytes = "1.9.0"
|
||||
|
||||
# I/O
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
|
||||
mpris-server = { version = "0.8.1", optional = true }
|
||||
dirs = "6.0.0"
|
||||
|
||||
# Misc
|
||||
scraper = "0.20.0"
|
||||
rodio = { version = "0.19.0", features = ["symphonia-mp3"], default-features = false }
|
||||
crossterm = "0.28.1"
|
||||
Inflector = "0.11.4"
|
||||
convert_case = "0.8.0"
|
||||
lazy_static = "1.5.0"
|
||||
url = "2.5.4"
|
||||
unicode-segmentation = "1.12.0"
|
||||
|
||||
# Scraper
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0.142", optional = true }
|
||||
scraper = { version = "0.21.0", optional = true }
|
||||
html-escape = { version = "0.2.13", optional = true }
|
||||
indicatif = { version = "0.18.0", optional = true }
|
||||
regex = "1.11.1"
|
||||
atomic_float = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2.167"
|
||||
|
||||
|
7
ENVIRONMENT_VARS.md
Normal file
7
ENVIRONMENT_VARS.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Environment Variables
|
||||
|
||||
Lowfi has some more specific options, usually as a result of minor feature requests, which are only documented here.
|
||||
If you have some behaviour you'd like to change, which is quite specific, then see if one of these options suits you.
|
||||
|
||||
* `LOWFI_FIXED_MPRIS_NAME` - Limits the number of lowfi instances to one, but ensures the player name is always `lowfi`.
|
||||
* `LOWFI_DISABLE_UI` - Disables the UI.
|
75
MUSIC.md
Normal file
75
MUSIC.md
Normal file
@ -0,0 +1,75 @@
|
||||
# The State of Lowfi's Music
|
||||
|
||||
> [!WARNING]
|
||||
> This document will be a bit long and has almost nothing to do with the actual
|
||||
> usage of lowfi, just the music embedded by default.
|
||||
|
||||
Before that though, some context. lowfi includes an extensive track list
|
||||
embedded into the software, so you can download it and have it "just work"
|
||||
out of the box.
|
||||
|
||||
I always hated apps that required extensive configuration just to be usable.
|
||||
Sometimes it's justified, but often, it's just pointless when most will end up
|
||||
with the same set of "defaults" that aren't really defaults.
|
||||
|
||||
Lowfi is so nice and simple because of the "plug and play" aspect,
|
||||
but it's become a lot harder to continue it as of late.
|
||||
|
||||
## The Lofi Girl List
|
||||
|
||||
Originally, it was planned that lowfi would use music scraped from Lofi Girl's own
|
||||
website. The scraper actually came before the rest of the program, believe it or not.
|
||||
|
||||
However, after a long period of downtime, the Lofi Girl website was redone without the
|
||||
mp3 track files. Those are now pretty much inaccessible aside from paying for individual
|
||||
albums on bandcamp which gets very expensive very quickly.
|
||||
|
||||
Doing this was never actually disallowed, but it is now simply impossible. So, the question was,
|
||||
what to do next after losing lowfi's primary source of music?
|
||||
|
||||
## Tracklists
|
||||
|
||||
I was originally against the idea of custom tracklists, because of my almost purist
|
||||
ideals of a 100% no config at all vision for lowfi. But eventually, I gave in, which proved
|
||||
to be a very good decision in hindsight. Now, regardless of what choices I make on the music
|
||||
which is embedded, all may opt out of that and choose whatever they like.
|
||||
|
||||
This culminated in a few templates located in the `data` directory of this repository
|
||||
which included a handful of tracklists, and in particular, the chillhop list by user
|
||||
[danielwerg](https://github.com/danielwerg).
|
||||
|
||||
## The Switch
|
||||
|
||||
After `lofigirl.com` went down, I thought a bit and eventually decided
|
||||
to just bite the bullet and switch to the chillhop list. This was despite the fact
|
||||
that chillhop entirely bans third party players in their TOS. They also ban
|
||||
scrapers, which I only learned after writing one.
|
||||
|
||||
So, is lowfi really going to have to violate the TOS of it's own music provider?
|
||||
Well, yes. I thought about it, and came to the conclusion that lowfi is probably
|
||||
not much of a threat for a few reasons.
|
||||
|
||||
Firstly, it emulates exactly the behavior of chillhop's own radio player.
|
||||
The only difference is that one shoves you into a web browser, and the other,
|
||||
into a nice terminal window.
|
||||
|
||||
Then, I also realize that lowfi is just a small program used by few.
|
||||
I'm not making money on any of this, and I think degrading the experience for my
|
||||
fellow nerds who just want to listen to some lowfi without all the crap is not worth it.
|
||||
|
||||
At the end of the day, lowfi has a distinct UserAgent. Should chillhop ever take issue with
|
||||
it's behaviour, banning it is extremely simple. I don't want that to happen, but I
|
||||
understand if it does.
|
||||
|
||||
## Well, *I* Hate the Chillhop Music
|
||||
|
||||
It's not as "lofi". It is almost certainly a compromise, that much I cannot even pretend to
|
||||
deny. I find myself hitting the skip button almost three times as often with chillhop.
|
||||
|
||||
If you are undeterred enough by TOS's to read this far, then you can use the `archive.txt`
|
||||
list in the `data` folder. The list is a product of me worrying that the tracks on `lofigirl.com`
|
||||
could've possibly been lost somehow, relating to the website going down.
|
||||
|
||||
It's hosted on `archive.org`, and could be taken down at any point for any reason.
|
||||
Being derived from my own local archive, it retains ~2700 out of the ~3700 tracks.
|
||||
That's not perfect, the organization is also *bad*, but it exists.
|
238
README.md
238
README.md
@ -7,68 +7,250 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
|
||||
under their [licensing guidelines](https://form.lofigirl.com/CommercialLicense).
|
||||
|
||||
If god forbid you're planning to use this in a commercial setting, please
|
||||
follow their rules.
|
||||
As of the 1.7.0 version of lowfi, **all** of the audio files embedded
|
||||
by default are from [chillhop](https://chillhop.com/). Read
|
||||
[MUSIC.md](MUSIC.md) for more information.
|
||||
|
||||
## Why?
|
||||
|
||||
I really hate modern music platforms, and I wanted a small, "suckless"
|
||||
app that would literally just play lofi without video so I could use it
|
||||
whenever.
|
||||
I really hate modern music platforms, and I wanted a small, simple
|
||||
app that would just play random ambient music without video and other fluff.
|
||||
|
||||
I also wanted it to be fairly resiliant to inconsistent networks,
|
||||
so it buffers 5 whole songs at a time instead of parts of the same song.
|
||||
|
||||
Although, lowfi is yet to be properly tested in difficult conditions,
|
||||
so don't rely on it too much until I do that. See [Scraping](#scraping) if
|
||||
you're interested in downloading the tracks. Beware, there's a lot of them.
|
||||
Beyond that, it was also designed to be fairly resilient to inconsistent networks,
|
||||
and as such it buffers 5 whole songs at a time instead of parts of the same song.
|
||||
|
||||
## Installing
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> If you're interested in maintaining a package for `lowfi`
|
||||
> on package managers such as homebrew and the like, open an issue.
|
||||
|
||||
### Dependencies
|
||||
|
||||
You'll need Rust 1.74.0+.
|
||||
|
||||
On MacOS & Windows, no extra dependencies are needed.
|
||||
|
||||
On Linux, you'll also need openssl & alsa, as well as their headers.
|
||||
|
||||
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu, `alsa-lib-devel` on Fedora.
|
||||
- `openssl` on Arch, `libssl-dev` on Ubuntu, `openssl-devel` on Fedora.
|
||||
|
||||
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
||||
|
||||
### Cargo
|
||||
|
||||
The recommended installation method is to use cargo:
|
||||
|
||||
```sh
|
||||
cargo install lowfi
|
||||
|
||||
# If you want MPRIS support.
|
||||
cargo install lowfi --features mpris
|
||||
```
|
||||
|
||||
and making sure `$HOME/.cargo/bin` is added to `$PATH`.
|
||||
|
||||
### AUR
|
||||
### Release Binaries
|
||||
|
||||
If you're on Arch, you can also use the AUR:
|
||||
If you're struggling or unwilling to use cargo, you can just download
|
||||
precompiled binaries from the [latest release](https://github.com/talwat/lowfi/releases/latest).
|
||||
|
||||
### AUR
|
||||
|
||||
```sh
|
||||
yay -S lowfi
|
||||
```
|
||||
|
||||
### openSUSE
|
||||
|
||||
```sh
|
||||
zypper install lowfi
|
||||
```
|
||||
|
||||
### Debian
|
||||
|
||||
> [!NOTE]
|
||||
> This uses an unofficial Debian repository maintained by [Dario Griffo](https://github.com/dariogriffo).
|
||||
|
||||
```sh
|
||||
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||
sudo apt install -y lowfi
|
||||
```
|
||||
|
||||
### Fedora (COPR)
|
||||
|
||||
> [!NOTE]
|
||||
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
|
||||
|
||||
```sh
|
||||
sudo dnf copr enable furqanhun/lowfi
|
||||
sudo dnf install lowfi
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
This is good for debugging, especially in issues.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/talwat/lowfi
|
||||
cd lowfi
|
||||
|
||||
# If you want an actual binary
|
||||
cargo build --release --all-features
|
||||
./target/release/lowfi
|
||||
|
||||
# If you just want to test
|
||||
cargo run --all-features
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
`lowfi`
|
||||
|
||||
Yeah, that's it. Controls are documented in the app.
|
||||
Yeah, that's it.
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Function |
|
||||
| ------------------ | --------------- |
|
||||
| `s`, `n`, `l` | Skip Song |
|
||||
| `p`, Space | Play/Pause |
|
||||
| `+`, `=`, `k`, `↑` | Volume Up 10% |
|
||||
| `→` | Volume Up 1% |
|
||||
| `-`, `_`, `j`, `↓` | Volume Down 10% |
|
||||
| `←` | Volume Down 1% |
|
||||
| `q`, CTRL+C | Quit |
|
||||
| `b` | Bookmark |
|
||||
|
||||
> [!NOTE]
|
||||
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
||||
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
|
||||
>
|
||||
> MPRIS is currently an optional feature in cargo (enabled with `--features mpris`)
|
||||
> due to it being only for Linux, as well as the fact that the main point of
|
||||
> lowfi is it's unique & minimal interface.
|
||||
|
||||
### Extra Flags
|
||||
|
||||
If you're having issues on Linux with error messages,
|
||||
or you don't want your terminal history to be visible when you
|
||||
run lofi, you can use the `--alternate` or `-a` flag to
|
||||
hide your history.
|
||||
If you have something you'd like to tweak about lowfi, you use additional flags which
|
||||
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
|
||||
|
||||
### Scraping
|
||||
| Flag | Function |
|
||||
| ----------------------------------- | --------------------------------------------------- |
|
||||
| `-a`, `--alternate` | Use an alternate terminal screen |
|
||||
| `-m`, `--minimalist` | Hide the bottom control bar |
|
||||
| `-b`, `--borderless` | Exclude borders in UI |
|
||||
| `-p`, `--paused` | Start lowfi paused |
|
||||
| `-f`, `--fps` | FPS of the UI [default: 12] |
|
||||
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
|
||||
| `-d`, `--debug` | Include ALSA & other logs |
|
||||
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
||||
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
|
||||
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
|
||||
|
||||
lowfi also has a `scrape` command which is usually not relevant, but
|
||||
if you're trying to download some files from Lofi Girls' website,
|
||||
it can be useful.
|
||||
### Extra Features
|
||||
|
||||
An example of scrape is as follows,
|
||||
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,
|
||||
like those which are only expected to be used by a handful of users.
|
||||
|
||||
`lowfi scrape --extension zip --include-full`
|
||||
#### `scrape` - Scraping
|
||||
|
||||
where more information can be found by running `lowfi help scrape`.
|
||||
This feature provides the `scrape` command.
|
||||
It's usually not very useful, but is included for transparency's sake.
|
||||
|
||||
More information can be found by running `lowfi help scrape`.
|
||||
|
||||
#### `mpris` - MPRIS
|
||||
|
||||
Enables MPRIS. It's not rocket science.
|
||||
|
||||
#### `extra-audio-formats` - Extra Audio Formats
|
||||
|
||||
This is only relevant to those using a custom track list, in which case
|
||||
it allows for more formats than just MP3. Those are FLAC, Vorbis, and WAV.
|
||||
|
||||
These should be sufficient for some 99% of music files people might want to play.
|
||||
If you dealing with the 1% using another audio format which is in
|
||||
[this list](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#codecs-decoders), open an issue.
|
||||
|
||||
### Custom Track Lists
|
||||
|
||||
> [!NOTE]
|
||||
> Some nice users, especially [danielwerg](https://github.com/danielwerg),
|
||||
> have aleady made alternative track lists located in the [data](https://github.com/talwat/lowfi/blob/main/data/)
|
||||
> directory of this repo. You can use them with lowfi by using the `--track-list` flag.
|
||||
>
|
||||
> Feel free to contribute your own list with a PR.
|
||||
|
||||
lowfi also supports custom track lists, although the default one from chillhop
|
||||
is embedded into the binary.
|
||||
|
||||
To use a custom list, use the `--track-list` flag. This can either be a path to some file,
|
||||
or it could also be the name of a file (without the `.txt` extension) in the data
|
||||
directory.
|
||||
|
||||
> [!NOTE]
|
||||
> Data directories:
|
||||
>
|
||||
> - Linux - `~/.local/share/lowfi`
|
||||
> - macOS - `~/Library/Application Support/lowfi`
|
||||
> - Windows - `%appdata%\Roaming\lowfi`
|
||||
|
||||
For example, `lowfi --track-list minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||
Whereas if you did `lowfi --track-list ~/Music/minipop.txt` it would load from that
|
||||
specified directory.
|
||||
|
||||
All tracks must be in the MP3 format, unless lowfi has been compiled with the
|
||||
`extra-audio-formats` feature which includes support for some others.
|
||||
|
||||
#### The Format
|
||||
|
||||
In lists, the first line is what's known as the header, followed by the rest of the tracks.
|
||||
Each track will be first appended to the header, and then use the combination to download
|
||||
the track.
|
||||
|
||||
> [!NOTE]
|
||||
> lowfi _will not_ put a `/` between the base & track for added flexibility,
|
||||
> so for most cases you should have a trailing `/` in your header.
|
||||
|
||||
The exception to this is if the track name begins with a protocol like `https://`,
|
||||
in which case the base will not be prepended to it. If all of your tracks are like this,
|
||||
then you can put `noheader` as the first line and not have a header at all.
|
||||
|
||||
For example, in this list:
|
||||
|
||||
```txt
|
||||
https://lofigirl.com/wp-content/uploads/
|
||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||
2023/04/2-In-Front-Of-Me.mp3
|
||||
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
|
||||
```
|
||||
|
||||
lowfi would download these three URLs:
|
||||
|
||||
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
|
||||
- `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
|
||||
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
|
||||
|
||||
Additionally, you may also specify a custom display name for the track which is indicated by a `!`.
|
||||
For example, if you had an entry like this:
|
||||
|
||||
```txt
|
||||
2023/04/2-In-Front-Of-Me.mp3!custom name
|
||||
```
|
||||
|
||||
Then lowfi would download from the first section, and display the second as the track name.
|
||||
|
||||
`file://` can be used in front a track/header to make lowfi treat it as a local file.
|
||||
This is useful if you want to use a local file as the base URL, for example:
|
||||
|
||||
```txt
|
||||
file:///home/user/Music/
|
||||
file.mp3
|
||||
file:///home/user/Other Music/second-file.mp3
|
||||
```
|
||||
|
||||
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
||||
|
2459
data/archive.txt
Normal file
2459
data/archive.txt
Normal file
File diff suppressed because it is too large
Load Diff
1358
data/chillhop.txt
Normal file
1358
data/chillhop.txt
Normal file
File diff suppressed because it is too large
Load Diff
2
data/file.txt
Normal file
2
data/file.txt
Normal file
@ -0,0 +1,2 @@
|
||||
file:///home/user/Music/
|
||||
Test.mp3
|
2
data/noheader.txt
Normal file
2
data/noheader.txt
Normal file
@ -0,0 +1,2 @@
|
||||
noheader
|
||||
https://stream.chillhop.com/mp3/9476
|
File diff suppressed because it is too large
Load Diff
115
data/old/synthboy.txt
Normal file
115
data/old/synthboy.txt
Normal file
@ -0,0 +1,115 @@
|
||||
https://lofigirl.com/wp-content/uploads/
|
||||
2024/01/1.-i_m-alone-out-here-ft.-Outgrown-master.mp3
|
||||
2024/01/2.-aurora-ft.-Outgrown-master.mp3
|
||||
2024/01/3.-dusk-master.mp3
|
||||
2024/01/4.-aria-ft.-after-noon-master.mp3
|
||||
2024/01/5.-dreamscape-ft.-Luke-Tidbury-master.mp3
|
||||
2023/11/Le-Metroid-Crystal-Children.mp3
|
||||
2023/11/Le-Metroid-Frequencies.mp3
|
||||
2023/11/Le-Metroid-Space-Echoes.mp3
|
||||
2023/11/Le-Metroid-Voyager.mp3
|
||||
2023/11/Le-Metroid-Orion.mp3
|
||||
2023/11/Le-Metroid-Saturne.mp3
|
||||
2023/11/Le-Metroid-Speed-Light.wav
|
||||
2023/11/Le-Metroid-Earth-21.wav
|
||||
2023/11/Le-Metroid-Sleepwalker.mp3
|
||||
2023/11/Le-Metroid-Blackhole.mp3
|
||||
2023/09/01-Akraa-Lightyears-Kupla-master.mp3
|
||||
2023/09/02-Virtua-_-Robert-Iver-Analogue-Pulsar-Kupla-master.mp3
|
||||
2023/09/03-Starwave-Soundgo-Kupla-Master.mp3
|
||||
2023/09/04-Forhill-_-Eagle-Eyed-Tiger-Infuse-Kupla-Master.mp3
|
||||
2023/09/05-VIQ-x-Krosia-Echodrift-Kupla-Master.mp3
|
||||
2023/09/06-Tbeauthetraveler-Soare-Kupla-master.mp3
|
||||
2023/09/07-Boy-From-Nowhere-Bleue-Astrale-Kupla-Master-.mp3
|
||||
2023/09/08-Virtua-Searching-Kupla-Master.mp3
|
||||
2023/09/09-Electrosky-Soundgo-Kupla-Master.mp3
|
||||
2023/09/10-Protocols-Orion-Kupla-Master.mp3
|
||||
2023/09/11-Hotel_Pools_Satin_Kupla-Master.mp3
|
||||
2023/09/12-Downtown-Binary-Icarus-Kupla-Master.mp3
|
||||
2023/09/13-A.L.I.S.O.N-Erebus-Kupla-Master.mp3
|
||||
2023/09/14-Girl-From-Nowhere-x-Boy-From-Nowhere-Diamond-Kupla-Master.mp3
|
||||
2023/09/15-Kabes-x-Protocols-Static-Kupla-Master.mp3
|
||||
2023/09/16-VIQ-Somewhere-Kupla-Master.mp3
|
||||
2023/09/17-Akraa-Virtua-Portal-Kupla-Master-2.mp3
|
||||
2023/09/18-Nitewalk-x-SwuM-Last-Cosmo-Kupla-Master.mp3
|
||||
2023/09/19-Nitewalk-Prismatic-Waves-Kupla-Master3.mp3
|
||||
2023/09/20-Theo-Aabel-Night-Owl-Kupla-Master.mp3
|
||||
2023/06/Foudroie-Journey-2023.mp3
|
||||
2023/06/Foudroie-Odyssey-MASTER.mp3
|
||||
2023/06/Foudroie-Solar-Wind-MASTER.mp3
|
||||
2023/06/Foudroie-x-Forhill-Interstellar-MASTER.mp3
|
||||
2023/06/Foudroie-The-Traveler-2023.mp3
|
||||
2023/06/Foudroie-Moon-Dust-2023.mp3
|
||||
2023/06/Foudroie-Departure-2023.mp3
|
||||
2023/06/Foudroie-Interpolation-2023.mp3
|
||||
2023/06/Foudroie-Lunar-2023.mp3
|
||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||
2023/06/A1Descend.mp3
|
||||
2023/06/A3S.O.L.O.mp3
|
||||
2023/06/B4K.E.Y.S.mp3
|
||||
2023/06/EmilRottmayerW.A.V.E.mp3
|
||||
2023/06/EmilRottmayerMEGA.mp3
|
||||
2023/06/1.-steezy-prime-better-days-w_-devon-rea-_-fnonose.mp3
|
||||
2023/06/2.-steezy-prime-deep-thoughts-w_-beyond-pluto.mp3
|
||||
2023/06/3.-steezy-prime-in-the-air.mp3
|
||||
2023/06/4.-steezy-prime-gemini-w_-laffey.mp3
|
||||
2023/06/5.-steezy-prime-it_s-getting-late-w_-mildred-_-devon-rea.mp3
|
||||
2023/06/6.-steezy-prime-slow-blink-w_-fnonose.mp3
|
||||
2023/05/Polaris.mp3
|
||||
2023/05/Taking-Flight.mp3
|
||||
2023/05/Astral.mp3
|
||||
2023/05/Atlantis.mp3
|
||||
2023/05/Cirrus.mp3
|
||||
2023/05/Light-Cycles.mp3
|
||||
2023/05/Umbra.mp3
|
||||
2023/05/Shuto-Expressway.mp3
|
||||
2023/05/Fantasia.mp3
|
||||
2023/05/Winter.mp3
|
||||
2023/05/Pandora.mp3
|
||||
2023/05/4am.mp3
|
||||
2023/05/Aurora.mp3
|
||||
2023/05/Lost-Signal.mp3
|
||||
2023/05/Reflect.mp3
|
||||
2023/05/City-Lights.mp3
|
||||
2023/05/Pools.mp3
|
||||
2023/05/Void.mp3
|
||||
2023/05/Distant.mp3
|
||||
2023/05/1.-Voyage-Beyond.mp3
|
||||
2023/05/2.-A.L.I.S.O.N-always-in-my-dreams.mp3
|
||||
2023/05/3.Hotel-Pools-Limits.mp3
|
||||
2023/05/4.-Downtown-Binary-Atlantis.mp3
|
||||
2023/05/5.-Voyage-Moon-Phase.mp3
|
||||
2023/05/6.Downtown-Binary-_-The-Present-Sound-Polaris.mp3
|
||||
2023/05/7.-Hotel-Pools-_-A.L.I.S.O.N_Lunar.mp3
|
||||
2023/05/8.Hotel-Pools-Snowfall.mp3
|
||||
2023/05/9.-oDDling-Drifting.mp3
|
||||
2023/05/10.-Hotel-Pools-_-oDDling-Remain.mp3
|
||||
2023/05/11.-A.L.I.S.O.N-Subtract.mp3
|
||||
2023/05/13.-oDDling-Void.mp3
|
||||
2023/05/14.-EmilRottmayer-Elevate.mp3
|
||||
2023/05/15.-Transparent-transparent.mp3
|
||||
2023/05/16.-Neon-Galaxy-Portal.mp3
|
||||
2023/05/17.-KING-PALM-Luxury.mp3
|
||||
2023/05/18.-Departure-Imagine.mp3
|
||||
2023/05/19.-Voyage-Center-Point.mp3
|
||||
2023/05/20.-A.L.I.S.O.N-Nightride-Revisited.mp3
|
||||
2023/05/21.-VIQ-Orbit.mp3
|
||||
2023/05/22.-KING-PALM-Journey.mp3
|
||||
2023/05/23.-Krosia-Crystal-Bells.mp3
|
||||
2023/05/24.-EVANS-third-planet.mp3
|
||||
2023/05/25.-Hotel-Pools-Evolve.mp3
|
||||
2023/05/26.-KING-PALM-Coast.mp3
|
||||
2023/05/27.-Monolism-infinitespace.mp3
|
||||
2023/05/28.-oDDling-Divide.mp3
|
||||
2023/05/29.-GRAEDA-Deluge.mp3
|
||||
2023/05/30.-Xtract-Audiotool-Day-2016.mp3
|
||||
2023/05/31.-MEGAS-Stargazer.mp3
|
||||
2023/05/32.-Hotel-Pools-_-Forhill-Descent.mp3
|
||||
2023/05/33.-Hotel-Pool-_-Memorex-Memories-Distance.mp3
|
||||
2023/05/34.-GRAEDA-Barranca.mp3
|
||||
2023/05/35.-Davz-Mindless.mp3
|
||||
2023/05/36.-Unfound-Rise.mp3
|
||||
2023/05/37.-oDDling-Reverie.mp3
|
||||
2023/05/38.-Unfound-Heaven.mp3
|
||||
2023/05/39.-Novus-Sana-Sound-of-the-Sky.mp3
|
||||
2023/05/40.-Krosia-Sonar.mp3
|
4
data/sample.txt
Normal file
4
data/sample.txt
Normal file
@ -0,0 +1,4 @@
|
||||
https://lofigirl.com/wp-content/uploads/
|
||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||
2023/04/2-In-Front-Of-Me.mp3
|
||||
https://stream.chillhop.com/mp3/9476
|
4
scripts/fix_cache.sh
Normal file
4
scripts/fix_cache.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
grep -rlZ "429 Too Many Requests" . | xargs -0 rm -f
|
||||
find . -type f -empty -delete
|
98
src/main.rs
98
src/main.rs
@ -1,18 +1,67 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
//! An extremely simple lofi player.
|
||||
|
||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod messages;
|
||||
mod play;
|
||||
mod player;
|
||||
mod scrape;
|
||||
mod tracks;
|
||||
|
||||
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
||||
#[cfg(feature = "scrape")]
|
||||
mod scrapers;
|
||||
|
||||
#[cfg(feature = "scrape")]
|
||||
use crate::scrapers::Source;
|
||||
|
||||
/// An extremely simple lofi player.
|
||||
#[derive(Parser)]
|
||||
#[derive(Parser, Clone)]
|
||||
#[command(about, version)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct Args {
|
||||
/// Whether to use an alternate terminal screen.
|
||||
/// Use an alternate terminal screen.
|
||||
#[clap(long, short)]
|
||||
alternate: bool,
|
||||
|
||||
/// Hide the bottom control bar.
|
||||
#[clap(long, short)]
|
||||
minimalist: bool,
|
||||
|
||||
/// Exclude borders in UI.
|
||||
#[clap(long, short)]
|
||||
borderless: bool,
|
||||
|
||||
/// Start lowfi paused.
|
||||
#[clap(long, short)]
|
||||
paused: bool,
|
||||
|
||||
/// FPS of the UI.
|
||||
#[clap(long, short, default_value_t = 12)]
|
||||
fps: u8,
|
||||
|
||||
/// Timeout in seconds for music downloads.
|
||||
#[clap(long, default_value_t = 3)]
|
||||
timeout: u64,
|
||||
|
||||
/// Include ALSA & other logs.
|
||||
#[clap(long, short)]
|
||||
debug: bool,
|
||||
|
||||
/// Width of the player, from 0 to 32.
|
||||
#[clap(long, short, default_value_t = 3)]
|
||||
width: usize,
|
||||
|
||||
/// Use a custom track list
|
||||
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
|
||||
track_list: Option<String>,
|
||||
|
||||
/// Internal song buffer size.
|
||||
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
|
||||
buffer_size: usize,
|
||||
|
||||
/// The command that was ran.
|
||||
/// This is [None] if no command was specified.
|
||||
#[command(subcommand)]
|
||||
@ -20,32 +69,43 @@ struct Args {
|
||||
}
|
||||
|
||||
/// Defines all of the extra commands lowfi can run.
|
||||
#[derive(Subcommand)]
|
||||
#[derive(Subcommand, Clone)]
|
||||
enum Commands {
|
||||
/// Scrapes the lofi girl website file server for files.
|
||||
/// Scrapes a music source for files.
|
||||
#[cfg(feature = "scrape")]
|
||||
Scrape {
|
||||
/// The file extension to search for, defaults to mp3.
|
||||
#[clap(long, short, default_value = "mp3")]
|
||||
extension: String,
|
||||
|
||||
/// Whether to include the full HTTP URL or just the distinguishing part.
|
||||
#[clap(long, short)]
|
||||
include_full: bool,
|
||||
// The source to scrape from.
|
||||
source: scrapers::Source,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gets lowfi's data directory.
|
||||
pub fn data_dir() -> eyre::Result<PathBuf, player::Error> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or(player::Error::DataDir)?
|
||||
.join("lowfi");
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let cli = Args::parse();
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
match command {
|
||||
Commands::Scrape {
|
||||
extension,
|
||||
include_full,
|
||||
} => scrape::scrape(extension, include_full).await,
|
||||
#[cfg(feature = "scrape")]
|
||||
Commands::Scrape { source } => match source {
|
||||
Source::Archive => scrapers::archive::scrape().await?,
|
||||
Source::Lofigirl => scrapers::lofigirl::scrape().await?,
|
||||
Source::Chillhop => scrapers::chillhop::scrape().await?,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
play::play(cli.alternate).await
|
||||
}
|
||||
play::play(cli).await?;
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
37
src/messages.rs
Normal file
37
src/messages.rs
Normal file
@ -0,0 +1,37 @@
|
||||
/// Handles communication between the frontend & audio player.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
pub enum Message {
|
||||
/// Notifies the audio server that it should update the track.
|
||||
Next,
|
||||
|
||||
/// Special in that this isn't sent in a "client to server" sort of way,
|
||||
/// but rather is sent by a child of the server when a song has not only
|
||||
/// been requested but also downloaded aswell.
|
||||
NewSong,
|
||||
|
||||
/// This signal is only sent if a track timed out. In that case,
|
||||
/// lowfi will try again and again to retrieve the track.
|
||||
TryAgain,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Unpause the [Sink].
|
||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||
Play,
|
||||
|
||||
/// Pauses the [Sink].
|
||||
Pause,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
PlayPause,
|
||||
|
||||
/// Change the volume of playback.
|
||||
ChangeVolume(f32),
|
||||
|
||||
/// Bookmark the current track.
|
||||
Bookmark,
|
||||
|
||||
/// Quits gracefully.
|
||||
Quit,
|
||||
}
|
82
src/play.rs
82
src/play.rs
@ -1,38 +1,78 @@
|
||||
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
|
||||
|
||||
use std::{io::stderr, sync::Arc};
|
||||
|
||||
use crossterm::{cursor::SavePosition, terminal};
|
||||
use tokio::{
|
||||
sync::mpsc::{self},
|
||||
task::{self},
|
||||
};
|
||||
use crossterm::cursor::Show;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::terminal::{self, Clear, ClearType};
|
||||
use std::io::{stdout, IsTerminal};
|
||||
use std::process::exit;
|
||||
use std::sync::Arc;
|
||||
use std::{env, panic};
|
||||
use tokio::{sync::mpsc, task};
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::player::persistent_volume::PersistentVolume;
|
||||
use crate::player::Player;
|
||||
use crate::player::{ui, Messages};
|
||||
use crate::player::{self, ui};
|
||||
use crate::Args;
|
||||
|
||||
/// Initializes the audio server, and then safely stops
|
||||
/// it when the frontend quits.
|
||||
pub async fn play(alternate: bool) -> eyre::Result<()> {
|
||||
// Save the position. This is important since later on we can revert to this position
|
||||
// and clear any potential error messages that may have showed up.
|
||||
// TODO: Figure how to set some sort of flag to hide error messages within rodio,
|
||||
// TODO: Instead of just ignoring & clearing them after.
|
||||
crossterm::execute!(stderr(), SavePosition)?;
|
||||
pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
|
||||
// TODO: This isn't a great way of doing things,
|
||||
// but it's better than vanilla behaviour at least.
|
||||
let eyre_hook = panic::take_hook();
|
||||
|
||||
// Enable raw mode early in theory to prevent uncontrolled text in the terminal from the user.
|
||||
terminal::enable_raw_mode()?;
|
||||
panic::set_hook(Box::new(move |x| {
|
||||
let mut lock = stdout().lock();
|
||||
crossterm::execute!(
|
||||
lock,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
Show,
|
||||
PopKeyboardEnhancementFlags
|
||||
)
|
||||
.unwrap();
|
||||
terminal::disable_raw_mode().unwrap();
|
||||
|
||||
eyre_hook(x);
|
||||
exit(1)
|
||||
}));
|
||||
|
||||
// Actually initializes the player.
|
||||
// Stream kept here in the master thread to keep it alive.
|
||||
let (player, stream) = Player::new(&args).await?;
|
||||
let player = Arc::new(player);
|
||||
|
||||
// Initialize the UI, as well as the internal communication channel.
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let ui = if stdout().is_terminal() && !(env::var("LOWFI_DISABLE_UI") == Ok("1".to_owned())) {
|
||||
Some(task::spawn(ui::start(
|
||||
Arc::clone(&player),
|
||||
tx.clone(),
|
||||
args.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let player = Arc::new(Player::new().await?);
|
||||
let audio = task::spawn(Player::play(Arc::clone(&player), tx.clone(), rx));
|
||||
tx.send(Messages::Init).await?;
|
||||
// Sends the player an "init" signal telling it to start playing a song straight away.
|
||||
tx.send(Message::Init).await?;
|
||||
|
||||
ui::start(Arc::clone(&player), tx.clone(), alternate).await?;
|
||||
// Actually starts the player.
|
||||
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
|
||||
|
||||
audio.abort();
|
||||
// Save the volume.txt file for the next session.
|
||||
PersistentVolume::save(player.sink.volume())
|
||||
.await
|
||||
.map_err(player::Error::PersistentVolumeSave)?;
|
||||
|
||||
// Save the bookmarks for the next session.
|
||||
player.bookmarks.save().await?;
|
||||
|
||||
drop(stream);
|
||||
player.sink.stop();
|
||||
if let Some(x) = ui {
|
||||
x.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
351
src/player.rs
351
src/player.rs
@ -5,9 +5,10 @@
|
||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use atomic_float::AtomicF32;
|
||||
use downloader::Downloader;
|
||||
use reqwest::Client;
|
||||
use rodio::{OutputStream, OutputStreamHandle, Sink};
|
||||
use rodio::{OutputStream, OutputStreamBuilder, Sink};
|
||||
use tokio::{
|
||||
select,
|
||||
sync::{
|
||||
@ -17,173 +18,297 @@ use tokio::{
|
||||
task,
|
||||
};
|
||||
|
||||
use crate::tracks::{DecodedTrack, Track, TrackInfo};
|
||||
#[cfg(feature = "mpris")]
|
||||
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
|
||||
tracks::{self, list::List},
|
||||
Args,
|
||||
};
|
||||
|
||||
pub mod audio;
|
||||
pub mod bookmark;
|
||||
pub mod downloader;
|
||||
pub mod error;
|
||||
pub mod persistent_volume;
|
||||
pub mod queue;
|
||||
pub mod ui;
|
||||
|
||||
/// Handles communication between the frontend & audio player.
|
||||
pub enum Messages {
|
||||
/// Notifies the audio server that it should update the track.
|
||||
Next,
|
||||
pub use error::Error;
|
||||
|
||||
/// This signal is only sent if a track timed out. In that case,
|
||||
/// lowfi will try again and again to retrieve the track.
|
||||
TryAgain,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
Pause,
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// The amount of songs to buffer up.
|
||||
const BUFFER_SIZE: usize = 5;
|
||||
#[cfg(feature = "mpris")]
|
||||
pub mod mpris;
|
||||
|
||||
/// Main struct responsible for queuing up & playing tracks.
|
||||
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
|
||||
// TODO: In other words, this would change the type from `Arc<Player>` to just `Player`.
|
||||
// TODO:
|
||||
// TODO: This is conflicting, since then it'd clone ~10 smaller [Arc]s
|
||||
// TODO: every single time, which could be even worse than having an
|
||||
// TODO: [Arc] of an [Arc] in some cases (Like with [Sink] & [Client]).
|
||||
pub struct Player {
|
||||
/// [rodio]'s [`Sink`] which can control playback.
|
||||
pub sink: Sink,
|
||||
|
||||
/// The internal buffer size.
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// The [`TrackInfo`] of the current track.
|
||||
/// This is [`None`] when lowfi is buffering.
|
||||
pub current: ArcSwapOption<TrackInfo>,
|
||||
/// This is [`None`] when lowfi is buffering/loading.
|
||||
current: ArcSwapOption<tracks::Info>,
|
||||
|
||||
/// The tracks, which is a [VecDeque] that holds
|
||||
/// The current progress for downloading tracks, if
|
||||
/// `current` is None.
|
||||
progress: AtomicF32,
|
||||
|
||||
/// The tracks, which is a [`VecDeque`] that holds
|
||||
/// *undecoded* [Track]s.
|
||||
tracks: RwLock<VecDeque<Track>>,
|
||||
///
|
||||
/// This is populated specifically by the [Downloader].
|
||||
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
|
||||
|
||||
/// The web client, which can contain a UserAgent & some
|
||||
/// The bookmarks, which are saved on quit.
|
||||
pub bookmarks: Bookmarks,
|
||||
|
||||
/// The timeout for track downloads, as a [Duration].
|
||||
timeout: Duration,
|
||||
|
||||
/// The actual list of tracks to be played.
|
||||
list: List,
|
||||
|
||||
/// The initial volume level.
|
||||
volume: PersistentVolume,
|
||||
|
||||
/// The web client, which can contain a `UserAgent` & some
|
||||
/// settings that help lowfi work more effectively.
|
||||
client: Client,
|
||||
|
||||
/// The [OutputStreamHandle], which also can control some
|
||||
/// playback, is for now unused and is here just to keep it
|
||||
/// alive so the playback can function properly.
|
||||
_handle: OutputStreamHandle,
|
||||
|
||||
/// The [OutputStream], which is just here to keep the playback
|
||||
/// alive and functioning.
|
||||
_stream: OutputStream,
|
||||
}
|
||||
|
||||
/// SAFETY: This is necessary because [OutputStream] does not implement [Send],
|
||||
/// SAFETY: even though it is perfectly possible.
|
||||
unsafe impl Send for Player {}
|
||||
|
||||
/// SAFETY: See implementation for [Send].
|
||||
unsafe impl Sync for Player {}
|
||||
|
||||
impl Player {
|
||||
/// Initializes the entire player, including audio devices & sink.
|
||||
pub async fn new() -> eyre::Result<Self> {
|
||||
let (_stream, handle) = OutputStream::try_default()?;
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
|
||||
Ok(Self {
|
||||
tracks: RwLock::new(VecDeque::with_capacity(5)),
|
||||
current: ArcSwapOption::new(None),
|
||||
client: Client::builder()
|
||||
.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(TIMEOUT)
|
||||
.build()?,
|
||||
sink,
|
||||
_handle: handle,
|
||||
_stream,
|
||||
})
|
||||
}
|
||||
|
||||
/// Just a shorthand for setting `current`.
|
||||
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
|
||||
fn set_current(&self, info: tracks::Info) {
|
||||
self.current.store(Some(Arc::new(info)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This will play the next track, as well as refilling the buffer in the background.
|
||||
pub async fn next(queue: Arc<Self>) -> eyre::Result<DecodedTrack> {
|
||||
let track = match queue.tracks.write().await.pop_front() {
|
||||
Some(x) => x,
|
||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
||||
// This is relevant particularly at the first song.
|
||||
None => Track::random(&queue.client).await?,
|
||||
/// A shorthand for checking if `self.current` is [Some].
|
||||
pub fn current_exists(&self) -> bool {
|
||||
self.current.load().is_some()
|
||||
}
|
||||
|
||||
/// Sets the volume of the sink, and also clamps the value to avoid negative/over 100% values.
|
||||
pub fn set_volume(&self, volume: f32) {
|
||||
self.sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Initializes the entire player, including audio devices & sink.
|
||||
///
|
||||
/// This also will load the track list & persistent volume.
|
||||
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
|
||||
// Load the bookmarks.
|
||||
let bookmarks = Bookmarks::load().await?;
|
||||
|
||||
// Load the volume file.
|
||||
let volume = PersistentVolume::load()
|
||||
.await
|
||||
.map_err(player::Error::PersistentVolumeLoad)?;
|
||||
|
||||
// Load the track list.
|
||||
let list = List::load(args.track_list.as_ref())
|
||||
.await
|
||||
.map_err(player::Error::TrackListLoad)?;
|
||||
|
||||
// We should only shut up alsa forcefully on Linux if we really have to.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut stream = if !args.alternate && !args.debug {
|
||||
audio::silent_get_output_stream()?
|
||||
} else {
|
||||
OutputStreamBuilder::open_default_stream()?
|
||||
};
|
||||
|
||||
let decoded = track.decode()?;
|
||||
queue.set_current(decoded.info.clone()).await?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut stream = OutputStreamBuilder::open_default_stream()?;
|
||||
|
||||
Ok(decoded)
|
||||
stream.log_on_drop(false); // Frankly, this is a stupid feature. Stop shoving your crap into my beloved stderr!!!
|
||||
let sink = Sink::connect_new(stream.mixer());
|
||||
|
||||
if args.paused {
|
||||
sink.pause();
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(Duration::from_secs(args.timeout * 5))
|
||||
.build()?;
|
||||
|
||||
let player = Self {
|
||||
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
||||
buffer_size: args.buffer_size,
|
||||
current: ArcSwapOption::new(None),
|
||||
progress: AtomicF32::new(0.0),
|
||||
timeout: Duration::from_secs(args.timeout),
|
||||
bookmarks,
|
||||
client,
|
||||
sink,
|
||||
volume,
|
||||
list,
|
||||
};
|
||||
|
||||
Ok((player, stream))
|
||||
}
|
||||
|
||||
/// This is the main "audio server".
|
||||
///
|
||||
/// `rx` & `ts` are used to communicate with it, for example when to
|
||||
/// `rx` & `tx` are used to communicate with it, for example when to
|
||||
/// skip tracks or pause.
|
||||
///
|
||||
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
|
||||
/// The [Downloader]s internal buffer size is determined by `buf_size`.
|
||||
pub async fn play(
|
||||
player: Arc<Self>,
|
||||
tx: Sender<Messages>,
|
||||
mut rx: Receiver<Messages>,
|
||||
) -> eyre::Result<()> {
|
||||
tx: Sender<Message>,
|
||||
mut rx: Receiver<Message>,
|
||||
debug: bool,
|
||||
) -> eyre::Result<(), player::Error> {
|
||||
// Initialize the mpris player.
|
||||
//
|
||||
// We're initializing here, despite MPRIS being a "user interface",
|
||||
// since we need to be able to *actively* write new information to MPRIS
|
||||
// specifically when it occurs, unlike the UI which passively reads the
|
||||
// information each frame. Blame MPRIS, not me.
|
||||
#[cfg(feature = "mpris")]
|
||||
let mpris = mpris::Server::new(Arc::clone(&player), tx.clone())
|
||||
.await
|
||||
.inspect_err(|x| {
|
||||
dbg!(x);
|
||||
})?;
|
||||
|
||||
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
||||
let (downloader, itx) = Downloader::new(player.clone());
|
||||
downloader.start().await;
|
||||
let downloader = Downloader::new(Arc::clone(&player));
|
||||
let (itx, downloader) = downloader.start(debug);
|
||||
|
||||
// Start buffering tracks immediately.
|
||||
itx.send(()).await?;
|
||||
Downloader::notify(&itx).await?;
|
||||
|
||||
// Set the initial sink volume to the one specified.
|
||||
player.set_volume(player.volume.float());
|
||||
|
||||
// Whether the last signal was a `NewSong`. This is helpful, since we
|
||||
// only want to autoplay if there hasn't been any manual intervention.
|
||||
//
|
||||
// In other words, this will be `true` after a new track has been fully
|
||||
// loaded and it'll be `false` if a track is still currently loading.
|
||||
let mut new = false;
|
||||
|
||||
loop {
|
||||
let clone = Arc::clone(&player);
|
||||
let msg = select! {
|
||||
Some(x) = rx.recv() => x,
|
||||
|
||||
let msg = select! {
|
||||
biased;
|
||||
|
||||
Some(x) = rx.recv() => x,
|
||||
// This future will finish only at the end of the current track.
|
||||
Ok(_) = task::spawn_blocking(move || clone.sink.sleep_until_end()) => Messages::Next,
|
||||
// The condition is a kind-of hack which gets around the quirks
|
||||
// of `sleep_until_end`.
|
||||
//
|
||||
// That's because `sleep_until_end` will return instantly if the sink
|
||||
// is uninitialized. That's why we put a check to make sure that the last
|
||||
// signal we got was `NewSong`, since we shouldn't start waiting for the
|
||||
// song to be over until it has actually started.
|
||||
//
|
||||
// It's also important to note that the condition is only checked at the
|
||||
// beginning of the loop, not throughout.
|
||||
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
|
||||
if new => Message::Next,
|
||||
};
|
||||
|
||||
match msg {
|
||||
Messages::Next | Messages::Init | Messages::TryAgain => {
|
||||
// Skip as early as possible so that music doesn't play
|
||||
// while lowfi is "loading".
|
||||
player.sink.stop();
|
||||
Message::Next | Message::Init | Message::TryAgain => {
|
||||
// We manually skipped, so we shouldn't actually wait for the song
|
||||
// to be over until we recieve the `NewSong` signal.
|
||||
new = false;
|
||||
|
||||
// Serves as an indicator that the queue is "loading".
|
||||
// This is also set by Player::next.
|
||||
player.current.store(None);
|
||||
// This basically just prevents `Next` while a song is still currently loading.
|
||||
if msg == Message::Next && !player.current_exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let track = Self::next(Arc::clone(&player)).await;
|
||||
|
||||
match track {
|
||||
Ok(track) => {
|
||||
player.sink.append(track.data);
|
||||
|
||||
// Notify the background downloader that there's an empty spot
|
||||
// in the buffer.
|
||||
itx.send(()).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
if !error.downcast::<reqwest::Error>()?.is_timeout() {
|
||||
tokio::time::sleep(TIMEOUT).await;
|
||||
}
|
||||
|
||||
tx.send(Messages::TryAgain).await?
|
||||
}
|
||||
};
|
||||
// Handle the rest of the signal in the background,
|
||||
// as to not block the main audio server thread.
|
||||
task::spawn(Self::next(
|
||||
Arc::clone(&player),
|
||||
itx.clone(),
|
||||
tx.clone(),
|
||||
debug,
|
||||
));
|
||||
}
|
||||
Messages::Pause => {
|
||||
Message::Play => {
|
||||
player.sink.play();
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris.playback(PlaybackStatus::Playing).await?;
|
||||
}
|
||||
Message::Pause => {
|
||||
player.sink.pause();
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris.playback(PlaybackStatus::Paused).await?;
|
||||
}
|
||||
Message::PlayPause => {
|
||||
if player.sink.is_paused() {
|
||||
player.sink.play();
|
||||
} else {
|
||||
player.sink.pause();
|
||||
}
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.playback(mpris.player().playback_status().await?)
|
||||
.await?;
|
||||
}
|
||||
Message::ChangeVolume(change) => {
|
||||
player.set_volume(player.sink.volume() + change);
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.changed(vec![Property::Volume(player.sink.volume().into())])
|
||||
.await?;
|
||||
}
|
||||
// This basically just continues, but more importantly, it'll re-evaluate
|
||||
// the select macro at the beginning of the loop.
|
||||
// See the top section to find out why this matters.
|
||||
Message::NewSong => {
|
||||
// We've recieved `NewSong`, so on the next loop iteration we'll
|
||||
// begin waiting for the song to be over in order to autoplay.
|
||||
new = true;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.changed(vec![
|
||||
Property::Metadata(mpris.player().metadata().await?),
|
||||
Property::PlaybackStatus(mpris.player().playback_status().await?),
|
||||
])
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
Message::Bookmark => {
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref().unwrap();
|
||||
|
||||
player.bookmarks.bookmark(current).await?;
|
||||
}
|
||||
Message::Quit => break,
|
||||
}
|
||||
}
|
||||
|
||||
downloader.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
40
src/player/audio.rs
Normal file
40
src/player/audio.rs
Normal file
@ -0,0 +1,40 @@
|
||||
/// This gets the output stream while also shutting up alsa with [libc].
|
||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::player::Error> {
|
||||
use libc::freopen;
|
||||
use rodio::OutputStreamBuilder;
|
||||
use std::ffi::CString;
|
||||
|
||||
// Get the file descriptor to stderr from libc.
|
||||
extern "C" {
|
||||
static stderr: *mut libc::FILE;
|
||||
}
|
||||
|
||||
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
|
||||
// output to `/dev/null` so that it wont be shoved down our throats.
|
||||
|
||||
// The mode which to redirect terminal output with.
|
||||
let mode = CString::new("w")?;
|
||||
|
||||
// First redirect to /dev/null, which basically silences alsa.
|
||||
let null = CString::new("/dev/null")?;
|
||||
|
||||
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
||||
unsafe {
|
||||
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
||||
}
|
||||
|
||||
// Make the OutputStream while stderr is still redirected to /dev/null.
|
||||
let stream = OutputStreamBuilder::open_default_stream()?;
|
||||
|
||||
// Redirect back to the current terminal, so that other output isn't silenced.
|
||||
let tty = CString::new("/dev/tty")?;
|
||||
|
||||
// SAFETY: See the first call to `freopen`.
|
||||
unsafe {
|
||||
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
}
|
107
src/player/bookmark.rs
Normal file
107
src/player/bookmark.rs
Normal file
@ -0,0 +1,107 @@
|
||||
//! Module for handling saving, loading, and adding
|
||||
//! bookmarks.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::{fs, io};
|
||||
|
||||
use crate::{data_dir, tracks};
|
||||
|
||||
/// Errors that might occur while managing bookmarks.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BookmarkError {
|
||||
#[error("data directory not found")]
|
||||
DataDir,
|
||||
|
||||
#[error("io failure")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Manages the bookmarks in the current player.
|
||||
pub struct Bookmarks {
|
||||
/// The different entries in the bookmarks file.
|
||||
entries: RwLock<Vec<String>>,
|
||||
|
||||
/// The internal bookmarked register, which keeps track
|
||||
/// of whether a track is bookmarked or not.
|
||||
///
|
||||
/// This is much more efficient than checking every single frame.
|
||||
bookmarked: AtomicBool,
|
||||
}
|
||||
|
||||
impl Bookmarks {
|
||||
/// Gets the path of the bookmarks file.
|
||||
pub async fn path() -> eyre::Result<PathBuf, BookmarkError> {
|
||||
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
|
||||
fs::create_dir_all(data_dir.clone()).await?;
|
||||
|
||||
Ok(data_dir.join("bookmarks.txt"))
|
||||
}
|
||||
|
||||
/// Loads bookmarks from the `bookmarks.txt` file.
|
||||
pub async fn load() -> eyre::Result<Self, BookmarkError> {
|
||||
let text = fs::read_to_string(Self::path().await?)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let lines: Vec<String> = text
|
||||
.trim_start_matches("noheader")
|
||||
.trim()
|
||||
.lines()
|
||||
.filter_map(|x| {
|
||||
if x.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(x.to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
entries: RwLock::new(lines),
|
||||
bookmarked: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
// Saves the bookmarks to the `bookmarks.txt` file.
|
||||
pub async fn save(&self) -> eyre::Result<(), BookmarkError> {
|
||||
let text = format!("noheader\n{}", self.entries.read().await.join("\n"));
|
||||
fs::write(Self::path().await?, text).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bookmarks a given track with a full path and optional custom name.
|
||||
///
|
||||
/// Returns whether the track is now bookmarked, or not.
|
||||
pub async fn bookmark(&self, track: &tracks::Info) -> eyre::Result<(), BookmarkError> {
|
||||
let entry = track.to_entry();
|
||||
let idx = self.entries.read().await.iter().position(|x| **x == entry);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
self.entries.write().await.remove(idx);
|
||||
} else {
|
||||
self.entries.write().await.push(entry);
|
||||
};
|
||||
|
||||
self.bookmarked
|
||||
.swap(idx.is_none(), std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether a track is bookmarked or not by using the internal
|
||||
/// bookmarked register.
|
||||
pub fn bookmarked(&self) -> bool {
|
||||
self.bookmarked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Sets the internal bookmarked register by checking against
|
||||
/// the current track's info.
|
||||
pub async fn set_bookmarked(&self, track: &tracks::Info) {
|
||||
let val = self.entries.read().await.contains(&track.to_entry());
|
||||
self.bookmarked
|
||||
.swap(val, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
@ -1,15 +1,14 @@
|
||||
//! Contains the [`Downloader`] struct.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{error::Error, sync::Arc};
|
||||
|
||||
use tokio::{
|
||||
sync::mpsc::{self, Receiver, Sender},
|
||||
task,
|
||||
task::{self, JoinHandle},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use crate::tracks::Track;
|
||||
|
||||
use super::{Player, BUFFER_SIZE};
|
||||
use super::Player;
|
||||
|
||||
/// This struct is responsible for downloading tracks in the background.
|
||||
///
|
||||
@ -21,32 +20,59 @@ pub struct Downloader {
|
||||
/// The internal reciever, which is used by the downloader to know
|
||||
/// when to begin downloading more tracks.
|
||||
rx: Receiver<()>,
|
||||
|
||||
/// A copy of the internal sender, which can be useful for keeping
|
||||
/// track of it.
|
||||
tx: Sender<()>,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
/// Uses a sender recieved from [Sender] to notify the
|
||||
/// download thread that it should resume downloading.
|
||||
pub async fn notify(sender: &Sender<()>) -> Result<(), mpsc::error::SendError<()>> {
|
||||
sender.send(()).await
|
||||
}
|
||||
|
||||
/// Initializes the [Downloader].
|
||||
///
|
||||
/// This also sends a [`Sender`] which can be used to notify
|
||||
/// when the downloader needs to begin downloading more tracks.
|
||||
pub fn new(player: Arc<Player>) -> (Self, Sender<()>) {
|
||||
pub fn new(player: Arc<Player>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
(Self { player, rx }, tx)
|
||||
Self { player, rx, tx }
|
||||
}
|
||||
|
||||
/// Push a new, random track onto the internal buffer.
|
||||
pub async fn push_buffer(&self, debug: bool) {
|
||||
let data = self.player.list.random(&self.player.client, None).await;
|
||||
match data {
|
||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
||||
Err(error) => {
|
||||
if debug {
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
if !error.is_timeout() {
|
||||
sleep(self.player.timeout).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually starts & consumes the [Downloader].
|
||||
pub async fn start(mut self) {
|
||||
task::spawn(async move {
|
||||
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
|
||||
let tx = self.tx.clone();
|
||||
|
||||
let handle = task::spawn(async move {
|
||||
// Loop through each update notification.
|
||||
while self.rx.recv().await == Some(()) {
|
||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
||||
while self.player.tracks.read().await.len() < BUFFER_SIZE {
|
||||
let Ok(track) = Track::random(&self.player.client).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.player.tracks.write().await.push_back(track);
|
||||
while self.player.tracks.read().await.len() < self.player.buffer_size {
|
||||
self.push_buffer(debug).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
}
|
||||
|
51
src/player/error.rs
Normal file
51
src/player/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use std::ffi::NulError;
|
||||
|
||||
use crate::{messages::Message, player::bookmark::BookmarkError};
|
||||
use tokio::sync::mpsc::error::SendError;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
use mpris_server::zbus::{self, fdo};
|
||||
|
||||
/// Any errors which might occur when running or initializing the lowfi player.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("unable to load the persistent volume")]
|
||||
PersistentVolumeLoad(eyre::Error),
|
||||
|
||||
#[error("unable to save the persistent volume")]
|
||||
PersistentVolumeSave(eyre::Error),
|
||||
|
||||
#[error("sending internal message failed")]
|
||||
Communication(#[from] SendError<Message>),
|
||||
|
||||
#[error("unable to load track list")]
|
||||
TrackListLoad(eyre::Error),
|
||||
|
||||
#[error("interfacing with audio failed")]
|
||||
Stream(#[from] rodio::StreamError),
|
||||
|
||||
#[error("NUL error, if you see this, something has gone VERY wrong")]
|
||||
Nul(#[from] NulError),
|
||||
|
||||
#[error("unable to send or prepare network request")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris bus error")]
|
||||
ZBus(#[from] zbus::Error),
|
||||
|
||||
// TODO: This has a terrible error message, mainly because I barely understand
|
||||
// what this error even represents. What does fdo mean?!?!? Why, MPRIS!?!?
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris fdo (zbus interface) error")]
|
||||
Fdo(#[from] fdo::Error),
|
||||
|
||||
#[error("unable to notify downloader")]
|
||||
DownloaderNotify(#[from] SendError<()>),
|
||||
|
||||
#[error("unable to find data directory")]
|
||||
DataDir,
|
||||
|
||||
#[error("bookmarking load/unload failed")]
|
||||
Bookmark(#[from] BookmarkError),
|
||||
}
|
281
src/player/mpris.rs
Normal file
281
src/player/mpris.rs
Normal file
@ -0,0 +1,281 @@
|
||||
//! Contains the code for the MPRIS server & other helper functions.
|
||||
|
||||
use std::{env, process, sync::Arc};
|
||||
|
||||
use mpris_server::{
|
||||
zbus::{self, fdo, Result},
|
||||
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
||||
Time, TrackId, Volume,
|
||||
};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use super::ui;
|
||||
use super::Message;
|
||||
|
||||
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
|
||||
|
||||
/// The actual MPRIS player.
|
||||
pub struct Player {
|
||||
/// A reference to the [`super::Player`] itself.
|
||||
pub player: Arc<super::Player>,
|
||||
|
||||
/// The audio server sender, which is used to communicate with
|
||||
/// the audio sender for skips and a few other inputs.
|
||||
pub sender: Sender<Message>,
|
||||
}
|
||||
|
||||
impl RootInterface for Player {
|
||||
async fn raise(&self) -> fdo::Result<()> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn quit(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Quit)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
}
|
||||
|
||||
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn set_fullscreen(&self, _: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn can_raise(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn has_track_list(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn identity(&self) -> fdo::Result<String> {
|
||||
Ok("lowfi".to_owned())
|
||||
}
|
||||
|
||||
async fn desktop_entry(&self) -> fdo::Result<String> {
|
||||
Ok("dev.talwat.lowfi".to_owned())
|
||||
}
|
||||
|
||||
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec!["https".to_owned()])
|
||||
}
|
||||
|
||||
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec!["audio/mpeg".to_owned()])
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerInterface for Player {
|
||||
async fn next(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Next)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
}
|
||||
|
||||
async fn previous(&self) -> fdo::Result<()> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn pause(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Pause)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
}
|
||||
|
||||
async fn play_pause(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::PlayPause)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
}
|
||||
|
||||
async fn stop(&self) -> fdo::Result<()> {
|
||||
self.pause().await
|
||||
}
|
||||
|
||||
async fn play(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Play)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
}
|
||||
|
||||
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn set_position(&self, _track_id: TrackId, _position: Time) -> fdo::Result<()> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
||||
Ok(if !self.player.current_exists() {
|
||||
PlaybackStatus::Stopped
|
||||
} else if self.player.sink.is_paused() {
|
||||
PlaybackStatus::Paused
|
||||
} else {
|
||||
PlaybackStatus::Playing
|
||||
})
|
||||
}
|
||||
|
||||
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
|
||||
Err(ERROR)
|
||||
}
|
||||
|
||||
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(self.player.sink.speed().into())
|
||||
}
|
||||
|
||||
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
|
||||
self.player.sink.set_speed(rate as f32);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shuffle(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||
let metadata = self
|
||||
.player
|
||||
.current
|
||||
.load()
|
||||
.as_ref()
|
||||
.map_or_else(Metadata::new, |track| {
|
||||
let mut metadata = Metadata::builder()
|
||||
.title(track.display_name.clone())
|
||||
.album(self.player.list.name.clone())
|
||||
.build();
|
||||
|
||||
metadata.set_length(
|
||||
track
|
||||
.duration
|
||||
.map(|x| Time::from_micros(x.as_micros() as i64)),
|
||||
);
|
||||
|
||||
metadata
|
||||
});
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
async fn volume(&self) -> fdo::Result<Volume> {
|
||||
Ok(self.player.sink.volume().into())
|
||||
}
|
||||
|
||||
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||
self.player.set_volume(volume as f32);
|
||||
ui::flash_audio();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn position(&self) -> fdo::Result<Time> {
|
||||
Ok(Time::from_micros(
|
||||
self.player.sink.get_pos().as_micros() as i64
|
||||
))
|
||||
}
|
||||
|
||||
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(0.2f64)
|
||||
}
|
||||
|
||||
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(3.0f64)
|
||||
}
|
||||
|
||||
async fn can_go_next(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn can_go_previous(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn can_play(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn can_pause(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn can_seek(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn can_control(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct which contains the MPRIS [Server], and has some helper functions
|
||||
/// to make it easier to work with.
|
||||
pub struct Server {
|
||||
/// The inner MPRIS server.
|
||||
inner: mpris_server::Server<Player>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
||||
pub async fn changed(
|
||||
&self,
|
||||
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
|
||||
) -> zbus::Result<()> {
|
||||
self.inner.properties_changed(properties).await
|
||||
}
|
||||
|
||||
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
|
||||
pub async fn playback(&self, new: PlaybackStatus) -> zbus::Result<()> {
|
||||
self.inner
|
||||
.properties_changed(vec![Property::PlaybackStatus(new)])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shorthand to get the inner mpris player object.
|
||||
pub fn player(&self) -> &Player {
|
||||
self.inner.imp()
|
||||
}
|
||||
|
||||
/// Creates a new MPRIS server.
|
||||
pub async fn new(
|
||||
player: Arc<super::Player>,
|
||||
sender: Sender<Message>,
|
||||
) -> eyre::Result<Self, zbus::Error> {
|
||||
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
|
||||
String::from("lowfi")
|
||||
} else {
|
||||
format!("lowfi.{}.instance{}", player.list.name, process::id())
|
||||
};
|
||||
|
||||
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
|
||||
|
||||
Ok(Self { inner: server })
|
||||
}
|
||||
}
|
70
src/player/persistent_volume.rs
Normal file
70
src/player/persistent_volume.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use eyre::eyre;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// This is the representation of the persistent volume,
|
||||
/// which is loaded at startup and saved on shutdown.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PersistentVolume {
|
||||
/// The volume, as a percentage.
|
||||
inner: u16,
|
||||
}
|
||||
|
||||
impl PersistentVolume {
|
||||
/// Retrieves the config directory.
|
||||
async fn config() -> eyre::Result<PathBuf> {
|
||||
let config = dirs::config_dir()
|
||||
.ok_or_else(|| eyre!("Couldn't find config directory"))?
|
||||
.join(PathBuf::from("lowfi"));
|
||||
|
||||
if !config.exists() {
|
||||
fs::create_dir_all(&config).await?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Returns the volume as a float from 0 to 1.
|
||||
pub fn float(self) -> f32 {
|
||||
f32::from(self.inner) / 100.0
|
||||
}
|
||||
|
||||
/// Loads the [`PersistentVolume`] from [`dirs::config_dir()`].
|
||||
pub async fn load() -> eyre::Result<Self> {
|
||||
let config = Self::config().await?;
|
||||
let volume = config.join(PathBuf::from("volume.txt"));
|
||||
|
||||
// Basically just read from the volume file if it exists, otherwise return 100.
|
||||
let volume = if volume.exists() {
|
||||
let contents = fs::read_to_string(volume).await?;
|
||||
let trimmed = contents.trim();
|
||||
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
|
||||
stripped
|
||||
.parse()
|
||||
.map_err(|_error| eyre!("volume.txt file is invalid"))?
|
||||
} else {
|
||||
fs::write(&volume, "100").await?;
|
||||
100u16
|
||||
};
|
||||
|
||||
Ok(Self { inner: volume })
|
||||
}
|
||||
|
||||
/// Saves `volume` to `volume.txt`.
|
||||
pub async fn save(volume: f32) -> eyre::Result<()> {
|
||||
let config = Self::config().await?;
|
||||
let path = config.join(PathBuf::from("volume.txt"));
|
||||
|
||||
// Already rounded & absolute, therefore this should be safe.
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation
|
||||
)]
|
||||
let percentage = (volume * 100.0).abs().round() as u16;
|
||||
|
||||
fs::write(path, percentage.to_string()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
88
src/player/queue.rs
Normal file
88
src/player/queue.rs
Normal file
@ -0,0 +1,88 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
use tokio::{sync::mpsc::Sender, time::sleep};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{downloader::Downloader, Player},
|
||||
tracks,
|
||||
};
|
||||
|
||||
impl Player {
|
||||
/// Fetches the next track from the queue, or a random track if the queue is empty.
|
||||
/// This will also set the current track to the fetched track's info.
|
||||
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::Error> {
|
||||
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
||||
let track = self.tracks.write().await.pop_front();
|
||||
let track = if let Some(track) = track {
|
||||
track
|
||||
} else {
|
||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
||||
// This is relevant particularly at the first song.
|
||||
|
||||
// Serves as an indicator that the queue is "loading".
|
||||
// We're doing it here so that we don't get the "loading" display
|
||||
// for only a frame in the other case that the buffer is not empty.
|
||||
self.current.store(None);
|
||||
self.progress.store(0.0, Ordering::Relaxed);
|
||||
self.list.random(&self.client, Some(&self.progress)).await?
|
||||
};
|
||||
|
||||
let decoded = track.decode()?;
|
||||
|
||||
// Set the current track.
|
||||
self.set_current(decoded.info.clone());
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Gets, decodes, and plays the next track in the queue while also handling the downloader.
|
||||
///
|
||||
/// This functions purpose is to be called in the background, so that when the audio server recieves a
|
||||
/// `Next` signal it will still be able to respond to other signals while it's loading.
|
||||
///
|
||||
/// This also sends the either a `NewSong` or `TryAgain` signal to `tx`.
|
||||
pub async fn next(
|
||||
player: Arc<Self>,
|
||||
itx: Sender<()>,
|
||||
tx: Sender<Message>,
|
||||
debug: bool,
|
||||
) -> eyre::Result<()> {
|
||||
// Stop the sink.
|
||||
player.sink.stop();
|
||||
|
||||
let track = player.fetch().await;
|
||||
|
||||
match track {
|
||||
Ok(track) => {
|
||||
// Start playing the new track.
|
||||
player.sink.append(track.data);
|
||||
|
||||
// Set whether it's bookmarked.
|
||||
player.bookmarks.set_bookmarked(&track.info).await;
|
||||
|
||||
// Notify the background downloader that there's an empty spot
|
||||
// in the buffer.
|
||||
Downloader::notify(&itx).await?;
|
||||
|
||||
// Notify the audio server that the next song has actually been downloaded.
|
||||
tx.send(Message::NewSong).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
if debug {
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
if !error.is_timeout() {
|
||||
sleep(player.timeout).await;
|
||||
}
|
||||
|
||||
tx.send(Message::TryAgain).await?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
420
src/player/ui.rs
420
src/player/ui.rs
@ -1,199 +1,307 @@
|
||||
//! The module which manages all user interface, including inputs.
|
||||
|
||||
use std::{io::stderr, sync::Arc, time::Duration};
|
||||
#![allow(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "the ui is full of these because of various layout & positioning aspects, and for a simple music player making all casts safe is not worth the effort"
|
||||
)]
|
||||
|
||||
use crate::tracks::TrackInfo;
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
io::{stdout, Stdout},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::Args;
|
||||
|
||||
use super::Player;
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, RestorePosition, Show},
|
||||
event::{self, KeyCode, KeyModifiers},
|
||||
style::{Print, Stylize},
|
||||
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
|
||||
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
||||
style::{Print, Stylize as _},
|
||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use tokio::{
|
||||
sync::mpsc::Sender,
|
||||
task::{self},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use super::Messages;
|
||||
use lazy_static::lazy_static;
|
||||
use thiserror::Error;
|
||||
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// How long to wait in between frames.
|
||||
/// This is fairly arbitrary, but an ideal value should be enough to feel
|
||||
/// snappy but not require too many resources.
|
||||
const FRAME_DELTA: f32 = 5.0 / 60.0;
|
||||
use super::Player;
|
||||
use crate::messages::Message;
|
||||
|
||||
/// Small helper function to format durations.
|
||||
fn format_duration(duration: &Duration) -> String {
|
||||
let seconds = duration.as_secs() % 60;
|
||||
let minutes = duration.as_secs() / 60;
|
||||
mod components;
|
||||
mod input;
|
||||
|
||||
format!("{:02}:{:02}", minutes, seconds)
|
||||
/// The error type for the UI, which is used to handle errors that occur
|
||||
/// while drawing the UI or handling input.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UIError {
|
||||
#[error("unable to convert number")]
|
||||
Conversion(#[from] std::num::TryFromIntError),
|
||||
|
||||
#[error("unable to write output")]
|
||||
Write(#[from] std::io::Error),
|
||||
|
||||
#[error("sending message to backend from ui failed")]
|
||||
Communication(#[from] tokio::sync::mpsc::error::SendError<Message>),
|
||||
}
|
||||
|
||||
/// This represents the main "action" bars state.
|
||||
enum ActionBar {
|
||||
Paused(TrackInfo),
|
||||
Playing(TrackInfo),
|
||||
Loading,
|
||||
/// How long the audio bar will be visible for when audio is adjusted.
|
||||
/// This is in frames.
|
||||
const AUDIO_BAR_DURATION: usize = 10;
|
||||
|
||||
lazy_static! {
|
||||
/// The volume timer, which controls how long the volume display should
|
||||
/// show up and when it should disappear.
|
||||
///
|
||||
/// When this is 0, it means that the audio bar shouldn't be displayed.
|
||||
/// To make it start counting, you need to set it to 1.
|
||||
static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0);
|
||||
}
|
||||
|
||||
impl ActionBar {
|
||||
/// Formats the action bar to be displayed.
|
||||
/// The second value is the character length of the result.
|
||||
fn format(&self) -> (String, usize) {
|
||||
let (word, subject) = match self {
|
||||
Self::Playing(x) => ("playing", Some(x.name.clone())),
|
||||
Self::Paused(x) => ("paused", Some(x.name.clone())),
|
||||
Self::Loading => ("loading", None),
|
||||
/// Sets the volume timer to one, effectively flashing the audio display in lowfi's UI.
|
||||
///
|
||||
/// The amount of frames the audio display is visible for is determined by [`AUDIO_BAR_DURATION`].
|
||||
pub fn flash_audio() {
|
||||
VOLUME_TIMER.store(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Represents an abstraction for drawing the actual lowfi window itself.
|
||||
///
|
||||
/// The main purpose of this struct is just to add the fancy border,
|
||||
/// as well as clear the screen before drawing.
|
||||
pub struct Window {
|
||||
/// Whether or not to include borders in the output.
|
||||
borderless: bool,
|
||||
|
||||
/// The top & bottom borders, which are here since they can be
|
||||
/// prerendered, as they don't change from window to window.
|
||||
///
|
||||
/// If the option to not include borders is set, these will just be empty [String]s.
|
||||
borders: [String; 2],
|
||||
|
||||
/// The width of the window.
|
||||
width: usize,
|
||||
|
||||
/// The output, currently just an [`Stdout`].
|
||||
out: Stdout,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Initializes a new [Window].
|
||||
///
|
||||
/// * `width` - Width of the windows.
|
||||
/// * `borderless` - Whether to include borders in the window, or not.
|
||||
pub fn new(width: usize, borderless: bool) -> Self {
|
||||
let borders = if borderless {
|
||||
[String::new(), String::new()]
|
||||
} else {
|
||||
let middle = "─".repeat(width + 2);
|
||||
|
||||
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
||||
};
|
||||
|
||||
subject.map_or_else(
|
||||
|| (word.to_owned(), word.len()),
|
||||
|subject| {
|
||||
(
|
||||
format!("{} {}", word, subject.clone().bold()),
|
||||
word.len() + 1 + subject.len(),
|
||||
)
|
||||
},
|
||||
)
|
||||
Self {
|
||||
borders,
|
||||
borderless,
|
||||
width,
|
||||
out: stdout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually draws the window, with each element in `content` being on a new line.
|
||||
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<(), UIError> {
|
||||
let len: u16 = content.len().try_into()?;
|
||||
|
||||
// Note that this will have a trailing newline, which we use later.
|
||||
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
||||
// Horizontal Padding & Border
|
||||
let padding = if self.borderless { " " } else { "│" };
|
||||
let space = if space {
|
||||
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
write!(output, "{padding} {}{space} {padding}\r\n", x.reset()).unwrap();
|
||||
|
||||
output
|
||||
});
|
||||
|
||||
// We're doing this because Windows is stupid and can't stand
|
||||
// writing to the last line repeatedly.
|
||||
#[cfg(windows)]
|
||||
let (height, suffix) = (len + 2, "\r\n");
|
||||
#[cfg(not(windows))]
|
||||
let (height, suffix) = (len + 1, "");
|
||||
|
||||
// There's no need for another newline after the main menu content, because it already has one.
|
||||
let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]);
|
||||
|
||||
crossterm::execute!(
|
||||
self.out,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
MoveToColumn(0),
|
||||
Print(rendered),
|
||||
MoveToColumn(0),
|
||||
MoveUp(height),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The code for the interface itself.
|
||||
async fn interface(queue: Arc<Player>) -> eyre::Result<()> {
|
||||
/// The total width of the UI.
|
||||
const WIDTH: usize = 27;
|
||||
|
||||
/// The width of the progress bar, not including the borders (`[` and `]`) or padding.
|
||||
const PROGRESS_WIDTH: usize = WIDTH - 16;
|
||||
/// The code for the terminal interface itself.
|
||||
///
|
||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||
async fn interface(
|
||||
player: Arc<Player>,
|
||||
minimalist: bool,
|
||||
borderless: bool,
|
||||
debug: bool,
|
||||
fps: u8,
|
||||
width: usize,
|
||||
) -> eyre::Result<(), UIError> {
|
||||
let mut window = Window::new(width, borderless || debug);
|
||||
|
||||
loop {
|
||||
let (mut main, len) = queue
|
||||
.current
|
||||
.load()
|
||||
.as_ref()
|
||||
.map_or(ActionBar::Loading, |x| {
|
||||
let name = (*Arc::clone(x)).clone();
|
||||
if queue.sink.is_paused() {
|
||||
ActionBar::Paused(name)
|
||||
} else {
|
||||
ActionBar::Playing(name)
|
||||
}
|
||||
})
|
||||
.format();
|
||||
// Load `current` once so that it doesn't have to be loaded over and over
|
||||
// again by different UI components.
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref();
|
||||
|
||||
if len > WIDTH {
|
||||
main = format!("{}...", &main[..=WIDTH]);
|
||||
} else {
|
||||
main = format!("{}{}", main, " ".repeat(WIDTH - len));
|
||||
}
|
||||
let action = components::action(&player, current, width);
|
||||
|
||||
let mut duration = Duration::new(0, 0);
|
||||
let elapsed = queue.sink.get_pos();
|
||||
let volume = player.sink.volume();
|
||||
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
||||
|
||||
let mut filled = 0;
|
||||
if let Some(current) = queue.current.load().as_ref() {
|
||||
if let Some(x) = current.duration {
|
||||
duration = x;
|
||||
|
||||
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
||||
filled = (elapsed * PROGRESS_WIDTH as f32).round() as usize;
|
||||
}
|
||||
let timer = VOLUME_TIMER.load(Ordering::Relaxed);
|
||||
let middle = match timer {
|
||||
0 => components::progress_bar(&player, current, width - 16),
|
||||
_ => components::audio_bar(volume, &percentage, width - 17),
|
||||
};
|
||||
|
||||
let progress = format!(
|
||||
" [{}{}] {}/{} ",
|
||||
"/".repeat(filled),
|
||||
" ".repeat(PROGRESS_WIDTH.saturating_sub(filled)),
|
||||
format_duration(&elapsed),
|
||||
format_duration(&duration),
|
||||
);
|
||||
let bar = [
|
||||
format!("{}kip", "[s]".bold()),
|
||||
format!("{}ause", "[p]".bold()),
|
||||
format!("{}uit", "[q]".bold()),
|
||||
];
|
||||
if timer > 0 && timer <= AUDIO_BAR_DURATION {
|
||||
// We'll keep increasing the timer until it eventually hits `AUDIO_BAR_DURATION`.
|
||||
VOLUME_TIMER.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
// If enough time has passed, we'll reset it back to 0.
|
||||
VOLUME_TIMER.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Formats the menu properly
|
||||
let menu = [main, progress, bar.join(" ")]
|
||||
.map(|x| format!("│ {} │\r\n", x.reset()).to_string());
|
||||
let controls = components::controls(width);
|
||||
|
||||
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?;
|
||||
crossterm::execute!(
|
||||
stderr(),
|
||||
MoveToColumn(0),
|
||||
Print(format!("┌{}┐\r\n", "─".repeat(WIDTH + 2))),
|
||||
Print(menu.join("")),
|
||||
Print(format!("└{}┘", "─".repeat(WIDTH + 2))),
|
||||
MoveToColumn(0),
|
||||
MoveUp(4)
|
||||
)?;
|
||||
let menu = match (minimalist, debug, player.current.load().as_ref()) {
|
||||
(true, _, _) => vec![action, middle],
|
||||
(false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
|
||||
_ => vec![action, middle, controls],
|
||||
};
|
||||
|
||||
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
|
||||
window.draw(menu, false)?;
|
||||
|
||||
let delta = 1.0 / f32::from(fps);
|
||||
sleep(Duration::from_secs_f32(delta)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the terminal environment, and is used to properly
|
||||
/// initialize and clean up the terminal.
|
||||
pub struct Environment {
|
||||
/// Whether keyboard enhancements are enabled.
|
||||
enhancement: bool,
|
||||
|
||||
/// Whether the terminal is in an alternate screen or not.
|
||||
alternate: bool,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// This prepares the terminal, returning an [Environment] helpful
|
||||
/// for cleaning up afterwards.
|
||||
pub fn ready(alternate: bool) -> eyre::Result<Self, UIError> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
crossterm::execute!(lock, Hide)?;
|
||||
|
||||
if alternate {
|
||||
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
|
||||
}
|
||||
|
||||
terminal::enable_raw_mode()?;
|
||||
let enhancement = terminal::supports_keyboard_enhancement()?;
|
||||
|
||||
if enhancement {
|
||||
crossterm::execute!(
|
||||
lock,
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enhancement,
|
||||
alternate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Uses the information collected from initialization to safely close down
|
||||
/// the terminal & restore it to it's previous state.
|
||||
pub fn cleanup(&self) -> eyre::Result<(), UIError> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
if self.alternate {
|
||||
crossterm::execute!(lock, LeaveAlternateScreen)?;
|
||||
}
|
||||
|
||||
crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?;
|
||||
|
||||
if self.enhancement {
|
||||
crossterm::execute!(lock, PopKeyboardEnhancementFlags)?;
|
||||
}
|
||||
|
||||
terminal::disable_raw_mode()?;
|
||||
|
||||
eprintln!("bye! :)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Environment {
|
||||
/// Just a wrapper for [`Environment::cleanup`] which ignores any errors thrown.
|
||||
fn drop(&mut self) {
|
||||
// Well, we're dropping it, so it doesn't really matter if there's an error.
|
||||
let _ = self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the UI, this will also start taking input from the user.
|
||||
///
|
||||
/// `alternate` controls whether to use [EnterAlternateScreen] in order to hide
|
||||
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
|
||||
/// previous terminal history.
|
||||
pub async fn start(
|
||||
queue: Arc<Player>,
|
||||
sender: Sender<Messages>,
|
||||
alternate: bool,
|
||||
) -> eyre::Result<()> {
|
||||
crossterm::execute!(
|
||||
stderr(),
|
||||
RestorePosition,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
Hide
|
||||
)?;
|
||||
player: Arc<Player>,
|
||||
sender: Sender<Message>,
|
||||
args: Args,
|
||||
) -> eyre::Result<(), UIError> {
|
||||
let environment = Environment::ready(args.alternate)?;
|
||||
|
||||
if alternate {
|
||||
crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?;
|
||||
}
|
||||
let interface = task::spawn(interface(
|
||||
Arc::clone(&player),
|
||||
args.minimalist,
|
||||
args.borderless,
|
||||
args.debug,
|
||||
args.fps,
|
||||
21 + args.width.min(32) * 2,
|
||||
));
|
||||
|
||||
task::spawn(interface(Arc::clone(&queue)));
|
||||
input::listen(sender.clone()).await?;
|
||||
interface.abort();
|
||||
|
||||
loop {
|
||||
let event::Event::Key(event) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let KeyCode::Char(code) = event.code else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match code {
|
||||
'c' => {
|
||||
// Handles Ctrl+C.
|
||||
if event.modifiers == KeyModifiers::CONTROL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
'q' => {
|
||||
break;
|
||||
}
|
||||
's' => {
|
||||
if !queue.current.load().is_none() {
|
||||
sender.send(Messages::Next).await?
|
||||
}
|
||||
}
|
||||
'p' => {
|
||||
sender.send(Messages::Pause).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if alternate {
|
||||
crossterm::execute!(stderr(), LeaveAlternateScreen)?;
|
||||
}
|
||||
|
||||
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown), Show)?;
|
||||
terminal::disable_raw_mode()?;
|
||||
environment.cleanup()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
152
src/player/ui/components.rs
Normal file
152
src/player/ui/components.rs
Normal file
@ -0,0 +1,152 @@
|
||||
//! Various different individual components that
|
||||
//! appear in lowfi's UI, like the progress bar.
|
||||
|
||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
||||
|
||||
use crossterm::style::Stylize as _;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use crate::{player::Player, tracks::Info};
|
||||
|
||||
/// Small helper function to format durations.
|
||||
pub fn format_duration(duration: &Duration) -> String {
|
||||
let seconds = duration.as_secs() % 60;
|
||||
let minutes = duration.as_secs() / 60;
|
||||
|
||||
format!("{minutes:02}:{seconds:02}")
|
||||
}
|
||||
|
||||
/// Creates the progress bar, as well as all the padding needed.
|
||||
pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
||||
let mut duration = Duration::new(0, 0);
|
||||
let elapsed = if current.is_some() {
|
||||
player.sink.get_pos()
|
||||
} else {
|
||||
Duration::new(0, 0)
|
||||
};
|
||||
|
||||
let mut filled = 0;
|
||||
if let Some(current) = current {
|
||||
if let Some(x) = current.duration {
|
||||
duration = x;
|
||||
|
||||
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
||||
filled = (elapsed * width as f32).round() as usize;
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
" [{}{}] {}/{} ",
|
||||
"/".repeat(filled),
|
||||
" ".repeat(width.saturating_sub(filled)),
|
||||
format_duration(&elapsed),
|
||||
format_duration(&duration),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates the audio bar, as well as all the padding needed.
|
||||
pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
||||
let audio = (volume * width as f32).round() as usize;
|
||||
|
||||
format!(
|
||||
" volume: [{}{}] {}{} ",
|
||||
"/".repeat(audio),
|
||||
" ".repeat(width.saturating_sub(audio)),
|
||||
" ".repeat(4usize.saturating_sub(percentage.len())),
|
||||
percentage,
|
||||
)
|
||||
}
|
||||
|
||||
/// This represents the main "action" bars state.
|
||||
enum ActionBar {
|
||||
/// When the app is paused.
|
||||
Paused(Info),
|
||||
|
||||
/// When the app is playing.
|
||||
Playing(Info),
|
||||
|
||||
/// When the app is loading.
|
||||
Loading(f32),
|
||||
|
||||
/// When the app is muted.
|
||||
Muted,
|
||||
}
|
||||
|
||||
impl ActionBar {
|
||||
/// Formats the action bar to be displayed.
|
||||
/// The second value is the character length of the result.
|
||||
fn format(&self, star: bool) -> (String, usize) {
|
||||
let (word, subject) = match self {
|
||||
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
||||
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
||||
Self::Loading(progress) => {
|
||||
let progress = format!("{: <2.0}%", (progress * 100.0).min(99.0));
|
||||
|
||||
("loading", Some((progress, 3)))
|
||||
}
|
||||
Self::Muted => {
|
||||
let msg = "+ to increase volume";
|
||||
|
||||
("muted,", Some((String::from(msg), msg.len())))
|
||||
}
|
||||
};
|
||||
|
||||
subject.map_or_else(
|
||||
|| (word.to_owned(), word.len()),
|
||||
|(subject, len)| {
|
||||
(
|
||||
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
|
||||
word.len() + 1 + len + usize::from(star),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the top/action bar, which has the name of the track and it's status.
|
||||
/// This also creates all the needed padding.
|
||||
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
||||
let (main, len) = current
|
||||
.map_or_else(
|
||||
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|
||||
|info| {
|
||||
let info = info.deref().clone();
|
||||
|
||||
if player.sink.volume() < 0.01 {
|
||||
return ActionBar::Muted;
|
||||
}
|
||||
|
||||
if player.sink.is_paused() {
|
||||
ActionBar::Paused(info)
|
||||
} else {
|
||||
ActionBar::Playing(info)
|
||||
}
|
||||
},
|
||||
)
|
||||
.format(player.bookmarks.bookmarked());
|
||||
|
||||
if len > width {
|
||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||
|
||||
format!("{chopped}...")
|
||||
} else {
|
||||
format!("{}{}", main, " ".repeat(width - len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the bottom controls bar, and also spaces it properly.
|
||||
pub fn controls(width: usize) -> String {
|
||||
let controls = [["[s]", "kip"], ["[p]", "ause"], ["[q]", "uit"]];
|
||||
|
||||
let len: usize = controls.concat().iter().map(|x| x.len()).sum();
|
||||
let controls = controls.map(|x| format!("{}{}", x[0].bold(), x[1]));
|
||||
|
||||
let mut controls = controls.join(&" ".repeat((width - len) / (controls.len() - 1)));
|
||||
// This is needed because changing the above line
|
||||
// only works for when the width is even
|
||||
controls.push_str(match width % 2 {
|
||||
0 => " ",
|
||||
_ => "",
|
||||
});
|
||||
controls
|
||||
}
|
75
src/player/ui/input.rs
Normal file
75
src/player/ui/input.rs
Normal file
@ -0,0 +1,75 @@
|
||||
//! Responsible for specifically recieving terminal input
|
||||
//! using [`crossterm`].
|
||||
|
||||
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use futures::{FutureExt as _, StreamExt as _};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::player::{
|
||||
ui::{self, UIError},
|
||||
Message,
|
||||
};
|
||||
|
||||
/// Starts the listener to recieve input from the terminal for various events.
|
||||
pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
|
||||
let mut reader = EventStream::new();
|
||||
|
||||
loop {
|
||||
let Some(Ok(event::Event::Key(event))) = reader.next().fuse().await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if event.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
|
||||
let messages = match event.code {
|
||||
// Arrow key volume controls.
|
||||
KeyCode::Up => Message::ChangeVolume(0.1),
|
||||
KeyCode::Right => Message::ChangeVolume(0.01),
|
||||
KeyCode::Down => Message::ChangeVolume(-0.1),
|
||||
KeyCode::Left => Message::ChangeVolume(-0.01),
|
||||
KeyCode::Char(character) => match character.to_ascii_lowercase() {
|
||||
// Ctrl+C
|
||||
'c' if event.modifiers == KeyModifiers::CONTROL => Message::Quit,
|
||||
|
||||
// Quit
|
||||
'q' => Message::Quit,
|
||||
|
||||
// Skip/Next
|
||||
's' | 'n' | 'l' => Message::Next,
|
||||
|
||||
// Pause
|
||||
'p' | ' ' => Message::PlayPause,
|
||||
|
||||
// Volume up & down
|
||||
'+' | '=' | 'k' => Message::ChangeVolume(0.1),
|
||||
'-' | '_' | 'j' => Message::ChangeVolume(-0.1),
|
||||
|
||||
// Bookmark
|
||||
'b' => Message::Bookmark,
|
||||
|
||||
_ => continue,
|
||||
},
|
||||
// Media keys
|
||||
KeyCode::Media(media) => match media {
|
||||
event::MediaKeyCode::Pause
|
||||
| event::MediaKeyCode::Play
|
||||
| event::MediaKeyCode::PlayPause => Message::PlayPause,
|
||||
event::MediaKeyCode::Stop => Message::Pause,
|
||||
event::MediaKeyCode::TrackNext => Message::Next,
|
||||
event::MediaKeyCode::LowerVolume => Message::ChangeVolume(-0.1),
|
||||
event::MediaKeyCode::RaiseVolume => Message::ChangeVolume(0.1),
|
||||
event::MediaKeyCode::MuteVolume => Message::ChangeVolume(-1.0),
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Message::ChangeVolume(_) = messages {
|
||||
ui::flash_audio();
|
||||
}
|
||||
|
||||
sender.send(messages).await?;
|
||||
}
|
||||
}
|
88
src/scrapers.rs
Normal file
88
src/scrapers.rs
Normal file
@ -0,0 +1,88 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use eyre::bail;
|
||||
use reqwest::Client;
|
||||
use tokio::{
|
||||
fs::{self, File},
|
||||
io::AsyncWriteExt,
|
||||
};
|
||||
|
||||
pub mod archive;
|
||||
pub mod chillhop;
|
||||
pub mod lofigirl;
|
||||
|
||||
/// Represents the different sources which can be scraped.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
|
||||
pub enum Source {
|
||||
Lofigirl,
|
||||
Archive,
|
||||
Chillhop,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Gets the cache directory name, for example, `chillhop`.
|
||||
pub fn cache_dir(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Lofigirl => "lofigirl",
|
||||
Source::Archive => "archive",
|
||||
Source::Chillhop => "chillhop",
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the full root URL of the source.
|
||||
pub fn url(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Chillhop => "https://chillhop.com",
|
||||
Source::Archive => "https://ia601004.us.archive.org/31/items/lofigirl",
|
||||
Source::Lofigirl => "https://lofigirl.com/wp-content/uploads",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a get request, with caching.
|
||||
async fn get(client: &Client, path: &str, source: Source) -> eyre::Result<String> {
|
||||
let trimmed = path.trim_matches('/');
|
||||
let cache = PathBuf::from(format!("./cache/{}/{trimmed}.html", source.cache_dir()));
|
||||
|
||||
if let Ok(x) = fs::read_to_string(&cache).await {
|
||||
Ok(x)
|
||||
} else {
|
||||
let resp = client
|
||||
.get(format!("{}/{trimmed}", source.url()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if status == 429 {
|
||||
bail!("rate limit reached: {path}");
|
||||
}
|
||||
|
||||
if status != 404 && !status.is_success() && !status.is_redirection() {
|
||||
bail!("non success code {}: {path}", resp.status().as_u16());
|
||||
}
|
||||
|
||||
let text = resp.text().await?;
|
||||
|
||||
let parent = cache.parent();
|
||||
if let Some(x) = parent {
|
||||
if x != Path::new("") {
|
||||
fs::create_dir_all(x).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = File::create(&cache).await?;
|
||||
file.write_all(text.as_bytes()).await?;
|
||||
|
||||
if status.is_redirection() {
|
||||
bail!("redirect: {path}")
|
||||
}
|
||||
|
||||
if status == 404 {
|
||||
bail!("not found: {path}")
|
||||
}
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
}
|
74
src/scrapers/archive.rs
Normal file
74
src/scrapers/archive.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! Has all of the functions for the `scrape` command.
|
||||
//!
|
||||
//! This command is completely optional, and as such isn't subject to the same
|
||||
//! quality standards as the rest of the codebase.
|
||||
|
||||
use futures::{stream::FuturesOrdered, StreamExt};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
use crate::scrapers::{get, Source};
|
||||
|
||||
lazy_static! {
|
||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
||||
}
|
||||
|
||||
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||
let document = get(client, path, super::Source::Lofigirl).await?;
|
||||
let html = Html::parse_document(&document);
|
||||
|
||||
Ok(html
|
||||
.select(&SELECTOR)
|
||||
.skip(1)
|
||||
.map(|x| String::from(x.attr("href").unwrap()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// This function basically just scans the entire file server, and returns a list of paths to mp3 files.
|
||||
///
|
||||
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
||||
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||
async fn scan() -> eyre::Result<Vec<String>> {
|
||||
let client = Client::new();
|
||||
|
||||
let mut releases = parse(&client, "/").await?;
|
||||
releases.truncate(releases.len() - 4);
|
||||
|
||||
// A little bit of async to run all of the months concurrently.
|
||||
let mut futures = FuturesOrdered::new();
|
||||
|
||||
for release in releases {
|
||||
let client = client.clone();
|
||||
futures.push_back(async move {
|
||||
let items = parse(&client, &release).await.unwrap();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
if x.ends_with(".mp3") {
|
||||
Some(format!("{release}{x}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
while let Some(mut result) = futures.next().await {
|
||||
files.append(&mut result);
|
||||
}
|
||||
|
||||
eyre::Result::Ok(files)
|
||||
}
|
||||
|
||||
pub async fn scrape() -> eyre::Result<()> {
|
||||
println!("{}/", Source::Lofigirl.url());
|
||||
let files = scan().await?;
|
||||
for file in files {
|
||||
println!("{file}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
223
src/scrapers/chillhop.rs
Normal file
223
src/scrapers/chillhop.rs
Normal file
@ -0,0 +1,223 @@
|
||||
use eyre::eyre;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use indicatif::ProgressBar;
|
||||
use lazy_static::lazy_static;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
use serde::{
|
||||
de::{self, Visitor},
|
||||
Deserialize, Deserializer,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::scrapers::{get, Source};
|
||||
|
||||
lazy_static! {
|
||||
static ref RELEASES: Selector = Selector::parse(".table-body > a").unwrap();
|
||||
static ref RELEASE_LABEL: Selector = Selector::parse("label").unwrap();
|
||||
// static ref RELEASE_DATE: Selector = Selector::parse(".release-feat-props > .text-xs").unwrap();
|
||||
// static ref RELEASE_NAME: Selector = Selector::parse(".release-feat-props > h2").unwrap();
|
||||
static ref RELEASE_AUTHOR: Selector = Selector::parse(".release-feat-props .artist-link").unwrap();
|
||||
static ref RELEASE_TEXTAREA: Selector = Selector::parse("textarea").unwrap();
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Track {
|
||||
title: String,
|
||||
#[serde(deserialize_with = "deserialize_u32_from_string")]
|
||||
file_id: u32,
|
||||
artists: String,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn clean(&mut self) {
|
||||
self.artists = html_escape::decode_html_entities(&self.artists).to_string();
|
||||
|
||||
self.title = html_escape::decode_html_entities(&self.title).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Release {
|
||||
#[serde(skip)]
|
||||
pub path: String,
|
||||
|
||||
#[serde(skip)]
|
||||
pub index: usize,
|
||||
|
||||
pub tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum ReleaseError {
|
||||
#[error("invalid release: {0}")]
|
||||
Invalid(#[from] eyre::Error),
|
||||
}
|
||||
|
||||
impl Release {
|
||||
pub async fn scan(
|
||||
path: String,
|
||||
index: usize,
|
||||
client: Client,
|
||||
bar: ProgressBar,
|
||||
) -> Result<Self, ReleaseError> {
|
||||
let content = get(&client, &path, Source::Chillhop).await?;
|
||||
let html = Html::parse_document(&content);
|
||||
|
||||
let textarea = html
|
||||
.select(&RELEASE_TEXTAREA)
|
||||
.next()
|
||||
.ok_or(eyre!("unable to find textarea: {path}"))?;
|
||||
|
||||
let mut release: Self = serde_json::from_str(&textarea.inner_html()).unwrap();
|
||||
release.path = path;
|
||||
release.index = index;
|
||||
release.tracks.reverse();
|
||||
|
||||
bar.inc(release.tracks.len() as u64);
|
||||
|
||||
Ok(release)
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_page(
|
||||
number: usize,
|
||||
client: &Client,
|
||||
bar: ProgressBar,
|
||||
) -> eyre::Result<Vec<impl futures::Future<Output = Result<Release, ReleaseError>>>> {
|
||||
let path = format!("releases/?page={number}");
|
||||
let content = get(client, &path, Source::Chillhop).await?;
|
||||
let html = Html::parse_document(&content);
|
||||
|
||||
let elements = html.select(&RELEASES);
|
||||
Ok(elements
|
||||
.enumerate()
|
||||
.filter_map(|(i, x)| {
|
||||
let label = x.select(&RELEASE_LABEL).next()?.inner_html();
|
||||
if label == "Compilation" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Release::scan(
|
||||
x.attr("href")?.to_string(),
|
||||
(number * 12) + i,
|
||||
client.clone(),
|
||||
bar.clone(),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn scrape() -> eyre::Result<()> {
|
||||
const PAGE_COUNT: usize = 40;
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
|
||||
const TRACK_COUNT: u64 = 1625;
|
||||
|
||||
const IGNORED_TRACKS: [u32; 20] = [
|
||||
// 404
|
||||
74707, // Lyrics
|
||||
21655, 21773, 8172, 55397, 75135, 24827, 8141, 8157, 64052, 31612, 41956, 8001, 9217,
|
||||
55372, // Abnormal
|
||||
8469, 7832, 10448, 9446, 9396,
|
||||
];
|
||||
|
||||
const IGNORED_ARTISTS: [&str; 1] = [
|
||||
"Kenji", // Lyrics
|
||||
];
|
||||
|
||||
fs::create_dir_all("./cache/chillhop").await.unwrap();
|
||||
let client = Client::builder().user_agent(USER_AGENT).build().unwrap();
|
||||
|
||||
let futures = FuturesUnordered::new();
|
||||
let bar = ProgressBar::new(TRACK_COUNT + (12 * (PAGE_COUNT as u64)));
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// This is slightly less memory efficient than I'd hope, but it is what it is.
|
||||
for page in 0..=PAGE_COUNT {
|
||||
bar.inc(12);
|
||||
for x in scan_page(page, &client, bar.clone()).await? {
|
||||
futures.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
let mut results: Vec<Result<Release, ReleaseError>> = futures.collect().await;
|
||||
bar.finish_and_clear();
|
||||
|
||||
// I mean, is it... optimal? Absolutely not. Does it work? Yes.
|
||||
eprintln!("sorting...");
|
||||
results.sort_by_key(|x| if let Ok(x) = x { x.index } else { 0 });
|
||||
results.reverse();
|
||||
|
||||
eprintln!("printing...");
|
||||
let mut printed = Vec::with_capacity(TRACK_COUNT as usize); // Lazy way to get rid of dupes.
|
||||
for result in results {
|
||||
let release = match result {
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for mut track in release.tracks {
|
||||
if IGNORED_TRACKS.contains(&track.file_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if IGNORED_ARTISTS.contains(&track.artists.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if printed.contains(&track.file_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
printed.push(track.file_id);
|
||||
|
||||
track.clean();
|
||||
println!("{}!{}", track.file_id, track.title);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("-- ERROR REPORT --");
|
||||
for error in errors {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deserialize_u32_from_string<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct U32FromStringVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for U32FromStringVisitor {
|
||||
type Value = u32;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a string containing an unsigned 32-bit integer")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
u32::from_str(value).map_err(|_| {
|
||||
de::Error::invalid_value(
|
||||
de::Unexpected::Str(value),
|
||||
&"a valid unsigned 32-bit integer",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(U32FromStringVisitor)
|
||||
}
|
@ -1,20 +1,23 @@
|
||||
//! Has all of the functions for the `scrape` command.
|
||||
//!
|
||||
//! This command is completely optional, and as such isn't subject to the same
|
||||
//! quality standards as the rest of the codebase.
|
||||
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use futures::{stream::FuturesOrdered, StreamExt};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
const BASE_URL: &str = "https://lofigirl.com/wp-content/uploads/";
|
||||
use crate::scrapers::get;
|
||||
|
||||
lazy_static! {
|
||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
||||
}
|
||||
|
||||
async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
||||
let response = reqwest::get(format!("{}{}", BASE_URL, path)).await?;
|
||||
let document = response.text().await?;
|
||||
|
||||
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||
let document = get(client, path, super::Source::Lofigirl).await?;
|
||||
let html = Html::parse_document(&document);
|
||||
|
||||
Ok(html
|
||||
.select(&SELECTOR)
|
||||
.skip(5)
|
||||
@ -26,12 +29,11 @@ async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
||||
///
|
||||
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
||||
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||
async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>> {
|
||||
let extension = &format!(".{}", extension);
|
||||
async fn scan() -> eyre::Result<Vec<String>> {
|
||||
let client = Client::new();
|
||||
let items = parse(&client, "/").await?;
|
||||
|
||||
let items = parse("").await?;
|
||||
|
||||
let years: Vec<u32> = items
|
||||
let mut years: Vec<u32> = items
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
let year = x.strip_suffix("/")?;
|
||||
@ -39,26 +41,25 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
|
||||
})
|
||||
.collect();
|
||||
|
||||
years.sort();
|
||||
|
||||
// A little bit of async to run all of the months concurrently.
|
||||
let mut futures = FuturesUnordered::new();
|
||||
let mut futures = FuturesOrdered::new();
|
||||
|
||||
for year in years {
|
||||
let months = parse(&year.to_string()).await?;
|
||||
let months = parse(&client, &year.to_string()).await?;
|
||||
|
||||
for month in months {
|
||||
futures.push(async move {
|
||||
let client = client.clone();
|
||||
futures.push_back(async move {
|
||||
let path = format!("{}/{}", year, month);
|
||||
|
||||
let items = parse(&path).await.unwrap();
|
||||
let items = parse(&client, &path).await.unwrap();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
if x.ends_with(extension) {
|
||||
if include_full {
|
||||
Some(format!("{BASE_URL}{path}{x}"))
|
||||
} else {
|
||||
Some(format!("{path}{x}"))
|
||||
}
|
||||
if x.ends_with(".mp3") {
|
||||
Some(format!("{path}{x}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -76,10 +77,10 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
|
||||
eyre::Result::Ok(files)
|
||||
}
|
||||
|
||||
pub async fn scrape(extension: String, include_full: bool) -> eyre::Result<()> {
|
||||
let files = scan(&extension, include_full).await?;
|
||||
pub async fn scrape() -> eyre::Result<()> {
|
||||
let files = scan().await?;
|
||||
for file in files {
|
||||
println!("{}", file);
|
||||
println!("{file}");
|
||||
}
|
||||
|
||||
Ok(())
|
281
src/tracks.rs
281
src/tracks.rs
@ -1,93 +1,218 @@
|
||||
//! Has all of the structs for managing the state
|
||||
//! of tracks, as well as downloading them &
|
||||
//! finding new ones.
|
||||
//! of tracks, as well as downloading them & finding new ones.
|
||||
//!
|
||||
//! There are several structs which represent the different stages
|
||||
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
|
||||
//! and what structs are relevant in each step, are as follows.
|
||||
//!
|
||||
//! First Stage, when a track is initially fetched.
|
||||
//! 1. Raw entry selected from track list.
|
||||
//! 2. Raw entry split into path & display name.
|
||||
//! 3. Track data fetched, and [`QueuedTrack`] is created which includes a [`TrackName`] that may be raw.
|
||||
//!
|
||||
//! Second Stage, when a track is played.
|
||||
//! 1. Track data is decoded.
|
||||
//! 2. [`Info`] created from decoded data.
|
||||
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
|
||||
|
||||
use std::{io::Cursor, time::Duration};
|
||||
use std::{io::Cursor, path::Path, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use inflector::Inflector;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use rodio::{Decoder, Source};
|
||||
use convert_case::{Case, Casing};
|
||||
use regex::Regex;
|
||||
use rodio::{Decoder, Source as _};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use url::form_urlencoded;
|
||||
|
||||
/// Downloads a raw track, but doesn't decode it.
|
||||
async fn download(track: &str, client: &Client) -> eyre::Result<Bytes> {
|
||||
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
|
||||
let response = client.get(url).send().await?;
|
||||
let data = response.bytes().await?;
|
||||
pub mod error;
|
||||
pub mod list;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
pub use error::Error;
|
||||
|
||||
/// Gets a random track from `tracks.txt` and returns it.
|
||||
fn random() -> &'static str {
|
||||
let tracks: Vec<&str> = include_str!("../data/tracks.txt")
|
||||
.split_ascii_whitespace()
|
||||
.collect();
|
||||
|
||||
let random = rand::thread_rng().gen_range(0..tracks.len());
|
||||
tracks[random]
|
||||
}
|
||||
use crate::tracks::error::Context;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
/// Just a shorthand for a decoded [Bytes].
|
||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||
|
||||
/// The TrackInfo struct, which has the name and duration of a track.
|
||||
/// Specifies a track's name, and specifically,
|
||||
/// whether it has already been formatted or if it
|
||||
/// is still in it's raw path form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrackName {
|
||||
/// Pulled straight from the list,
|
||||
/// with no splitting done at all.
|
||||
Raw(String),
|
||||
|
||||
/// If a track has a custom specified name
|
||||
/// in the list, then it should be defined with this variant.
|
||||
Formatted(String),
|
||||
}
|
||||
|
||||
/// Tracks which are still waiting in the queue, and can't be played yet.
|
||||
///
|
||||
/// This means that only the data & track name are included.
|
||||
pub struct QueuedTrack {
|
||||
/// Name of the track, which may be raw.
|
||||
pub name: TrackName,
|
||||
|
||||
/// Full downloadable path/url of the track.
|
||||
pub full_path: String,
|
||||
|
||||
/// The raw data of the track, which is not decoded and
|
||||
/// therefore much more memory efficient.
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
impl QueuedTrack {
|
||||
/// This will actually decode and format the track,
|
||||
/// returning a [`DecodedTrack`] which can be played
|
||||
/// and also has a duration & formatted name.
|
||||
pub fn decode(self) -> eyre::Result<DecodedTrack, Error> {
|
||||
DecodedTrack::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Info`] struct, which has the name and duration of a track.
|
||||
///
|
||||
/// This is not included in [Track] as the duration has to be acquired
|
||||
/// from the decoded data and not from the raw data.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct TrackInfo {
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub struct Info {
|
||||
/// The full downloadable path/url of the track.
|
||||
pub full_path: String,
|
||||
|
||||
/// Whether the track entry included a custom name, or not.
|
||||
pub custom_name: bool,
|
||||
|
||||
/// This is a formatted name, so it doesn't include the full path.
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
|
||||
/// This is the *actual* terminal width of the track name, used to make
|
||||
/// the UI consistent.
|
||||
pub width: usize,
|
||||
|
||||
/// The duration of the track, this is an [Option] because there are
|
||||
/// cases where the duration of a track is unknown.
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
impl TrackInfo {
|
||||
/// Formats a name with [Inflector].
|
||||
/// This will also strip the first few numbers that are
|
||||
/// usually present on most lofi tracks.
|
||||
fn format_name(name: &'static str) -> String {
|
||||
let mut formatted = name
|
||||
.split("/")
|
||||
.nth(2)
|
||||
.unwrap()
|
||||
.strip_suffix(".mp3")
|
||||
.unwrap()
|
||||
.to_title_case()
|
||||
// Inflector doesn't like contractions...
|
||||
// Replaces a few very common ones.
|
||||
// TODO: Properly handle these.
|
||||
.replace(" S ", "'s ")
|
||||
.replace(" T ", "'t ")
|
||||
.replace(" D ", "'d ")
|
||||
.replace(" Ve ", "'ve ")
|
||||
.replace(" M ", "'m ");
|
||||
lazy_static! {
|
||||
static ref MASTER_PATTERNS: [Regex; 5] = [
|
||||
// (master), (master v2)
|
||||
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(),
|
||||
// mstr or - mstr or (mstr) — now also matches "mstr v3", "mstr2", etc.
|
||||
Regex::new(r"\s*[-(]?\s*mstr(?:\s*v?\d+)?\s*\)?$").unwrap(),
|
||||
// - master, master at end without parentheses
|
||||
Regex::new(r"\s*[-]?\s*master(?:\s*v?\d+)?$").unwrap(),
|
||||
// kupla master1, kupla master v2 (without parentheses or separator)
|
||||
Regex::new(r"\s+kupla\s+master(?:\s*v?\d+|\d+)?$").unwrap(),
|
||||
// (kupla master) followed by trailing parenthetical numbers, e.g. "... (kupla master) (1)"
|
||||
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)(?:\s*\(\d+\))+$").unwrap(),
|
||||
];
|
||||
static ref ID_PATTERN: Regex = Regex::new(r"^[a-z]\d[ .]").unwrap();
|
||||
}
|
||||
|
||||
impl Info {
|
||||
/// Converts the info back into a full track list entry.
|
||||
pub fn to_entry(&self) -> String {
|
||||
let mut entry = self.full_path.clone();
|
||||
|
||||
if self.custom_name {
|
||||
entry.push('!');
|
||||
entry.push_str(&self.display_name);
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
/// Decodes a URL string into normal UTF-8.
|
||||
fn decode_url(text: &str) -> String {
|
||||
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
||||
#[allow(clippy::tuple_array_conversions)]
|
||||
form_urlencoded::parse(text.as_bytes())
|
||||
.map(|(key, val)| [key, val].concat())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Formats a name with [`convert_case`].
|
||||
///
|
||||
/// This will also strip the first few numbers that are
|
||||
/// usually present on most lofi tracks and do some other
|
||||
/// formatting operations.
|
||||
fn format_name(name: &str) -> eyre::Result<String, Error> {
|
||||
let path = Path::new(name);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or((name, error::Kind::InvalidName))?;
|
||||
|
||||
let name = Self::decode_url(name).to_lowercase();
|
||||
let mut name = name
|
||||
.replace("masster", "master")
|
||||
.replace("(online-audio-converter.com)", "") // Some of these names, man...
|
||||
.replace('_', " ");
|
||||
|
||||
// Get rid of "master" suffix with a few regex patterns.
|
||||
for regex in MASTER_PATTERNS.iter() {
|
||||
name = regex.replace(&name, "").to_string();
|
||||
}
|
||||
|
||||
name = ID_PATTERN.replace(&name, "").to_string();
|
||||
|
||||
let name = name
|
||||
.replace("13lufs", "")
|
||||
.to_case(Case::Title)
|
||||
.replace(" .", "")
|
||||
.replace(" Ft ", " ft. ")
|
||||
.replace("Ft.", "ft.")
|
||||
.replace("Feat.", "ft.")
|
||||
.replace(" W ", " w/ ");
|
||||
|
||||
// This is incremented for each digit in front of the song name.
|
||||
let mut skip = 0;
|
||||
|
||||
// SAFETY: All of the track names originate with the `'static` lifetime,
|
||||
// SAFETY: so basically this has already been checked.
|
||||
for character in unsafe { formatted.as_bytes_mut() } {
|
||||
if character.is_ascii_digit() {
|
||||
for character in name.as_bytes() {
|
||||
if character.is_ascii_digit()
|
||||
|| *character == b'.'
|
||||
|| *character == b')'
|
||||
|| *character == b'('
|
||||
{
|
||||
skip += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String::from(&formatted[skip..])
|
||||
// If the entire name of the track is a number, then just return it.
|
||||
if skip == name.len() {
|
||||
Ok(name.trim().to_string())
|
||||
} else {
|
||||
// We've already checked before that the bound is at an ASCII digit.
|
||||
#[allow(clippy::string_slice)]
|
||||
Ok(String::from(name[skip..].trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
|
||||
pub fn new(name: &'static str, decoded: &DecodedData) -> Self {
|
||||
Self {
|
||||
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
|
||||
pub fn new(
|
||||
name: TrackName,
|
||||
full_path: String,
|
||||
decoded: &DecodedData,
|
||||
) -> eyre::Result<Self, Error> {
|
||||
let (display_name, custom_name) = match name {
|
||||
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||
TrackName::Formatted(custom) => (custom, true),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
duration: decoded.total_duration(),
|
||||
name: Self::format_name(name),
|
||||
}
|
||||
width: display_name.graphemes(true).count(),
|
||||
full_path,
|
||||
custom_name,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,7 +220,7 @@ impl TrackInfo {
|
||||
/// a track, and not when the track is first downloaded.
|
||||
pub struct DecodedTrack {
|
||||
/// Has both the formatted name and some information from the decoded data.
|
||||
pub info: TrackInfo,
|
||||
pub info: Info,
|
||||
|
||||
/// The decoded data, which is able to be played by [rodio].
|
||||
pub data: DecodedData,
|
||||
@ -103,38 +228,16 @@ pub struct DecodedTrack {
|
||||
|
||||
impl DecodedTrack {
|
||||
/// Creates a new track.
|
||||
/// This is equivalent to [Track::decode].
|
||||
pub fn new(track: Track) -> eyre::Result<Self> {
|
||||
let data = Decoder::new(Cursor::new(track.data))?;
|
||||
let info = TrackInfo::new(track.name, &data);
|
||||
/// This is equivalent to [`QueuedTrack::decode`].
|
||||
pub fn new(track: QueuedTrack) -> eyre::Result<Self, Error> {
|
||||
let data = Decoder::builder()
|
||||
.with_byte_len(track.data.len().try_into().unwrap())
|
||||
.with_data(Cursor::new(track.data))
|
||||
.build()
|
||||
.track(track.full_path.clone())?;
|
||||
|
||||
let info = Info::new(track.name, track.full_path, &data)?;
|
||||
|
||||
Ok(Self { info, data })
|
||||
}
|
||||
}
|
||||
|
||||
/// The main track struct, which only includes data & the track name.
|
||||
pub struct Track {
|
||||
/// This name is not formatted, and also includes the month & year of the track.
|
||||
pub name: &'static str,
|
||||
|
||||
/// The raw data of the track, which is not decoded and
|
||||
/// therefore much more memory efficient.
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// Fetches and downloads a random track from the tracklist.
|
||||
pub async fn random(client: &Client) -> eyre::Result<Self> {
|
||||
let name = random();
|
||||
let data = download(name, client).await?;
|
||||
|
||||
Ok(Self { data, name })
|
||||
}
|
||||
|
||||
/// This will actually decode and format the track,
|
||||
/// returning a [`DecodedTrack`] which can be played
|
||||
/// and also has a duration & formatted name.
|
||||
pub fn decode(self) -> eyre::Result<DecodedTrack> {
|
||||
DecodedTrack::new(self)
|
||||
}
|
||||
}
|
||||
|
73
src/tracks/error.rs
Normal file
73
src/tracks/error.rs
Normal file
@ -0,0 +1,73 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Kind {
|
||||
#[error("unable to decode: {0}")]
|
||||
Decode(#[from] rodio::decoder::DecoderError),
|
||||
|
||||
#[error("invalid name")]
|
||||
InvalidName,
|
||||
|
||||
#[error("invalid file path")]
|
||||
InvalidPath,
|
||||
|
||||
#[error("unknown target track length")]
|
||||
UnknownLength,
|
||||
|
||||
#[error("unable to read file: {0}")]
|
||||
File(#[from] std::io::Error),
|
||||
|
||||
#[error("unable to fetch data: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{kind} (track: {track})")]
|
||||
pub struct Error {
|
||||
pub track: String,
|
||||
|
||||
#[source]
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn is_timeout(&self) -> bool {
|
||||
if let Kind::Request(x) = &self.kind {
|
||||
x.is_timeout()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E> From<(T, E)> for Error
|
||||
where
|
||||
T: Into<String>,
|
||||
Kind: From<E>,
|
||||
{
|
||||
fn from((track, err): (T, E)) -> Self {
|
||||
Self {
|
||||
track: track.into(),
|
||||
kind: Kind::from(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Context<T> {
|
||||
fn track(self, name: impl Into<String>) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
impl<T, E> Context<T> for Result<T, E>
|
||||
where
|
||||
(String, E): Into<Error>,
|
||||
E: Into<Kind>,
|
||||
{
|
||||
fn track(self, name: impl Into<String>) -> Result<T, Error> {
|
||||
self.map_err(|e| {
|
||||
let error = match e.into() {
|
||||
Kind::Request(e) => Kind::Request(e.without_url()),
|
||||
e => e,
|
||||
};
|
||||
|
||||
(name.into(), error).into()
|
||||
})
|
||||
}
|
||||
}
|
189
src/tracks/list.rs
Normal file
189
src/tracks/list.rs
Normal file
@ -0,0 +1,189 @@
|
||||
//! The module containing all of the logic behind track lists,
|
||||
//! as well as obtaining track names & downloading the raw audio data
|
||||
|
||||
use std::{cmp::min, sync::atomic::Ordering};
|
||||
|
||||
use atomic_float::AtomicF32;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use eyre::OptionExt as _;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
data_dir,
|
||||
tracks::{self, error::Context},
|
||||
};
|
||||
|
||||
use super::QueuedTrack;
|
||||
|
||||
/// Represents a list of tracks that can be played.
|
||||
///
|
||||
/// See the [README](https://github.com/talwat/lowfi?tab=readme-ov-file#the-format) for more details about the format.
|
||||
#[derive(Clone)]
|
||||
pub struct List {
|
||||
/// The "name" of the list, usually derived from a filename.
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
|
||||
/// Just the raw file, but seperated by `/n` (newlines).
|
||||
/// `lines[0]` is the base/heaeder, with the rest being tracks.
|
||||
lines: Vec<String>,
|
||||
|
||||
/// The file path which the list was read from.
|
||||
#[allow(dead_code)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
impl List {
|
||||
/// Gets the base URL of the [List].
|
||||
pub fn base(&self) -> &str {
|
||||
self.lines[0].trim()
|
||||
}
|
||||
|
||||
/// Gets the path of a random track.
|
||||
///
|
||||
/// The second value in the tuple specifies whether the
|
||||
/// track has a custom display name.
|
||||
fn random_path(&self) -> (String, Option<String>) {
|
||||
// We're getting from 1 here, since the base is at `self.lines[0]`.
|
||||
//
|
||||
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
|
||||
// how rust vectors work, since it is slower to drain only a single element from
|
||||
// the start, so it's faster to just keep it in & work around it.
|
||||
let random = fastrand::usize(1..self.lines.len());
|
||||
let line = self.lines[random].clone();
|
||||
|
||||
if let Some((first, second)) = line.split_once('!') {
|
||||
(first.to_owned(), Some(second.to_owned()))
|
||||
} else {
|
||||
(line, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads a raw track, but doesn't decode it.
|
||||
async fn download(
|
||||
&self,
|
||||
track: &str,
|
||||
client: &Client,
|
||||
progress: Option<&AtomicF32>,
|
||||
) -> Result<(Bytes, String), tracks::Error> {
|
||||
// If the track has a protocol, then we should ignore the base for it.
|
||||
let full_path = if track.contains("://") {
|
||||
track.to_owned()
|
||||
} else {
|
||||
format!("{}{}", self.base(), track)
|
||||
};
|
||||
|
||||
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
|
||||
let path = if x.starts_with('~') {
|
||||
let home_path =
|
||||
dirs::home_dir().ok_or((track, tracks::error::Kind::InvalidPath))?;
|
||||
let home = home_path
|
||||
.to_str()
|
||||
.ok_or((track, tracks::error::Kind::InvalidPath))?;
|
||||
|
||||
x.replace('~', home)
|
||||
} else {
|
||||
x.to_owned()
|
||||
};
|
||||
|
||||
let result = tokio::fs::read(path.clone()).await.track(track)?;
|
||||
result.into()
|
||||
} else {
|
||||
let response = client.get(full_path.clone()).send().await.track(track)?;
|
||||
|
||||
if let Some(progress) = progress {
|
||||
let total = response
|
||||
.content_length()
|
||||
.ok_or((track, tracks::error::Kind::UnknownLength))?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = BytesMut::new();
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.track(track)?;
|
||||
let new = min(downloaded + (chunk.len() as u64), total);
|
||||
downloaded = new;
|
||||
progress.store((new as f32) / (total as f32), Ordering::Relaxed);
|
||||
|
||||
bytes.put(chunk);
|
||||
}
|
||||
|
||||
bytes.into()
|
||||
} else {
|
||||
response.bytes().await.track(track)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok((data, full_path))
|
||||
}
|
||||
|
||||
/// Fetches and downloads a random track from the [List].
|
||||
///
|
||||
/// The Result's error is a bool, which is true if a timeout error occured,
|
||||
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
|
||||
pub async fn random(
|
||||
&self,
|
||||
client: &Client,
|
||||
progress: Option<&AtomicF32>,
|
||||
) -> Result<QueuedTrack, tracks::Error> {
|
||||
let (path, custom_name) = self.random_path();
|
||||
let (data, full_path) = self.download(&path, client, progress).await?;
|
||||
|
||||
let name = custom_name.map_or_else(
|
||||
|| super::TrackName::Raw(path.clone()),
|
||||
super::TrackName::Formatted,
|
||||
);
|
||||
|
||||
Ok(QueuedTrack {
|
||||
name,
|
||||
full_path,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses text into a [List].
|
||||
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
|
||||
let lines: Vec<String> = text
|
||||
.trim_end()
|
||||
.lines()
|
||||
.map(|x| x.trim_end().to_owned())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
lines,
|
||||
path: path.map(ToOwned::to_owned),
|
||||
name: name.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a [List] from the filesystem using the CLI argument provided.
|
||||
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
|
||||
if let Some(arg) = tracks {
|
||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
||||
let path = data_dir()?.join(format!("{arg}.txt"));
|
||||
let path = if path.exists() { path } else { arg.into() };
|
||||
|
||||
let raw = fs::read_to_string(path.clone()).await?;
|
||||
|
||||
// Get rid of special noheader case for tracklists without a header.
|
||||
let raw = raw
|
||||
.strip_prefix("noheader")
|
||||
.map_or(raw.as_ref(), |stripped| stripped);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or_eyre("invalid track path")?;
|
||||
|
||||
Ok(Self::new(name, raw, path.to_str()))
|
||||
} else {
|
||||
Ok(Self::new(
|
||||
"chillhop",
|
||||
include_str!("../../data/chillhop.txt"),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user