mirror of
https://github.com/talwat/lowfi
synced 2025-09-29 20:00:03 +00:00
Compare commits
23 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
||||
/target
|
||||
/cache
|
||||
.DS_Store
|
11
CHILLHOP.md
11
CHILLHOP.md
@ -1,5 +1,10 @@
|
||||
# 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
|
||||
@ -10,8 +15,8 @@ curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillho
|
||||
## MacOS
|
||||
|
||||
```sh
|
||||
mkdir -p "~/Library/Application Support/lowfi"
|
||||
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir "~/Library/Application Support/lowfi"
|
||||
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
|
||||
@ -21,4 +26,4 @@ Then just put [this file](https://raw.githubusercontent.com/talwat/lowfi/refs/he
|
||||
|
||||
## Launching lowfi
|
||||
|
||||
Once the list has been added, just launch `lowfi` with `-t chillhop`.
|
||||
Once the list has been added, just launch `lowfi` with `-t chillhop`.
|
||||
|
598
Cargo.lock
generated
598
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
36
Cargo.toml
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lowfi"
|
||||
version = "1.7.0-dev"
|
||||
version = "1.7.2"
|
||||
edition = "2021"
|
||||
description = "An extremely simple lofi player."
|
||||
license = "MIT"
|
||||
@ -19,38 +19,46 @@ 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.21", features = ["derive", "cargo"] }
|
||||
eyre = { version = "0.6.12" }
|
||||
rand = "0.8.5"
|
||||
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.41.1", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"fs"
|
||||
], default-features = false }
|
||||
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.9"
|
||||
reqwest = { version = "0.12.9", features = ["stream"] }
|
||||
bytes = "1.9.0"
|
||||
|
||||
# I/O
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
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 = "5.0.1"
|
||||
dirs = "6.0.0"
|
||||
|
||||
# Misc
|
||||
scraper = "0.21.0"
|
||||
Inflector = "0.11.4"
|
||||
convert_case = "0.8.0"
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.167"
|
||||
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"
|
||||
|
||||
|
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.
|
143
README.md
143
README.md
@ -7,30 +7,18 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> As of the 19th of July 2025, lofigirl.com is temporarily down. If your lowfi is up to date,
|
||||
> you can follow the [quick instructions](CHILLHOP.md) for using the [chillhop](https://chillhop.com/) alternative track list.
|
||||
>
|
||||
> Apologies for the inconvenience, it's out of lowfi's control.
|
||||
|
||||
**All** of the audio files embedded into in lowfi by default 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 lowfi 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 just play random lofi without video.
|
||||
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.
|
||||
|
||||
It was also designed to be fairly resilient to inconsistent networks,
|
||||
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.
|
||||
|
||||
See [Scraping](#scraping) if you're interested in downloading the tracks.
|
||||
Beware, there's a lot of them.
|
||||
|
||||
## Installing
|
||||
|
||||
> [!NOTE]
|
||||
@ -46,8 +34,8 @@ 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.
|
||||
- `openssl` on Arch, `libssl-dev` on Ubuntu.
|
||||
- `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.
|
||||
|
||||
@ -88,7 +76,7 @@ zypper install lowfi
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
@ -135,12 +123,13 @@ Yeah, that's it.
|
||||
| `-`, `_`, `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 optional feature in cargo (enabled with `--features mpris`)
|
||||
> 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.
|
||||
|
||||
@ -149,69 +138,87 @@ Yeah, that's it.
|
||||
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`.
|
||||
|
||||
| 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 |
|
||||
| `-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] |
|
||||
| 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] |
|
||||
|
||||
### Scraping
|
||||
### Extra Features
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
An example of scrape is as follows,
|
||||
#### `scrape` - Scraping
|
||||
|
||||
`lowfi scrape --extension zip --include-full`
|
||||
This feature provides the `scrape` command.
|
||||
It's usually not very useful, but is included for transparency's sake.
|
||||
|
||||
where more information can be found by running `lowfi help scrape`.
|
||||
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
|
||||
|
||||
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 `--tracks` flag.
|
||||
|
||||
Feel free to contribute your own list with a PR.
|
||||
|
||||
> [!WARNING]
|
||||
> [!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.
|
||||
>
|
||||
> Custom track lists are going to be pretty particular.
|
||||
> This is because I still want to keep `lowfi` as simple as possible,
|
||||
> so custom lists will be very similar to how the built in list functions.
|
||||
>
|
||||
> This also means that there will be no added flexibility to these lists,
|
||||
> so you'll have to work that out on your own.
|
||||
> Feel free to contribute your own list with a PR.
|
||||
|
||||
lowfi also supports custom track lists, although the default one from Lofi Girl
|
||||
lowfi also supports custom track lists, although the default one from chillhop
|
||||
is embedded into the binary.
|
||||
|
||||
To use a custom list, use the `--tracks` flag. This can either be a path to some file,
|
||||
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, so on Linux it's `~/.local/share/lowfi`.
|
||||
directory.
|
||||
|
||||
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||
Whereas if you did `lowfi --tracks ~/Music/minipop.txt` it would load from that
|
||||
> [!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 should be the base URL, followed by the rest of the tracks.
|
||||
This is also known as the "header", because it comes first.
|
||||
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.
|
||||
|
||||
Each track will be first appended to the base URL, and then the result use to download
|
||||
the track. All tracks must be in the MP3 format, as lowfi doesn't support any others currently.
|
||||
> [!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.
|
||||
|
||||
Additionally, lowfi _won't_ put a `/` between the base & track for added flexibility,
|
||||
so for most cases you should have a trailing `/` in your base url.
|
||||
The exception to this is if the track name begins with something like `https://`,
|
||||
where in that case the base will not be prepended to it.
|
||||
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:
|
||||
|
||||
@ -237,13 +244,13 @@ For example, if you had an entry like this:
|
||||
|
||||
Then lowfi would download from the first section, and display the second as the track name.
|
||||
|
||||
You can also prepend `file://` to the header track name, which will make lowfi treat it as a local file.
|
||||
This is useful if you want to use a local file as the base URL, such as:
|
||||
`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/Music/second-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
2488
data/chillhop.txt
2488
data/chillhop.txt
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,2 @@
|
||||
file:///home/user/Music/
|
||||
Anomaly.mp3
|
||||
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
|
@ -1,4 +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://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.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
|
33
src/main.rs
33
src/main.rs
@ -11,8 +11,12 @@ mod player;
|
||||
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, Clone)]
|
||||
#[command(about, version)]
|
||||
@ -38,6 +42,10 @@ struct Args {
|
||||
#[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,
|
||||
@ -47,7 +55,7 @@ struct Args {
|
||||
width: usize,
|
||||
|
||||
/// Use a custom track list
|
||||
#[clap(long, short, alias = "list", short_alias = 'l')]
|
||||
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
|
||||
track_list: Option<String>,
|
||||
|
||||
/// Internal song buffer size.
|
||||
@ -64,17 +72,10 @@ struct Args {
|
||||
#[derive(Subcommand, Clone)]
|
||||
enum Commands {
|
||||
/// Scrapes a music source for files.
|
||||
#[cfg(feature = "scrape")]
|
||||
Scrape {
|
||||
// The source to scrape from.
|
||||
source: scrapers::Sources,
|
||||
|
||||
/// 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,
|
||||
source: scrapers::Source,
|
||||
},
|
||||
}
|
||||
|
||||
@ -95,12 +96,12 @@ async fn main() -> eyre::Result<()> {
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
match command {
|
||||
// TODO: Actually distinguish between sources.
|
||||
Commands::Scrape {
|
||||
source: _,
|
||||
extension,
|
||||
include_full,
|
||||
} => scrapers::lofigirl::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).await?;
|
||||
|
@ -65,9 +65,14 @@ pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
|
||||
.await
|
||||
.map_err(player::Error::PersistentVolumeSave)?;
|
||||
|
||||
// Save the bookmarks for the next session.
|
||||
player.bookmarks.save().await?;
|
||||
|
||||
drop(stream);
|
||||
player.sink.stop();
|
||||
ui.map(|x| x.abort());
|
||||
if let Some(x) = ui {
|
||||
x.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -2,16 +2,10 @@
|
||||
//! This also has the code for the underlying
|
||||
//! audio server which adds new tracks.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
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, OutputStreamBuilder, Sink};
|
||||
@ -29,7 +23,7 @@ use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{self, persistent_volume::PersistentVolume},
|
||||
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
|
||||
tracks::{self, list::List},
|
||||
Args,
|
||||
};
|
||||
@ -47,9 +41,6 @@ pub use error::Error;
|
||||
#[cfg(feature = "mpris")]
|
||||
pub mod mpris;
|
||||
|
||||
/// The time to wait in between errors.
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// 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`.
|
||||
@ -64,19 +55,26 @@ pub struct Player {
|
||||
/// The internal buffer size.
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// Whether the current track has been bookmarked.
|
||||
bookmarked: AtomicBool,
|
||||
|
||||
/// The [`TrackInfo`] of the current track.
|
||||
/// This is [`None`] when lowfi is buffering/loading.
|
||||
current: ArcSwapOption<tracks::Info>,
|
||||
|
||||
/// The current progress for downloading tracks, if
|
||||
/// `current` is None.
|
||||
progress: AtomicF32,
|
||||
|
||||
/// The tracks, which is a [`VecDeque`] that holds
|
||||
/// *undecoded* [Track]s.
|
||||
///
|
||||
/// This is populated specifically by the [Downloader].
|
||||
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
|
||||
|
||||
/// 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,
|
||||
|
||||
@ -108,6 +106,9 @@ impl Player {
|
||||
///
|
||||
/// 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
|
||||
@ -126,6 +127,9 @@ impl Player {
|
||||
OutputStreamBuilder::open_default_stream()?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut stream = OutputStreamBuilder::open_default_stream()?;
|
||||
|
||||
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());
|
||||
|
||||
@ -139,18 +143,20 @@ impl Player {
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(TIMEOUT)
|
||||
.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,
|
||||
bookmarked: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
Ok((player, stream))
|
||||
@ -223,8 +229,6 @@ impl Player {
|
||||
|
||||
match msg {
|
||||
Message::Next | Message::Init | Message::TryAgain => {
|
||||
player.bookmarked.swap(false, Ordering::Relaxed);
|
||||
|
||||
// We manually skipped, so we shouldn't actually wait for the song
|
||||
// to be over until we recieve the `NewSong` signal.
|
||||
new = false;
|
||||
@ -297,18 +301,7 @@ impl Player {
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref().unwrap();
|
||||
|
||||
let bookmarked = bookmark::bookmark(
|
||||
current.full_path.clone(),
|
||||
if current.custom_name {
|
||||
Some(current.display_name.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(player::Error::Bookmark)?;
|
||||
|
||||
player.bookmarked.swap(bookmarked, Ordering::Relaxed);
|
||||
player.bookmarks.bookmark(current).await?;
|
||||
}
|
||||
Message::Quit => break,
|
||||
}
|
||||
|
@ -1,13 +1,7 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
use rodio::OutputStream;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::player;
|
||||
|
||||
/// 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<OutputStream, player::Error> {
|
||||
pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::player::Error> {
|
||||
use libc::freopen;
|
||||
use rodio::OutputStreamBuilder;
|
||||
use std::ffi::CString;
|
||||
|
@ -1,50 +1,107 @@
|
||||
use std::io::SeekFrom;
|
||||
//! Module for handling saving, loading, and adding
|
||||
//! bookmarks.
|
||||
|
||||
use tokio::fs::{create_dir_all, OpenOptions};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use crate::data_dir;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::{fs, io};
|
||||
|
||||
/// 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(path: String, custom: Option<String>) -> eyre::Result<bool> {
|
||||
let mut entry = path.to_string();
|
||||
if let Some(custom) = custom {
|
||||
entry.push('!');
|
||||
entry.push_str(&custom);
|
||||
}
|
||||
use crate::{data_dir, tracks};
|
||||
|
||||
let data_dir = data_dir()?;
|
||||
create_dir_all(data_dir.clone()).await?;
|
||||
/// Errors that might occur while managing bookmarks.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BookmarkError {
|
||||
#[error("data directory not found")]
|
||||
DataDir,
|
||||
|
||||
// TODO: Only open and close the file at startup and shutdown, not every single bookmark.
|
||||
// TODO: Sort of like PersistentVolume, but for bookmarks.
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.read(true)
|
||||
.append(false)
|
||||
.truncate(true)
|
||||
.open(data_dir.join("bookmarks.txt"))
|
||||
.await?;
|
||||
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).await?;
|
||||
|
||||
let mut lines: Vec<&str> = text.trim().lines().filter(|x| !x.is_empty()).collect();
|
||||
let idx = lines.iter().position(|x| **x == entry);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
lines.remove(idx);
|
||||
} else {
|
||||
lines.push(&entry);
|
||||
}
|
||||
|
||||
let text = format!("\n{}", lines.join("\n"));
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
file.set_len(0).await?;
|
||||
file.write_all(text.as_bytes()).await?;
|
||||
|
||||
Ok(idx.is_none())
|
||||
#[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,6 +1,6 @@
|
||||
//! Contains the [`Downloader`] struct.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{error::Error, sync::Arc};
|
||||
|
||||
use tokio::{
|
||||
sync::mpsc::{self, Receiver, Sender},
|
||||
@ -8,7 +8,7 @@ use tokio::{
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use super::{Player, TIMEOUT};
|
||||
use super::Player;
|
||||
|
||||
/// This struct is responsible for downloading tracks in the background.
|
||||
///
|
||||
@ -44,17 +44,18 @@ impl Downloader {
|
||||
|
||||
/// 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).await;
|
||||
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 !error.is_timeout() => {
|
||||
Err(error) => {
|
||||
if debug {
|
||||
panic!("{}", error)
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
sleep(TIMEOUT).await;
|
||||
if !error.is_timeout() {
|
||||
sleep(self.player.timeout).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::ffi::NulError;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::{messages::Message, player::bookmark::BookmarkError};
|
||||
use tokio::sync::mpsc::error::SendError;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
@ -43,9 +43,9 @@ pub enum Error {
|
||||
#[error("unable to notify downloader")]
|
||||
DownloaderNotify(#[from] SendError<()>),
|
||||
|
||||
#[error("unable to bookmark track")]
|
||||
Bookmark(eyre::Error),
|
||||
|
||||
#[error("unable to find data directory")]
|
||||
DataDir,
|
||||
|
||||
#[error("bookmarking load/unload failed")]
|
||||
Bookmark(#[from] BookmarkError),
|
||||
}
|
||||
|
@ -1,16 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
error::Error,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
use tokio::{sync::mpsc::Sender, time::sleep};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{downloader::Downloader, Player, TIMEOUT},
|
||||
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::TrackError> {
|
||||
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 {
|
||||
@ -23,7 +26,8 @@ impl Player {
|
||||
// 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.list.random(&self.client).await?
|
||||
self.progress.store(0.0, Ordering::Relaxed);
|
||||
self.list.random(&self.client, Some(&self.progress)).await?
|
||||
};
|
||||
|
||||
let decoded = track.decode()?;
|
||||
@ -56,6 +60,9 @@ impl Player {
|
||||
// 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?;
|
||||
@ -64,12 +71,12 @@ impl Player {
|
||||
tx.send(Message::NewSong).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
if !error.is_timeout() {
|
||||
if debug {
|
||||
panic!("{error:?}")
|
||||
}
|
||||
if debug {
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
sleep(TIMEOUT).await;
|
||||
if !error.is_timeout() {
|
||||
sleep(player.timeout).await;
|
||||
}
|
||||
|
||||
tx.send(Message::TryAgain).await?;
|
||||
|
@ -159,16 +159,15 @@ impl Window {
|
||||
/// The code for the terminal interface itself.
|
||||
///
|
||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||
/// * `borderless` - Whether to include borders or not.
|
||||
/// * `width` - The width of player
|
||||
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);
|
||||
let mut window = Window::new(width, borderless || debug);
|
||||
|
||||
loop {
|
||||
// Load `current` once so that it doesn't have to be loaded over and over
|
||||
@ -197,10 +196,10 @@ async fn interface(
|
||||
|
||||
let controls = components::controls(width);
|
||||
|
||||
let menu = if minimalist {
|
||||
vec![action, middle]
|
||||
} else {
|
||||
vec![action, middle, controls]
|
||||
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],
|
||||
};
|
||||
|
||||
window.draw(menu, false)?;
|
||||
@ -294,6 +293,7 @@ pub async fn start(
|
||||
Arc::clone(&player),
|
||||
args.minimalist,
|
||||
args.borderless,
|
||||
args.debug,
|
||||
args.fps,
|
||||
21 + args.width.min(32) * 2,
|
||||
));
|
||||
|
@ -1,11 +1,7 @@
|
||||
//! Various different individual components that
|
||||
//! appear in lowfi's UI, like the progress bar.
|
||||
|
||||
use std::{
|
||||
ops::Deref as _,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
||||
|
||||
use crossterm::style::Stylize as _;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
@ -63,14 +59,17 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
||||
|
||||
/// This represents the main "action" bars state.
|
||||
enum ActionBar {
|
||||
/// When the app is currently displaying "paused".
|
||||
/// When the app is paused.
|
||||
Paused(Info),
|
||||
|
||||
/// When the app is currently displaying "playing".
|
||||
/// When the app is playing.
|
||||
Playing(Info),
|
||||
|
||||
/// When the app is currently displaying "loading".
|
||||
Loading,
|
||||
/// When the app is loading.
|
||||
Loading(f32),
|
||||
|
||||
/// When the app is muted.
|
||||
Muted,
|
||||
}
|
||||
|
||||
impl ActionBar {
|
||||
@ -80,7 +79,16 @@ impl ActionBar {
|
||||
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 => ("loading", None),
|
||||
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(
|
||||
@ -99,16 +107,23 @@ impl ActionBar {
|
||||
/// 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(ActionBar::Loading, |info| {
|
||||
let info = info.deref().clone();
|
||||
.map_or_else(
|
||||
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|
||||
|info| {
|
||||
let info = info.deref().clone();
|
||||
|
||||
if player.sink.is_paused() {
|
||||
ActionBar::Paused(info)
|
||||
} else {
|
||||
ActionBar::Playing(info)
|
||||
}
|
||||
})
|
||||
.format(player.bookmarked.load(Ordering::Relaxed));
|
||||
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();
|
||||
|
@ -1,9 +1,88 @@
|
||||
use clap::ValueEnum;
|
||||
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 Sources {
|
||||
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)
|
||||
}
|
@ -5,19 +5,19 @@
|
||||
|
||||
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)
|
||||
@ -29,10 +29,9 @@ 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);
|
||||
|
||||
let items = parse("").await?;
|
||||
async fn scan() -> eyre::Result<Vec<String>> {
|
||||
let client = Client::new();
|
||||
let items = parse(&client, "/").await?;
|
||||
|
||||
let mut years: Vec<u32> = items
|
||||
.iter()
|
||||
@ -48,22 +47,19 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@ -81,8 +77,8 @@ 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}");
|
||||
}
|
||||
|
134
src/tracks.rs
134
src/tracks.rs
@ -18,43 +18,19 @@
|
||||
use std::{io::Cursor, path::Path, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use inflector::Inflector as _;
|
||||
use convert_case::{Case, Casing};
|
||||
use regex::Regex;
|
||||
use rodio::{Decoder, Source as _};
|
||||
use thiserror::Error;
|
||||
use tokio::io;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use url::form_urlencoded;
|
||||
|
||||
pub mod error;
|
||||
pub mod list;
|
||||
|
||||
/// The error type for the track system, which is used to handle errors that occur
|
||||
/// while downloading, decoding, or playing tracks.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TrackError {
|
||||
#[error("timeout")]
|
||||
Timeout,
|
||||
pub use error::Error;
|
||||
|
||||
#[error("unable to decode")]
|
||||
Decode(#[from] rodio::decoder::DecoderError),
|
||||
|
||||
#[error("invalid name")]
|
||||
InvalidName,
|
||||
|
||||
#[error("invalid file path")]
|
||||
InvalidPath,
|
||||
|
||||
#[error("unable to read file")]
|
||||
File(#[from] io::Error),
|
||||
|
||||
#[error("unable to fetch data")]
|
||||
Request(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
impl TrackError {
|
||||
pub const fn is_timeout(&self) -> bool {
|
||||
matches!(self, Self::Timeout)
|
||||
}
|
||||
}
|
||||
use crate::tracks::error::Context;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
/// Just a shorthand for a decoded [Bytes].
|
||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||
@ -92,7 +68,7 @@ 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, TrackError> {
|
||||
pub fn decode(self) -> eyre::Result<DecodedTrack, Error> {
|
||||
DecodedTrack::new(self)
|
||||
}
|
||||
}
|
||||
@ -121,7 +97,35 @@ pub struct Info {
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
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()`.
|
||||
@ -131,35 +135,50 @@ impl Info {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Formats a name with [Inflector].
|
||||
/// Formats a name with [`convert_case`].
|
||||
///
|
||||
/// This will also strip the first few numbers that are
|
||||
/// usually present on most lofi tracks.
|
||||
fn format_name(name: &str) -> eyre::Result<String, TrackError> {
|
||||
/// 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 stem = path
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or(TrackError::InvalidName)?;
|
||||
let formatted = Self::decode_url(stem)
|
||||
.to_lowercase()
|
||||
.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(" Ll ", "'ll ")
|
||||
.replace(" Re ", "'re ")
|
||||
.replace(" M ", "'m ");
|
||||
.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;
|
||||
|
||||
for character in formatted.as_bytes() {
|
||||
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;
|
||||
@ -167,12 +186,12 @@ impl Info {
|
||||
}
|
||||
|
||||
// If the entire name of the track is a number, then just return it.
|
||||
if skip == formatted.len() {
|
||||
Ok(formatted)
|
||||
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(&formatted[skip..]))
|
||||
Ok(String::from(name[skip..].trim()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +200,7 @@ impl Info {
|
||||
name: TrackName,
|
||||
full_path: String,
|
||||
decoded: &DecodedData,
|
||||
) -> eyre::Result<Self, TrackError> {
|
||||
) -> eyre::Result<Self, Error> {
|
||||
let (display_name, custom_name) = match name {
|
||||
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||
TrackName::Formatted(custom) => (custom, true),
|
||||
@ -210,11 +229,12 @@ pub struct DecodedTrack {
|
||||
impl DecodedTrack {
|
||||
/// Creates a new track.
|
||||
/// This is equivalent to [`QueuedTrack::decode`].
|
||||
pub fn new(track: QueuedTrack) -> eyre::Result<Self, TrackError> {
|
||||
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()?;
|
||||
.build()
|
||||
.track(track.full_path.clone())?;
|
||||
|
||||
let info = Info::new(track.name, track.full_path, &data)?;
|
||||
|
||||
|
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()
|
||||
})
|
||||
}
|
||||
}
|
@ -1,13 +1,19 @@
|
||||
//! The module containing all of the logic behind track lists,
|
||||
//! as well as obtaining track names & downloading the raw audio data
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::{cmp::min, sync::atomic::Ordering};
|
||||
|
||||
use atomic_float::AtomicF32;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use eyre::OptionExt as _;
|
||||
use rand::Rng as _;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{data_dir, tracks::TrackError};
|
||||
use crate::{
|
||||
data_dir,
|
||||
tracks::{self, error::Context},
|
||||
};
|
||||
|
||||
use super::QueuedTrack;
|
||||
|
||||
@ -21,8 +27,12 @@ pub struct List {
|
||||
pub name: String,
|
||||
|
||||
/// Just the raw file, but seperated by `/n` (newlines).
|
||||
/// `lines[0]` is the base, with the rest being tracks.
|
||||
/// `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 {
|
||||
@ -41,7 +51,7 @@ impl List {
|
||||
// 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 = rand::thread_rng().gen_range(1..self.lines.len());
|
||||
let random = fastrand::usize(1..self.lines.len());
|
||||
let line = self.lines[random].clone();
|
||||
|
||||
if let Some((first, second)) = line.split_once('!') {
|
||||
@ -52,7 +62,12 @@ impl List {
|
||||
}
|
||||
|
||||
/// Downloads a raw track, but doesn't decode it.
|
||||
async fn download(&self, track: &str, client: &Client) -> Result<(Bytes, String), TrackError> {
|
||||
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()
|
||||
@ -62,28 +77,43 @@ impl List {
|
||||
|
||||
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(TrackError::InvalidPath)?;
|
||||
let home = home_path.to_str().ok_or(TrackError::InvalidPath)?;
|
||||
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).await?;
|
||||
let result = tokio::fs::read(path.clone()).await.track(track)?;
|
||||
result.into()
|
||||
} else {
|
||||
let response = match client.get(full_path.clone()).send().await {
|
||||
Ok(x) => Ok(x),
|
||||
Err(x) => {
|
||||
if x.is_timeout() {
|
||||
Err(TrackError::Timeout)
|
||||
} else {
|
||||
Err(TrackError::Request(x))
|
||||
}
|
||||
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);
|
||||
}
|
||||
}?;
|
||||
response.bytes().await?
|
||||
|
||||
bytes.into()
|
||||
} else {
|
||||
response.bytes().await.track(track)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok((data, full_path))
|
||||
@ -93,13 +123,17 @@ impl 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) -> Result<QueuedTrack, TrackError> {
|
||||
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).await?;
|
||||
let (data, full_path) = self.download(&path, client, progress).await?;
|
||||
|
||||
let name = custom_name.map_or_else(
|
||||
|| super::TrackName::Raw(path.clone()),
|
||||
|formatted| super::TrackName::Formatted(formatted),
|
||||
super::TrackName::Formatted,
|
||||
);
|
||||
|
||||
Ok(QueuedTrack {
|
||||
@ -110,7 +144,7 @@ impl List {
|
||||
}
|
||||
|
||||
/// Parses text into a [List].
|
||||
pub fn new(name: &str, text: &str) -> Self {
|
||||
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
|
||||
let lines: Vec<String> = text
|
||||
.trim_end()
|
||||
.lines()
|
||||
@ -119,6 +153,7 @@ impl List {
|
||||
|
||||
Self {
|
||||
lines,
|
||||
path: path.map(ToOwned::to_owned),
|
||||
name: name.to_owned(),
|
||||
}
|
||||
}
|
||||
@ -127,21 +162,27 @@ impl List {
|
||||
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 name = data_dir()?.join(format!("{arg}.txt"));
|
||||
let name = if name.exists() { name } else { arg.into() };
|
||||
let path = data_dir()?.join(format!("{arg}.txt"));
|
||||
let path = if path.exists() { path } else { arg.into() };
|
||||
|
||||
let raw = fs::read_to_string(name.clone()).await?;
|
||||
let raw = fs::read_to_string(path.clone()).await?;
|
||||
|
||||
let name = name
|
||||
// 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))
|
||||
Ok(Self::new(name, raw, path.to_str()))
|
||||
} else {
|
||||
Ok(Self::new(
|
||||
"lofigirl",
|
||||
include_str!("../../data/lofigirl.txt"),
|
||||
"chillhop",
|
||||
include_str!("../../data/chillhop.txt"),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user