mirror of
https://github.com/talwat/lowfi
synced 2025-09-30 04:10:13 +00:00
Compare commits
No commits in common. "main" and "1.7.0-dev" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1 @@
|
|||||||
/target
|
/target
|
||||||
/cache
|
|
||||||
.DS_Store
|
|
11
CHILLHOP.md
11
CHILLHOP.md
@ -1,10 +1,5 @@
|
|||||||
# Using the chillhop list
|
# 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
|
## Linux
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@ -15,8 +10,8 @@ curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillho
|
|||||||
## MacOS
|
## MacOS
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p "$HOME/Library/Application Support/lowfi"
|
mkdir -p "~/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"
|
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir "~/Library/Application Support/lowfi"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
@ -26,4 +21,4 @@ Then just put [this file](https://raw.githubusercontent.com/talwat/lowfi/refs/he
|
|||||||
|
|
||||||
## Launching lowfi
|
## 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`.
|
566
Cargo.lock
generated
566
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
36
Cargo.toml
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "1.7.2"
|
version = "1.7.0-dev"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "An extremely simple lofi player."
|
description = "An extremely simple lofi player."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -19,46 +19,38 @@ repository = "https://github.com/talwat/lowfi"
|
|||||||
[features]
|
[features]
|
||||||
mpris = ["dep:mpris-server"]
|
mpris = ["dep:mpris-server"]
|
||||||
extra-audio-formats = ["rodio/default"]
|
extra-audio-formats = ["rodio/default"]
|
||||||
scrape = ["dep:serde", "dep:serde_json", "dep:html-escape", "dep:scraper", "dep:indicatif"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Basics
|
# Basics
|
||||||
clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
||||||
eyre = "0.6.12"
|
eyre = { version = "0.6.12" }
|
||||||
fastrand = "2.3.0"
|
rand = "0.8.5"
|
||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
color-eyre = { version = "0.6.5", default-features = false }
|
color-eyre = { version = "0.6.5", default-features = false }
|
||||||
|
|
||||||
# Async
|
# 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"
|
futures = "0.3.31"
|
||||||
arc-swap = "1.7.1"
|
arc-swap = "1.7.1"
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
reqwest = { version = "0.12.9", features = ["stream"] }
|
reqwest = "0.12.9"
|
||||||
bytes = "1.9.0"
|
bytes = "1.9.0"
|
||||||
|
|
||||||
# I/O
|
# I/O
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||||
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
|
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
|
||||||
mpris-server = { version = "0.8.1", optional = true }
|
mpris-server = { version = "0.8.1", optional = true }
|
||||||
dirs = "6.0.0"
|
dirs = "5.0.1"
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
convert_case = "0.8.0"
|
scraper = "0.21.0"
|
||||||
|
Inflector = "0.11.4"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
|
libc = "0.2.167"
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
unicode-segmentation = "1.12.0"
|
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
75
MUSIC.md
@ -1,75 +0,0 @@
|
|||||||
# 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.
|
|
145
README.md
145
README.md
@ -7,18 +7,30 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
As of the 1.7.0 version of lowfi, **all** of the audio files embedded
|
> [!NOTE]
|
||||||
by default are from [chillhop](https://chillhop.com/). Read
|
>
|
||||||
[MUSIC.md](MUSIC.md) for more information.
|
> 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.
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
I really hate modern music platforms, and I wanted a small, simple
|
I really hate modern music platforms, and I wanted a small, "suckless"
|
||||||
app that would just play random ambient music without video and other fluff.
|
app that would just play random lofi without video.
|
||||||
|
|
||||||
Beyond that, it was also designed to be fairly resilient to inconsistent networks,
|
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.
|
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
|
## Installing
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@ -34,8 +46,8 @@ On MacOS & Windows, no extra dependencies are needed.
|
|||||||
|
|
||||||
On Linux, you'll also need openssl & alsa, as well as their headers.
|
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.
|
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu.
|
||||||
- `openssl` on Arch, `libssl-dev` on Ubuntu, `openssl-devel` on Fedora.
|
- `openssl` on Arch, `libssl-dev` on Ubuntu.
|
||||||
|
|
||||||
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
||||||
|
|
||||||
@ -76,7 +88,7 @@ zypper install lowfi
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
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
|
sudo apt install -y lowfi
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -123,13 +135,12 @@ Yeah, that's it.
|
|||||||
| `-`, `_`, `j`, `↓` | Volume Down 10% |
|
| `-`, `_`, `j`, `↓` | Volume Down 10% |
|
||||||
| `←` | Volume Down 1% |
|
| `←` | Volume Down 1% |
|
||||||
| `q`, CTRL+C | Quit |
|
| `q`, CTRL+C | Quit |
|
||||||
| `b` | Bookmark |
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
||||||
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
|
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
|
||||||
>
|
>
|
||||||
> MPRIS is currently an optional feature in cargo (enabled with `--features mpris`)
|
> MPRIS is currently 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
|
> due to it being only for Linux, as well as the fact that the main point of
|
||||||
> lowfi is it's unique & minimal interface.
|
> lowfi is it's unique & minimal interface.
|
||||||
|
|
||||||
@ -138,87 +149,69 @@ Yeah, that's it.
|
|||||||
If you have something you'd like to tweak about lowfi, you use additional flags which
|
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`.
|
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
|
||||||
|
|
||||||
| Flag | Function |
|
| Flag | Function |
|
||||||
| ----------------------------------- | --------------------------------------------------- |
|
| ----------------------------------- | ---------------------------------------------- |
|
||||||
| `-a`, `--alternate` | Use an alternate terminal screen |
|
| `-a`, `--alternate` | Use an alternate terminal screen |
|
||||||
| `-m`, `--minimalist` | Hide the bottom control bar |
|
| `-m`, `--minimalist` | Hide the bottom control bar |
|
||||||
| `-b`, `--borderless` | Exclude borders in UI |
|
| `-b`, `--borderless` | Exclude borders in UI |
|
||||||
| `-p`, `--paused` | Start lowfi paused |
|
| `-p`, `--paused` | Start lowfi paused |
|
||||||
| `-f`, `--fps` | FPS of the UI [default: 12] |
|
| `-d`, `--debug` | Include ALSA & other logs |
|
||||||
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
|
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
||||||
| `-d`, `--debug` | Include ALSA & other logs |
|
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
|
||||||
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
|
||||||
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
|
|
||||||
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
|
|
||||||
|
|
||||||
### Extra Features
|
### Scraping
|
||||||
|
|
||||||
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,
|
lowfi also has a `scrape` command which is usually not relevant, but
|
||||||
like those which are only expected to be used by a handful of users.
|
if you're trying to download some files from Lofi Girls' website,
|
||||||
|
it can be useful.
|
||||||
|
|
||||||
#### `scrape` - Scraping
|
An example of scrape is as follows,
|
||||||
|
|
||||||
This feature provides the `scrape` command.
|
`lowfi scrape --extension zip --include-full`
|
||||||
It's usually not very useful, but is included for transparency's sake.
|
|
||||||
|
|
||||||
More information can be found by running `lowfi help scrape`.
|
where 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
|
### Custom Track Lists
|
||||||
|
|
||||||
> [!NOTE]
|
Some nice users, especially [danielwerg](https://github.com/danielwerg),
|
||||||
> 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/)
|
||||||
> 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.
|
||||||
> 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
|
Feel free to contribute your own list with a PR.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
lowfi also supports custom track lists, although the default one from Lofi Girl
|
||||||
is embedded into the binary.
|
is embedded into the binary.
|
||||||
|
|
||||||
To use a custom list, use the `--track-list` flag. This can either be a path to some file,
|
To use a custom list, use the `--tracks` 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
|
or it could also be the name of a file (without the `.txt` extension) in the data
|
||||||
directory.
|
directory, so on Linux it's `~/.local/share/lowfi`.
|
||||||
|
|
||||||
> [!NOTE]
|
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||||
> Data directories:
|
Whereas if you did `lowfi --tracks ~/Music/minipop.txt` it would load from that
|
||||||
>
|
|
||||||
> - 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.
|
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
|
#### The Format
|
||||||
|
|
||||||
In lists, the first line is what's known as the header, followed by the rest of the tracks.
|
In lists, the first line should be the base URL, followed by the rest of the tracks.
|
||||||
Each track will be first appended to the header, and then use the combination to download
|
This is also known as the "header", because it comes first.
|
||||||
the track.
|
|
||||||
|
|
||||||
> [!NOTE]
|
Each track will be first appended to the base URL, and then the result use to download
|
||||||
> lowfi _will not_ put a `/` between the base & track for added flexibility,
|
the track. All tracks must be in the MP3 format, as lowfi doesn't support any others currently.
|
||||||
> 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://`,
|
Additionally, lowfi _won't_ put a `/` between the base & track for added flexibility,
|
||||||
in which case the base will not be prepended to it. If all of your tracks are like this,
|
so for most cases you should have a trailing `/` in your base url.
|
||||||
then you can put `noheader` as the first line and not have a header at all.
|
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.
|
||||||
|
|
||||||
For example, in this list:
|
For example, in this list:
|
||||||
|
|
||||||
@ -244,13 +237,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.
|
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.
|
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, for example:
|
This is useful if you want to use a local file as the base URL, such as:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
file:///home/user/Music/
|
file:///home/user/Music/
|
||||||
file.mp3
|
file.mp3
|
||||||
file:///home/user/Other Music/second-file.mp3
|
file:///home/user/Music/second-file.mp3
|
||||||
```
|
```
|
||||||
|
|
||||||
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
||||||
|
2459
data/archive.txt
2459
data/archive.txt
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/
|
file:///home/user/Music/
|
||||||
Test.mp3
|
Anomaly.mp3
|
@ -1,2 +0,0 @@
|
|||||||
noheader
|
|
||||||
https://stream.chillhop.com/mp3/9476
|
|
@ -1,4 +1,4 @@
|
|||||||
https://lofigirl.com/wp-content/uploads/
|
https://lofigirl.com/wp-content/uploads/
|
||||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
2023/04/2-In-Front-Of-Me.mp3
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
https://stream.chillhop.com/mp3/9476
|
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
|
@ -1,4 +0,0 @@
|
|||||||
#!/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,12 +11,8 @@ mod player;
|
|||||||
mod tracks;
|
mod tracks;
|
||||||
|
|
||||||
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
||||||
#[cfg(feature = "scrape")]
|
|
||||||
mod scrapers;
|
mod scrapers;
|
||||||
|
|
||||||
#[cfg(feature = "scrape")]
|
|
||||||
use crate::scrapers::Source;
|
|
||||||
|
|
||||||
/// An extremely simple lofi player.
|
/// An extremely simple lofi player.
|
||||||
#[derive(Parser, Clone)]
|
#[derive(Parser, Clone)]
|
||||||
#[command(about, version)]
|
#[command(about, version)]
|
||||||
@ -42,10 +38,6 @@ struct Args {
|
|||||||
#[clap(long, short, default_value_t = 12)]
|
#[clap(long, short, default_value_t = 12)]
|
||||||
fps: u8,
|
fps: u8,
|
||||||
|
|
||||||
/// Timeout in seconds for music downloads.
|
|
||||||
#[clap(long, default_value_t = 3)]
|
|
||||||
timeout: u64,
|
|
||||||
|
|
||||||
/// Include ALSA & other logs.
|
/// Include ALSA & other logs.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
@ -55,7 +47,7 @@ struct Args {
|
|||||||
width: usize,
|
width: usize,
|
||||||
|
|
||||||
/// Use a custom track list
|
/// Use a custom track list
|
||||||
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
|
#[clap(long, short, alias = "list", short_alias = 'l')]
|
||||||
track_list: Option<String>,
|
track_list: Option<String>,
|
||||||
|
|
||||||
/// Internal song buffer size.
|
/// Internal song buffer size.
|
||||||
@ -72,10 +64,17 @@ struct Args {
|
|||||||
#[derive(Subcommand, Clone)]
|
#[derive(Subcommand, Clone)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Scrapes a music source for files.
|
/// Scrapes a music source for files.
|
||||||
#[cfg(feature = "scrape")]
|
|
||||||
Scrape {
|
Scrape {
|
||||||
// The source to scrape from.
|
// The source to scrape from.
|
||||||
source: scrapers::Source,
|
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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,12 +95,12 @@ async fn main() -> eyre::Result<()> {
|
|||||||
|
|
||||||
if let Some(command) = cli.command {
|
if let Some(command) = cli.command {
|
||||||
match command {
|
match command {
|
||||||
#[cfg(feature = "scrape")]
|
// TODO: Actually distinguish between sources.
|
||||||
Commands::Scrape { source } => match source {
|
Commands::Scrape {
|
||||||
Source::Archive => scrapers::archive::scrape().await?,
|
source: _,
|
||||||
Source::Lofigirl => scrapers::lofigirl::scrape().await?,
|
extension,
|
||||||
Source::Chillhop => scrapers::chillhop::scrape().await?,
|
include_full,
|
||||||
},
|
} => scrapers::lofigirl::scrape(extension, include_full).await?,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
play::play(cli).await?;
|
play::play(cli).await?;
|
||||||
|
@ -65,14 +65,9 @@ pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
|
|||||||
.await
|
.await
|
||||||
.map_err(player::Error::PersistentVolumeSave)?;
|
.map_err(player::Error::PersistentVolumeSave)?;
|
||||||
|
|
||||||
// Save the bookmarks for the next session.
|
|
||||||
player.bookmarks.save().await?;
|
|
||||||
|
|
||||||
drop(stream);
|
drop(stream);
|
||||||
player.sink.stop();
|
player.sink.stop();
|
||||||
if let Some(x) = ui {
|
ui.map(|x| x.abort());
|
||||||
x.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,16 @@
|
|||||||
//! This also has the code for the underlying
|
//! This also has the code for the underlying
|
||||||
//! audio server which adds new tracks.
|
//! audio server which adds new tracks.
|
||||||
|
|
||||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use arc_swap::ArcSwapOption;
|
use arc_swap::ArcSwapOption;
|
||||||
use atomic_float::AtomicF32;
|
|
||||||
use downloader::Downloader;
|
use downloader::Downloader;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rodio::{OutputStream, OutputStreamBuilder, Sink};
|
use rodio::{OutputStream, OutputStreamBuilder, Sink};
|
||||||
@ -23,7 +29,7 @@ use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
messages::Message,
|
messages::Message,
|
||||||
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
|
player::{self, persistent_volume::PersistentVolume},
|
||||||
tracks::{self, list::List},
|
tracks::{self, list::List},
|
||||||
Args,
|
Args,
|
||||||
};
|
};
|
||||||
@ -41,6 +47,9 @@ pub use error::Error;
|
|||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
pub mod 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.
|
/// 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: 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: In other words, this would change the type from `Arc<Player>` to just `Player`.
|
||||||
@ -55,26 +64,19 @@ pub struct Player {
|
|||||||
/// The internal buffer size.
|
/// The internal buffer size.
|
||||||
pub buffer_size: usize,
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Whether the current track has been bookmarked.
|
||||||
|
bookmarked: AtomicBool,
|
||||||
|
|
||||||
/// The [`TrackInfo`] of the current track.
|
/// The [`TrackInfo`] of the current track.
|
||||||
/// This is [`None`] when lowfi is buffering/loading.
|
/// This is [`None`] when lowfi is buffering/loading.
|
||||||
current: ArcSwapOption<tracks::Info>,
|
current: ArcSwapOption<tracks::Info>,
|
||||||
|
|
||||||
/// The current progress for downloading tracks, if
|
|
||||||
/// `current` is None.
|
|
||||||
progress: AtomicF32,
|
|
||||||
|
|
||||||
/// The tracks, which is a [`VecDeque`] that holds
|
/// The tracks, which is a [`VecDeque`] that holds
|
||||||
/// *undecoded* [Track]s.
|
/// *undecoded* [Track]s.
|
||||||
///
|
///
|
||||||
/// This is populated specifically by the [Downloader].
|
/// This is populated specifically by the [Downloader].
|
||||||
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
|
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.
|
/// The actual list of tracks to be played.
|
||||||
list: List,
|
list: List,
|
||||||
|
|
||||||
@ -106,9 +108,6 @@ impl Player {
|
|||||||
///
|
///
|
||||||
/// This also will load the track list & persistent volume.
|
/// This also will load the track list & persistent volume.
|
||||||
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
|
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
|
||||||
// Load the bookmarks.
|
|
||||||
let bookmarks = Bookmarks::load().await?;
|
|
||||||
|
|
||||||
// Load the volume file.
|
// Load the volume file.
|
||||||
let volume = PersistentVolume::load()
|
let volume = PersistentVolume::load()
|
||||||
.await
|
.await
|
||||||
@ -127,9 +126,6 @@ impl Player {
|
|||||||
OutputStreamBuilder::open_default_stream()?
|
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!!!
|
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());
|
let sink = Sink::connect_new(stream.mixer());
|
||||||
|
|
||||||
@ -143,20 +139,18 @@ impl Player {
|
|||||||
"/",
|
"/",
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
))
|
))
|
||||||
.timeout(Duration::from_secs(args.timeout * 5))
|
.timeout(TIMEOUT)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let player = Self {
|
let player = Self {
|
||||||
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
||||||
buffer_size: args.buffer_size,
|
buffer_size: args.buffer_size,
|
||||||
current: ArcSwapOption::new(None),
|
current: ArcSwapOption::new(None),
|
||||||
progress: AtomicF32::new(0.0),
|
|
||||||
timeout: Duration::from_secs(args.timeout),
|
|
||||||
bookmarks,
|
|
||||||
client,
|
client,
|
||||||
sink,
|
sink,
|
||||||
volume,
|
volume,
|
||||||
list,
|
list,
|
||||||
|
bookmarked: AtomicBool::new(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((player, stream))
|
Ok((player, stream))
|
||||||
@ -229,6 +223,8 @@ impl Player {
|
|||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
Message::Next | Message::Init | Message::TryAgain => {
|
Message::Next | Message::Init | Message::TryAgain => {
|
||||||
|
player.bookmarked.swap(false, Ordering::Relaxed);
|
||||||
|
|
||||||
// We manually skipped, so we shouldn't actually wait for the song
|
// We manually skipped, so we shouldn't actually wait for the song
|
||||||
// to be over until we recieve the `NewSong` signal.
|
// to be over until we recieve the `NewSong` signal.
|
||||||
new = false;
|
new = false;
|
||||||
@ -301,7 +297,18 @@ impl Player {
|
|||||||
let current = player.current.load();
|
let current = player.current.load();
|
||||||
let current = current.as_ref().unwrap();
|
let current = current.as_ref().unwrap();
|
||||||
|
|
||||||
player.bookmarks.bookmark(current).await?;
|
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);
|
||||||
}
|
}
|
||||||
Message::Quit => break,
|
Message::Quit => break,
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
|
#[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].
|
/// This gets the output stream while also shutting up alsa with [libc].
|
||||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::player::Error> {
|
pub fn silent_get_output_stream() -> eyre::Result<OutputStream, player::Error> {
|
||||||
use libc::freopen;
|
use libc::freopen;
|
||||||
use rodio::OutputStreamBuilder;
|
use rodio::OutputStreamBuilder;
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
|
@ -1,107 +1,50 @@
|
|||||||
//! Module for handling saving, loading, and adding
|
use std::io::SeekFrom;
|
||||||
//! bookmarks.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use tokio::fs::{create_dir_all, OpenOptions};
|
||||||
use std::sync::atomic::AtomicBool;
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use crate::data_dir;
|
||||||
use tokio::{fs, io};
|
|
||||||
|
|
||||||
use crate::{data_dir, tracks};
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors that might occur while managing bookmarks.
|
let data_dir = data_dir()?;
|
||||||
#[derive(Debug, thiserror::Error)]
|
create_dir_all(data_dir.clone()).await?;
|
||||||
pub enum BookmarkError {
|
|
||||||
#[error("data directory not found")]
|
|
||||||
DataDir,
|
|
||||||
|
|
||||||
#[error("io failure")]
|
// TODO: Only open and close the file at startup and shutdown, not every single bookmark.
|
||||||
Io(#[from] io::Error),
|
// TODO: Sort of like PersistentVolume, but for bookmarks.
|
||||||
}
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
/// Manages the bookmarks in the current player.
|
.write(true)
|
||||||
pub struct Bookmarks {
|
.read(true)
|
||||||
/// The different entries in the bookmarks file.
|
.append(false)
|
||||||
entries: RwLock<Vec<String>>,
|
.truncate(true)
|
||||||
|
.open(data_dir.join("bookmarks.txt"))
|
||||||
/// The internal bookmarked register, which keeps track
|
.await?;
|
||||||
/// of whether a track is bookmarked or not.
|
|
||||||
///
|
let mut text = String::new();
|
||||||
/// This is much more efficient than checking every single frame.
|
file.read_to_string(&mut text).await?;
|
||||||
bookmarked: AtomicBool,
|
|
||||||
}
|
let mut lines: Vec<&str> = text.trim().lines().filter(|x| !x.is_empty()).collect();
|
||||||
|
let idx = lines.iter().position(|x| **x == entry);
|
||||||
impl Bookmarks {
|
|
||||||
/// Gets the path of the bookmarks file.
|
if let Some(idx) = idx {
|
||||||
pub async fn path() -> eyre::Result<PathBuf, BookmarkError> {
|
lines.remove(idx);
|
||||||
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
|
} else {
|
||||||
fs::create_dir_all(data_dir.clone()).await?;
|
lines.push(&entry);
|
||||||
|
}
|
||||||
Ok(data_dir.join("bookmarks.txt"))
|
|
||||||
}
|
let text = format!("\n{}", lines.join("\n"));
|
||||||
|
file.seek(SeekFrom::Start(0)).await?;
|
||||||
/// Loads bookmarks from the `bookmarks.txt` file.
|
file.set_len(0).await?;
|
||||||
pub async fn load() -> eyre::Result<Self, BookmarkError> {
|
file.write_all(text.as_bytes()).await?;
|
||||||
let text = fs::read_to_string(Self::path().await?)
|
|
||||||
.await
|
Ok(idx.is_none())
|
||||||
.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.
|
//! Contains the [`Downloader`] struct.
|
||||||
|
|
||||||
use std::{error::Error, sync::Arc};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::mpsc::{self, Receiver, Sender},
|
sync::mpsc::{self, Receiver, Sender},
|
||||||
@ -8,7 +8,7 @@ use tokio::{
|
|||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Player;
|
use super::{Player, TIMEOUT};
|
||||||
|
|
||||||
/// This struct is responsible for downloading tracks in the background.
|
/// This struct is responsible for downloading tracks in the background.
|
||||||
///
|
///
|
||||||
@ -44,18 +44,17 @@ impl Downloader {
|
|||||||
|
|
||||||
/// Push a new, random track onto the internal buffer.
|
/// Push a new, random track onto the internal buffer.
|
||||||
pub async fn push_buffer(&self, debug: bool) {
|
pub async fn push_buffer(&self, debug: bool) {
|
||||||
let data = self.player.list.random(&self.player.client, None).await;
|
let data = self.player.list.random(&self.player.client).await;
|
||||||
match data {
|
match data {
|
||||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
Ok(track) => self.player.tracks.write().await.push_back(track),
|
||||||
Err(error) => {
|
Err(error) if !error.is_timeout() => {
|
||||||
if debug {
|
if debug {
|
||||||
panic!("{error} - {:?}", error.source())
|
panic!("{}", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !error.is_timeout() {
|
sleep(TIMEOUT).await;
|
||||||
sleep(self.player.timeout).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use std::ffi::NulError;
|
use std::ffi::NulError;
|
||||||
|
|
||||||
use crate::{messages::Message, player::bookmark::BookmarkError};
|
use crate::messages::Message;
|
||||||
use tokio::sync::mpsc::error::SendError;
|
use tokio::sync::mpsc::error::SendError;
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
@ -43,9 +43,9 @@ pub enum Error {
|
|||||||
#[error("unable to notify downloader")]
|
#[error("unable to notify downloader")]
|
||||||
DownloaderNotify(#[from] SendError<()>),
|
DownloaderNotify(#[from] SendError<()>),
|
||||||
|
|
||||||
|
#[error("unable to bookmark track")]
|
||||||
|
Bookmark(eyre::Error),
|
||||||
|
|
||||||
#[error("unable to find data directory")]
|
#[error("unable to find data directory")]
|
||||||
DataDir,
|
DataDir,
|
||||||
|
|
||||||
#[error("bookmarking load/unload failed")]
|
|
||||||
Bookmark(#[from] BookmarkError),
|
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,16 @@
|
|||||||
use std::{
|
use std::sync::Arc;
|
||||||
error::Error,
|
|
||||||
sync::{atomic::Ordering, Arc},
|
|
||||||
};
|
|
||||||
use tokio::{sync::mpsc::Sender, time::sleep};
|
use tokio::{sync::mpsc::Sender, time::sleep};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
messages::Message,
|
messages::Message,
|
||||||
player::{downloader::Downloader, Player},
|
player::{downloader::Downloader, Player, TIMEOUT},
|
||||||
tracks,
|
tracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
/// Fetches the next track from the queue, or a random track if the queue is empty.
|
/// 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.
|
/// This will also set the current track to the fetched track's info.
|
||||||
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::Error> {
|
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::TrackError> {
|
||||||
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
||||||
let track = self.tracks.write().await.pop_front();
|
let track = self.tracks.write().await.pop_front();
|
||||||
let track = if let Some(track) = track {
|
let track = if let Some(track) = track {
|
||||||
@ -26,8 +23,7 @@ impl Player {
|
|||||||
// We're doing it here so that we don't get the "loading" display
|
// 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.
|
// for only a frame in the other case that the buffer is not empty.
|
||||||
self.current.store(None);
|
self.current.store(None);
|
||||||
self.progress.store(0.0, Ordering::Relaxed);
|
self.list.random(&self.client).await?
|
||||||
self.list.random(&self.client, Some(&self.progress)).await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoded = track.decode()?;
|
let decoded = track.decode()?;
|
||||||
@ -60,9 +56,6 @@ impl Player {
|
|||||||
// Start playing the new track.
|
// Start playing the new track.
|
||||||
player.sink.append(track.data);
|
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
|
// Notify the background downloader that there's an empty spot
|
||||||
// in the buffer.
|
// in the buffer.
|
||||||
Downloader::notify(&itx).await?;
|
Downloader::notify(&itx).await?;
|
||||||
@ -71,12 +64,12 @@ impl Player {
|
|||||||
tx.send(Message::NewSong).await?;
|
tx.send(Message::NewSong).await?;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if debug {
|
|
||||||
panic!("{error} - {:?}", error.source())
|
|
||||||
}
|
|
||||||
|
|
||||||
if !error.is_timeout() {
|
if !error.is_timeout() {
|
||||||
sleep(player.timeout).await;
|
if debug {
|
||||||
|
panic!("{error:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(TIMEOUT).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.send(Message::TryAgain).await?;
|
tx.send(Message::TryAgain).await?;
|
||||||
|
@ -159,15 +159,16 @@ impl Window {
|
|||||||
/// The code for the terminal interface itself.
|
/// The code for the terminal interface itself.
|
||||||
///
|
///
|
||||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
/// * `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(
|
async fn interface(
|
||||||
player: Arc<Player>,
|
player: Arc<Player>,
|
||||||
minimalist: bool,
|
minimalist: bool,
|
||||||
borderless: bool,
|
borderless: bool,
|
||||||
debug: bool,
|
|
||||||
fps: u8,
|
fps: u8,
|
||||||
width: usize,
|
width: usize,
|
||||||
) -> eyre::Result<(), UIError> {
|
) -> eyre::Result<(), UIError> {
|
||||||
let mut window = Window::new(width, borderless || debug);
|
let mut window = Window::new(width, borderless);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Load `current` once so that it doesn't have to be loaded over and over
|
// Load `current` once so that it doesn't have to be loaded over and over
|
||||||
@ -196,10 +197,10 @@ async fn interface(
|
|||||||
|
|
||||||
let controls = components::controls(width);
|
let controls = components::controls(width);
|
||||||
|
|
||||||
let menu = match (minimalist, debug, player.current.load().as_ref()) {
|
let menu = if minimalist {
|
||||||
(true, _, _) => vec![action, middle],
|
vec![action, middle]
|
||||||
(false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
|
} else {
|
||||||
_ => vec![action, middle, controls],
|
vec![action, middle, controls]
|
||||||
};
|
};
|
||||||
|
|
||||||
window.draw(menu, false)?;
|
window.draw(menu, false)?;
|
||||||
@ -293,7 +294,6 @@ pub async fn start(
|
|||||||
Arc::clone(&player),
|
Arc::clone(&player),
|
||||||
args.minimalist,
|
args.minimalist,
|
||||||
args.borderless,
|
args.borderless,
|
||||||
args.debug,
|
|
||||||
args.fps,
|
args.fps,
|
||||||
21 + args.width.min(32) * 2,
|
21 + args.width.min(32) * 2,
|
||||||
));
|
));
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
//! Various different individual components that
|
//! Various different individual components that
|
||||||
//! appear in lowfi's UI, like the progress bar.
|
//! appear in lowfi's UI, like the progress bar.
|
||||||
|
|
||||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
ops::Deref as _,
|
||||||
|
sync::{atomic::Ordering, Arc},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crossterm::style::Stylize as _;
|
use crossterm::style::Stylize as _;
|
||||||
use unicode_segmentation::UnicodeSegmentation as _;
|
use unicode_segmentation::UnicodeSegmentation as _;
|
||||||
@ -59,17 +63,14 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
|||||||
|
|
||||||
/// This represents the main "action" bars state.
|
/// This represents the main "action" bars state.
|
||||||
enum ActionBar {
|
enum ActionBar {
|
||||||
/// When the app is paused.
|
/// When the app is currently displaying "paused".
|
||||||
Paused(Info),
|
Paused(Info),
|
||||||
|
|
||||||
/// When the app is playing.
|
/// When the app is currently displaying "playing".
|
||||||
Playing(Info),
|
Playing(Info),
|
||||||
|
|
||||||
/// When the app is loading.
|
/// When the app is currently displaying "loading".
|
||||||
Loading(f32),
|
Loading,
|
||||||
|
|
||||||
/// When the app is muted.
|
|
||||||
Muted,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionBar {
|
impl ActionBar {
|
||||||
@ -79,16 +80,7 @@ impl ActionBar {
|
|||||||
let (word, subject) = match self {
|
let (word, subject) = match self {
|
||||||
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
||||||
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
||||||
Self::Loading(progress) => {
|
Self::Loading => ("loading", None),
|
||||||
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(
|
subject.map_or_else(
|
||||||
@ -107,23 +99,16 @@ impl ActionBar {
|
|||||||
/// This also creates all the needed padding.
|
/// This also creates all the needed padding.
|
||||||
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
||||||
let (main, len) = current
|
let (main, len) = current
|
||||||
.map_or_else(
|
.map_or(ActionBar::Loading, |info| {
|
||||||
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|
let info = info.deref().clone();
|
||||||
|info| {
|
|
||||||
let info = info.deref().clone();
|
|
||||||
|
|
||||||
if player.sink.volume() < 0.01 {
|
if player.sink.is_paused() {
|
||||||
return ActionBar::Muted;
|
ActionBar::Paused(info)
|
||||||
}
|
} else {
|
||||||
|
ActionBar::Playing(info)
|
||||||
if player.sink.is_paused() {
|
}
|
||||||
ActionBar::Paused(info)
|
})
|
||||||
} else {
|
.format(player.bookmarked.load(Ordering::Relaxed));
|
||||||
ActionBar::Playing(info)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.format(player.bookmarks.bookmarked());
|
|
||||||
|
|
||||||
if len > width {
|
if len > width {
|
||||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||||
|
@ -1,88 +1,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use clap::ValueEnum;
|
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;
|
pub mod lofigirl;
|
||||||
|
|
||||||
/// Represents the different sources which can be scraped.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
|
||||||
pub enum Source {
|
pub enum Sources {
|
||||||
Lofigirl,
|
Lofigirl,
|
||||||
Archive,
|
|
||||||
Chillhop,
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
@ -1,223 +0,0 @@
|
|||||||
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 futures::{stream::FuturesOrdered, StreamExt};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use reqwest::Client;
|
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
use crate::scrapers::get;
|
const BASE_URL: &str = "https://lofigirl.com/wp-content/uploads/";
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
||||||
let document = get(client, path, super::Source::Lofigirl).await?;
|
let response = reqwest::get(format!("{}{}", BASE_URL, path)).await?;
|
||||||
let html = Html::parse_document(&document);
|
let document = response.text().await?;
|
||||||
|
|
||||||
|
let html = Html::parse_document(&document);
|
||||||
Ok(html
|
Ok(html
|
||||||
.select(&SELECTOR)
|
.select(&SELECTOR)
|
||||||
.skip(5)
|
.skip(5)
|
||||||
@ -29,9 +29,10 @@ async fn parse(client: &Client, 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.
|
/// 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.
|
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||||
async fn scan() -> eyre::Result<Vec<String>> {
|
async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>> {
|
||||||
let client = Client::new();
|
let extension = &format!(".{}", extension);
|
||||||
let items = parse(&client, "/").await?;
|
|
||||||
|
let items = parse("").await?;
|
||||||
|
|
||||||
let mut years: Vec<u32> = items
|
let mut years: Vec<u32> = items
|
||||||
.iter()
|
.iter()
|
||||||
@ -47,19 +48,22 @@ async fn scan() -> eyre::Result<Vec<String>> {
|
|||||||
let mut futures = FuturesOrdered::new();
|
let mut futures = FuturesOrdered::new();
|
||||||
|
|
||||||
for year in years {
|
for year in years {
|
||||||
let months = parse(&client, &year.to_string()).await?;
|
let months = parse(&year.to_string()).await?;
|
||||||
|
|
||||||
for month in months {
|
for month in months {
|
||||||
let client = client.clone();
|
|
||||||
futures.push_back(async move {
|
futures.push_back(async move {
|
||||||
let path = format!("{}/{}", year, month);
|
let path = format!("{}/{}", year, month);
|
||||||
|
|
||||||
let items = parse(&client, &path).await.unwrap();
|
let items = parse(&path).await.unwrap();
|
||||||
items
|
items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|x| {
|
.filter_map(|x| {
|
||||||
if x.ends_with(".mp3") {
|
if x.ends_with(extension) {
|
||||||
Some(format!("{path}{x}"))
|
if include_full {
|
||||||
|
Some(format!("{BASE_URL}{path}{x}"))
|
||||||
|
} else {
|
||||||
|
Some(format!("{path}{x}"))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -77,8 +81,8 @@ async fn scan() -> eyre::Result<Vec<String>> {
|
|||||||
eyre::Result::Ok(files)
|
eyre::Result::Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scrape() -> eyre::Result<()> {
|
pub async fn scrape(extension: String, include_full: bool) -> eyre::Result<()> {
|
||||||
let files = scan().await?;
|
let files = scan(&extension, include_full).await?;
|
||||||
for file in files {
|
for file in files {
|
||||||
println!("{file}");
|
println!("{file}");
|
||||||
}
|
}
|
||||||
|
134
src/tracks.rs
134
src/tracks.rs
@ -18,19 +18,43 @@
|
|||||||
use std::{io::Cursor, path::Path, time::Duration};
|
use std::{io::Cursor, path::Path, time::Duration};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use convert_case::{Case, Casing};
|
use inflector::Inflector as _;
|
||||||
use regex::Regex;
|
|
||||||
use rodio::{Decoder, Source as _};
|
use rodio::{Decoder, Source as _};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io;
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
pub mod error;
|
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
|
||||||
pub use error::Error;
|
/// 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,
|
||||||
|
|
||||||
use crate::tracks::error::Context;
|
#[error("unable to decode")]
|
||||||
use lazy_static::lazy_static;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Just a shorthand for a decoded [Bytes].
|
/// Just a shorthand for a decoded [Bytes].
|
||||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||||
@ -68,7 +92,7 @@ impl QueuedTrack {
|
|||||||
/// This will actually decode and format the track,
|
/// This will actually decode and format the track,
|
||||||
/// returning a [`DecodedTrack`] which can be played
|
/// returning a [`DecodedTrack`] which can be played
|
||||||
/// and also has a duration & formatted name.
|
/// and also has a duration & formatted name.
|
||||||
pub fn decode(self) -> eyre::Result<DecodedTrack, Error> {
|
pub fn decode(self) -> eyre::Result<DecodedTrack, TrackError> {
|
||||||
DecodedTrack::new(self)
|
DecodedTrack::new(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,35 +121,7 @@ pub struct Info {
|
|||||||
pub duration: Option<Duration>,
|
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 {
|
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.
|
/// Decodes a URL string into normal UTF-8.
|
||||||
fn decode_url(text: &str) -> String {
|
fn decode_url(text: &str) -> String {
|
||||||
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
||||||
@ -135,50 +131,35 @@ impl Info {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a name with [`convert_case`].
|
/// Formats a name with [Inflector].
|
||||||
///
|
|
||||||
/// This will also strip the first few numbers that are
|
/// This will also strip the first few numbers that are
|
||||||
/// usually present on most lofi tracks and do some other
|
/// usually present on most lofi tracks.
|
||||||
/// formatting operations.
|
fn format_name(name: &str) -> eyre::Result<String, TrackError> {
|
||||||
fn format_name(name: &str) -> eyre::Result<String, Error> {
|
|
||||||
let path = Path::new(name);
|
let path = Path::new(name);
|
||||||
|
|
||||||
let name = path
|
let stem = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|x| x.to_str())
|
.and_then(|x| x.to_str())
|
||||||
.ok_or((name, error::Kind::InvalidName))?;
|
.ok_or(TrackError::InvalidName)?;
|
||||||
|
let formatted = Self::decode_url(stem)
|
||||||
let name = Self::decode_url(name).to_lowercase();
|
.to_lowercase()
|
||||||
let mut name = name
|
.to_title_case()
|
||||||
.replace("masster", "master")
|
// Inflector doesn't like contractions...
|
||||||
.replace("(online-audio-converter.com)", "") // Some of these names, man...
|
// Replaces a few very common ones.
|
||||||
.replace('_', " ");
|
// TODO: Properly handle these.
|
||||||
|
.replace(" S ", "'s ")
|
||||||
// Get rid of "master" suffix with a few regex patterns.
|
.replace(" T ", "'t ")
|
||||||
for regex in MASTER_PATTERNS.iter() {
|
.replace(" D ", "'d ")
|
||||||
name = regex.replace(&name, "").to_string();
|
.replace(" Ve ", "'ve ")
|
||||||
}
|
.replace(" Ll ", "'ll ")
|
||||||
|
.replace(" Re ", "'re ")
|
||||||
name = ID_PATTERN.replace(&name, "").to_string();
|
.replace(" M ", "'m ");
|
||||||
|
|
||||||
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.
|
// This is incremented for each digit in front of the song name.
|
||||||
let mut skip = 0;
|
let mut skip = 0;
|
||||||
|
|
||||||
for character in name.as_bytes() {
|
for character in formatted.as_bytes() {
|
||||||
if character.is_ascii_digit()
|
if character.is_ascii_digit() {
|
||||||
|| *character == b'.'
|
|
||||||
|| *character == b')'
|
|
||||||
|| *character == b'('
|
|
||||||
{
|
|
||||||
skip += 1;
|
skip += 1;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@ -186,12 +167,12 @@ impl Info {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the entire name of the track is a number, then just return it.
|
// If the entire name of the track is a number, then just return it.
|
||||||
if skip == name.len() {
|
if skip == formatted.len() {
|
||||||
Ok(name.trim().to_string())
|
Ok(formatted)
|
||||||
} else {
|
} else {
|
||||||
// We've already checked before that the bound is at an ASCII digit.
|
// We've already checked before that the bound is at an ASCII digit.
|
||||||
#[allow(clippy::string_slice)]
|
#[allow(clippy::string_slice)]
|
||||||
Ok(String::from(name[skip..].trim()))
|
Ok(String::from(&formatted[skip..]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,7 +181,7 @@ impl Info {
|
|||||||
name: TrackName,
|
name: TrackName,
|
||||||
full_path: String,
|
full_path: String,
|
||||||
decoded: &DecodedData,
|
decoded: &DecodedData,
|
||||||
) -> eyre::Result<Self, Error> {
|
) -> eyre::Result<Self, TrackError> {
|
||||||
let (display_name, custom_name) = match name {
|
let (display_name, custom_name) = match name {
|
||||||
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||||
TrackName::Formatted(custom) => (custom, true),
|
TrackName::Formatted(custom) => (custom, true),
|
||||||
@ -229,12 +210,11 @@ pub struct DecodedTrack {
|
|||||||
impl DecodedTrack {
|
impl DecodedTrack {
|
||||||
/// Creates a new track.
|
/// Creates a new track.
|
||||||
/// This is equivalent to [`QueuedTrack::decode`].
|
/// This is equivalent to [`QueuedTrack::decode`].
|
||||||
pub fn new(track: QueuedTrack) -> eyre::Result<Self, Error> {
|
pub fn new(track: QueuedTrack) -> eyre::Result<Self, TrackError> {
|
||||||
let data = Decoder::builder()
|
let data = Decoder::builder()
|
||||||
.with_byte_len(track.data.len().try_into().unwrap())
|
.with_byte_len(track.data.len().try_into().unwrap())
|
||||||
.with_data(Cursor::new(track.data))
|
.with_data(Cursor::new(track.data))
|
||||||
.build()
|
.build()?;
|
||||||
.track(track.full_path.clone())?;
|
|
||||||
|
|
||||||
let info = Info::new(track.name, track.full_path, &data)?;
|
let info = Info::new(track.name, track.full_path, &data)?;
|
||||||
|
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
#[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,19 +1,13 @@
|
|||||||
//! The module containing all of the logic behind track lists,
|
//! The module containing all of the logic behind track lists,
|
||||||
//! as well as obtaining track names & downloading the raw audio data
|
//! as well as obtaining track names & downloading the raw audio data
|
||||||
|
|
||||||
use std::{cmp::min, sync::atomic::Ordering};
|
use bytes::Bytes;
|
||||||
|
|
||||||
use atomic_float::AtomicF32;
|
|
||||||
use bytes::{BufMut, Bytes, BytesMut};
|
|
||||||
use eyre::OptionExt as _;
|
use eyre::OptionExt as _;
|
||||||
use futures::StreamExt;
|
use rand::Rng as _;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::{
|
use crate::{data_dir, tracks::TrackError};
|
||||||
data_dir,
|
|
||||||
tracks::{self, error::Context},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::QueuedTrack;
|
use super::QueuedTrack;
|
||||||
|
|
||||||
@ -27,12 +21,8 @@ pub struct List {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
/// Just the raw file, but seperated by `/n` (newlines).
|
/// Just the raw file, but seperated by `/n` (newlines).
|
||||||
/// `lines[0]` is the base/heaeder, with the rest being tracks.
|
/// `lines[0]` is the base, with the rest being tracks.
|
||||||
lines: Vec<String>,
|
lines: Vec<String>,
|
||||||
|
|
||||||
/// The file path which the list was read from.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub path: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl List {
|
impl List {
|
||||||
@ -51,7 +41,7 @@ impl List {
|
|||||||
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
|
// 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
|
// 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.
|
// the start, so it's faster to just keep it in & work around it.
|
||||||
let random = fastrand::usize(1..self.lines.len());
|
let random = rand::thread_rng().gen_range(1..self.lines.len());
|
||||||
let line = self.lines[random].clone();
|
let line = self.lines[random].clone();
|
||||||
|
|
||||||
if let Some((first, second)) = line.split_once('!') {
|
if let Some((first, second)) = line.split_once('!') {
|
||||||
@ -62,12 +52,7 @@ impl List {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a raw track, but doesn't decode it.
|
/// Downloads a raw track, but doesn't decode it.
|
||||||
async fn download(
|
async fn download(&self, track: &str, client: &Client) -> Result<(Bytes, String), TrackError> {
|
||||||
&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.
|
// If the track has a protocol, then we should ignore the base for it.
|
||||||
let full_path = if track.contains("://") {
|
let full_path = if track.contains("://") {
|
||||||
track.to_owned()
|
track.to_owned()
|
||||||
@ -77,43 +62,28 @@ impl List {
|
|||||||
|
|
||||||
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
|
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
|
||||||
let path = if x.starts_with('~') {
|
let path = if x.starts_with('~') {
|
||||||
let home_path =
|
let home_path = dirs::home_dir().ok_or(TrackError::InvalidPath)?;
|
||||||
dirs::home_dir().ok_or((track, tracks::error::Kind::InvalidPath))?;
|
let home = home_path.to_str().ok_or(TrackError::InvalidPath)?;
|
||||||
let home = home_path
|
|
||||||
.to_str()
|
|
||||||
.ok_or((track, tracks::error::Kind::InvalidPath))?;
|
|
||||||
|
|
||||||
x.replace('~', home)
|
x.replace('~', home)
|
||||||
} else {
|
} else {
|
||||||
x.to_owned()
|
x.to_owned()
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = tokio::fs::read(path.clone()).await.track(track)?;
|
let result = tokio::fs::read(path).await?;
|
||||||
result.into()
|
result.into()
|
||||||
} else {
|
} else {
|
||||||
let response = client.get(full_path.clone()).send().await.track(track)?;
|
let response = match client.get(full_path.clone()).send().await {
|
||||||
|
Ok(x) => Ok(x),
|
||||||
if let Some(progress) = progress {
|
Err(x) => {
|
||||||
let total = response
|
if x.is_timeout() {
|
||||||
.content_length()
|
Err(TrackError::Timeout)
|
||||||
.ok_or((track, tracks::error::Kind::UnknownLength))?;
|
} else {
|
||||||
let mut stream = response.bytes_stream();
|
Err(TrackError::Request(x))
|
||||||
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()
|
response.bytes().await?
|
||||||
} else {
|
|
||||||
response.bytes().await.track(track)?
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((data, full_path))
|
Ok((data, full_path))
|
||||||
@ -123,17 +93,13 @@ impl List {
|
|||||||
///
|
///
|
||||||
/// The Result's error is a bool, which is true if a timeout error occured,
|
/// 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.
|
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
|
||||||
pub async fn random(
|
pub async fn random(&self, client: &Client) -> Result<QueuedTrack, TrackError> {
|
||||||
&self,
|
|
||||||
client: &Client,
|
|
||||||
progress: Option<&AtomicF32>,
|
|
||||||
) -> Result<QueuedTrack, tracks::Error> {
|
|
||||||
let (path, custom_name) = self.random_path();
|
let (path, custom_name) = self.random_path();
|
||||||
let (data, full_path) = self.download(&path, client, progress).await?;
|
let (data, full_path) = self.download(&path, client).await?;
|
||||||
|
|
||||||
let name = custom_name.map_or_else(
|
let name = custom_name.map_or_else(
|
||||||
|| super::TrackName::Raw(path.clone()),
|
|| super::TrackName::Raw(path.clone()),
|
||||||
super::TrackName::Formatted,
|
|formatted| super::TrackName::Formatted(formatted),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(QueuedTrack {
|
Ok(QueuedTrack {
|
||||||
@ -144,7 +110,7 @@ impl List {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parses text into a [List].
|
/// Parses text into a [List].
|
||||||
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
|
pub fn new(name: &str, text: &str) -> Self {
|
||||||
let lines: Vec<String> = text
|
let lines: Vec<String> = text
|
||||||
.trim_end()
|
.trim_end()
|
||||||
.lines()
|
.lines()
|
||||||
@ -153,7 +119,6 @@ impl List {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
lines,
|
lines,
|
||||||
path: path.map(ToOwned::to_owned),
|
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,27 +127,21 @@ impl List {
|
|||||||
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
|
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
|
||||||
if let Some(arg) = tracks {
|
if let Some(arg) = tracks {
|
||||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
// 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 name = data_dir()?.join(format!("{arg}.txt"));
|
||||||
let path = if path.exists() { path } else { arg.into() };
|
let name = if name.exists() { name } else { arg.into() };
|
||||||
|
|
||||||
let raw = fs::read_to_string(path.clone()).await?;
|
let raw = fs::read_to_string(name.clone()).await?;
|
||||||
|
|
||||||
// Get rid of special noheader case for tracklists without a header.
|
let name = name
|
||||||
let raw = raw
|
|
||||||
.strip_prefix("noheader")
|
|
||||||
.map_or(raw.as_ref(), |stripped| stripped);
|
|
||||||
|
|
||||||
let name = path
|
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|x| x.to_str())
|
.and_then(|x| x.to_str())
|
||||||
.ok_or_eyre("invalid track path")?;
|
.ok_or_eyre("invalid track path")?;
|
||||||
|
|
||||||
Ok(Self::new(name, raw, path.to_str()))
|
Ok(Self::new(name, &raw))
|
||||||
} else {
|
} else {
|
||||||
Ok(Self::new(
|
Ok(Self::new(
|
||||||
"chillhop",
|
"lofigirl",
|
||||||
include_str!("../../data/chillhop.txt"),
|
include_str!("../../data/lofigirl.txt"),
|
||||||
None,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user