Compare commits

...

23 Commits

Author SHA1 Message Date
talwat
e702c1de00 docs: update feature list and fix some wording 2025-09-25 19:40:19 +02:00
talwat
8f837302c3 docs: update list of flags 2025-09-25 19:25:03 +02:00
talwat
226c674295 style: format code 2025-09-25 19:19:43 +02:00
talwat
66f2243b2c fix: don't crash if bookmarks.txt is missing
this was a one line fix that i just completely forgot about whilst improving the bookmarking system.
i feel very, very stupid.
2025-09-25 19:16:22 +02:00
Dario Griffo
05fe8069ea
docs: fix debian mirror url
Fix mirror url
2025-09-25 18:42:34 +02:00
talwat
9b61dffb12 chore: bump version due to mistake with scraper
yes, i messed up... it's fixed now.
2025-09-25 16:07:32 +02:00
talwat
c2530453fb chore: rescrape and update chillhop list 2025-09-25 16:07:07 +02:00
talwat
e4fd542edf chore: purge a few more songs from scraper 2025-09-25 16:03:32 +02:00
talwat
632b298de2 docs: properly link music.md 2025-09-25 15:50:26 +02:00
talwat
41ba98b9cf docs: add leading newline to MUSIC.md 2025-09-25 15:40:47 +02:00
Tal
0162421db4
feat: the 1.7.0 release (#97)
* docs: update to be relevant to the current version

* chore: bump version

* fix: change default progress to 0

why it was ever -1.0 is a mystery to me, it doesn't make any logical
sense...

* fix: switch from rand to fastrand

* feat: prepare for 1.7.0 release

docs: explain music situation
docs: more internal documentation
feat: make timeout configurable
chore: clean up some sections of code

* fix: use boring fs functions for bookmark loading and writing

* chore: remove useless internal doc

* chore: bump version
2025-09-25 15:39:20 +02:00
Lim Ding Wen
4d4f5e0920
docs: add Fedora installation dependencies (#98) 2025-09-12 16:16:28 +02:00
danielwerg
d41bd16069
chore(chillhop): remove tracks with lyrics (#99) 2025-09-12 11:01:20 +02:00
Dan
d2c8bdb8aa
chore: update README.md (#96)
* chore: Update README.md

updated the custom tracklists section with the proper command

* Update README.md
2025-09-06 20:57:02 +02:00
talwat
f6ec3bb1fe feat: switch to chillhop by default
feat: add special noheader exception for legibility of tracklists
feat: add small muted display
docs: fix macos instructions
2025-08-21 23:56:32 +02:00
Tal
6f679055ea chore: remove a few tracks with lyrics 2025-08-19 20:20:56 +02:00
Tal
1bb3ee2e02 fix: also split off letter ids 2025-08-10 17:18:29 +02:00
Tal
d60dc362ca feat: add percent loading indicator
chore: switch from inflector to convert case
chore: tweak timeout settings again
fix: make debug mode more useful by showing full track path
fix: strip url from reqwest errors
2025-08-10 16:22:37 +02:00
talwat
3e0cbf9871 fix: formatting touch ups and timeout adjustment 2025-08-10 00:27:37 +02:00
talwat
6f15f9226f feat: add archive scraper
feat: add more information to errors
2025-08-09 23:38:00 +02:00
Tal
bdd508bfbb
feat: chillhop scraper (#94)
* feat: start work on chillhop scraper

only the basic get request with caching has actually been implemented,
but the rest shouldn't be too complicated.

* feat: finish chillhop scraper

* chore: remove vscode dir

* chore: upload new chillhop list for comparison

* fix: improve scraper and remove duplicates

* style: reorganize release scan function slightly

* fix: make lowfi compile on non-linux

* feat: make scraper fully reproducable (hopefully)

* chore: remove useless mut

* feat: add scrape feature flag

* chore: update deps

* chore: i hate macos

* chore: add .DS_Store to gitignore

* fix: ignore two tracks with lyrics

* fix: seperate get function from chillhop scraper

* fix: linux audio output stream

* chore: replace chillhop old list
2025-08-09 19:18:50 +02:00
talwat
19ca315509 fix: make lowfi compile on non-linux 2025-08-08 20:12:13 +02:00
Tal
620b568926 fix(bookmarks): don't write to the bookmarks file on every bookmark 2025-08-07 00:08:11 +02:00
31 changed files with 5244 additions and 1663 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
/target
/cache
.DS_Store

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,2 @@
file:///home/user/Music/
Anomaly.mp3
Test.mp3

2
data/noheader.txt Normal file
View File

@ -0,0 +1,2 @@
noheader
https://stream.chillhop.com/mp3/9476

View File

@ -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
View File

@ -0,0 +1,4 @@
#!/bin/sh
grep -rlZ "429 Too Many Requests" . | xargs -0 rm -f
find . -type f -empty -delete

View File

@ -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?;

View File

@ -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(())
}

View File

@ -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,
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;
}
}
_ => {}
}
}

View File

@ -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),
}

View File

@ -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?;

View File

@ -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,
));

View File

@ -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();

View File

@ -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
View 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
View 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)
}

View File

@ -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}");
}

View File

@ -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
View 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()
})
}
}

View File

@ -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,
))
}
}