Compare commits

..

No commits in common. "main" and "1.5.1" have entirely different histories.
main ... 1.5.1

35 changed files with 2057 additions and 7875 deletions

2
.gitignore vendored
View File

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

View File

@ -1,24 +0,0 @@
# Using the chillhop list
## Linux
```sh
mkdir -p ~/.local/share/lowfi
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir ~/.local/share/lowfi
```
## MacOS
```sh
mkdir -p "$HOME/Library/Application Support/lowfi"
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir "$HOME/Library/Application Support/lowfi"
```
## Windows
Go to `%appdata%` in Explorer, then `Roaming`, and make a folder called `lowfi`.
Then just put [this file](https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt) in there.
## Launching lowfi
Once the list has been added, just launch `lowfi` with `-t chillhop`.

1518
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.5.1"
edition = "2021"
description = "An extremely simple lofi player."
license = "MIT"
@ -18,47 +18,36 @@ 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 = "0.6.12"
clap = { version = "4.5.18", features = ["derive", "cargo"] }
eyre = { version = "0.6.12" }
rand = "0.8.5"
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 }
futures = "0.3.31"
tokio = { version = "1.40.0", features = [
"macros",
"rt-multi-thread",
"fs"
], default-features = false }
futures = "0.3.30"
arc-swap = "1.7.1"
# Data
reqwest = { version = "0.12.9", features = ["stream"] }
bytes = "1.9.0"
reqwest = "0.12.7"
bytes = "1.7.2"
# I/O
crossterm = { version = "0.28.1", features = ["event-stream"] }
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
rodio = { version = "0.19.0", features = ["symphonia-mp3"], default-features = false }
mpris-server = { version = "0.8.1", optional = true }
dirs = "5.0.1"
# Misc
convert_case = "0.8.0"
scraper = "0.20.0"
Inflector = "0.11.4"
lazy_static = "1.5.0"
url = "2.5.4"
unicode-segmentation = "1.12.0"
# Scraper
serde = { version = "1.0.219", features = ["derive"], optional = true }
serde_json = { version = "1.0.142", optional = true }
scraper = { version = "0.21.0", optional = true }
html-escape = { version = "0.2.13", optional = true }
indicatif = { version = "0.18.0", optional = true }
regex = "1.11.1"
atomic_float = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.167"
libc = "0.2.159"
url = "2.5.2"
unicode-width = "0.2.0"

View File

@ -1,7 +0,0 @@
# Environment Variables
Lowfi has some more specific options, usually as a result of minor feature requests, which are only documented here.
If you have some behaviour you'd like to change, which is quite specific, then see if one of these options suits you.
* `LOWFI_FIXED_MPRIS_NAME` - Limits the number of lowfi instances to one, but ensures the player name is always `lowfi`.
* `LOWFI_DISABLE_UI` - Disables the UI.

140
README.md
View File

@ -7,29 +7,24 @@ 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,
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
under their [licensing guidelines](https://form.lofigirl.com/CommercialLicense).
If, god forbid, you're planning to use lowfi in a commercial setting, please
If god forbid you're planning to use this in a commercial setting, please
follow their rules.
## Why?
I really hate modern music platforms, and I wanted a small, "suckless"
app that would just play random lofi without video.
app that would literally just play lofi without video so I could use it
whenever.
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.
I also wanted it to be fairly resiliant to inconsistent networks,
so it buffers 5 whole songs at a time instead of parts of the same song.
See [Scraping](#scraping) if you're interested in downloading the tracks.
Beware, there's a lot of them.
Although, lowfi is yet to be properly tested in difficult conditions,
so don't rely on it too much until I do that. See [Scraping](#scraping) if
you're interested in downloading the tracks. Beware, there's a lot of them.
## Installing
@ -49,7 +44,7 @@ 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.
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
Make sure to also install `pulseaudio-alsa` if you're using pulseaudio.
### Cargo
@ -71,6 +66,8 @@ precompiled binaries from the [latest release](https://github.com/talwat/lowfi/r
### AUR
If you're on Arch, you can also use the AUR:
```sh
yay -S lowfi
```
@ -81,41 +78,14 @@ yay -S lowfi
zypper install lowfi
```
### Debian
> [!NOTE]
> This uses an unofficial Debian repository maintained by [Dario Griffo](https://github.com/dariogriffo).
```sh
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
echo "deb https://debian.griffo.io//apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
sudo apt install -y lowfi
```
### Fedora (COPR)
> [!NOTE]
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
```sh
sudo dnf copr enable furqanhun/lowfi
sudo dnf install lowfi
```
### Manual
This is good for debugging, especially in issues.
```sh
git clone https://github.com/talwat/lowfi
cd lowfi
# If you want an actual binary
cargo build --release --all-features
cargo build --release
./target/release/lowfi
# If you just want to test
cargo run --all-features
```
## Usage
@ -126,39 +96,17 @@ Yeah, that's it.
### Controls
| Key | Function |
| ------------------ | --------------- |
| `s`, `n`, `l` | Skip Song |
| `p`, Space | Play/Pause |
| `+`, `=`, `k`, `↑` | Volume Up 10% |
| `→` | Volume Up 1% |
| `-`, `_`, `j`, `↓` | Volume Down 10% |
| `←` | Volume Down 1% |
| `q`, CTRL+C | Quit |
> [!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`)
> due to it being only for Linux, as well as the fact that the main point of
> lowfi is it's unique & minimal interface.
| Key | Function |
|-------|----------------|
| `s` | Skip song |
| `p` | Play/Pause |
| `+/-` | Volume Up/Down |
| `q` | Quit |
### Extra Flags
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] |
If you have something you'd like to tweak about lowfi, you can run `lowfi help`
to view the available options.
### Scraping
@ -174,12 +122,6 @@ where more information can be found by running `lowfi help scrape`.
### 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]
>
> Custom track lists are going to be pretty particular.
@ -189,7 +131,7 @@ Feel free to contribute your own list with a PR.
> 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
lowfi also can support custom track lists, although the default one with Lofi Girl's
is embedded into the binary.
To use a custom list, use the `--tracks` flag. This can either be a path to some file,
@ -197,21 +139,19 @@ or it could also be the name of a file (without the `.txt` extension) in the dat
directory, so on Linux it's `~/.local/share/lowfi`.
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
Whereas if you did `lowfi --tracks /home/user/Music/minipop.txt` it would load from that
specified directory.
#### 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 List's, the first line should be the base URL, followed by the rest of the tracks.
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.
the track. All tracks should end in `.mp3` and as such must be in the MP3 format.
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.
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.
For example, in this list:
@ -219,31 +159,11 @@ For example, in this list:
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://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3
```
lowfi would download these three URLs:
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
- `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
- `https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3`
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
Additionally, you may also specify a custom display name for the track which is indicated by a `!`.
For example, if you had an entry like this:
```txt
2023/04/2-In-Front-Of-Me.mp3!custom name
```
Then lowfi would download from the first section, and display the second as the track name.
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:
```txt
file:///home/user/Music/
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.

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

16
data/micropop.txt Normal file
View File

@ -0,0 +1,16 @@
https://archive.org/download/jack-stauber-s-micropop-extended-micropops/Jack%20Stauber-%27s%20Micropop%20-%20
Al%20Dente.mp3
Baby%20Hotline.mp3
Cupid.mp3
Deploy.mp3
Dinner%20Is%20Not%20Over.mp3
Fighter.mp3
Inchman.mp3
Keyman.mp3
Out%20the%20Ox.mp3
Tea%20Errors.mp3
The%20Ballad%20of%20Hamantha.mp3
There%27s%20Something%20Happening.mp3
Those%20Eggs%20Aren%27t%20Dippy%20.mp3
Today%20Today.mp3
Two%20Time.mp3

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://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3

View File

@ -1,115 +0,0 @@
https://lofigirl.com/wp-content/uploads/
2024/01/1.-i_m-alone-out-here-ft.-Outgrown-master.mp3
2024/01/2.-aurora-ft.-Outgrown-master.mp3
2024/01/3.-dusk-master.mp3
2024/01/4.-aria-ft.-after-noon-master.mp3
2024/01/5.-dreamscape-ft.-Luke-Tidbury-master.mp3
2023/11/Le-Metroid-Crystal-Children.mp3
2023/11/Le-Metroid-Frequencies.mp3
2023/11/Le-Metroid-Space-Echoes.mp3
2023/11/Le-Metroid-Voyager.mp3
2023/11/Le-Metroid-Orion.mp3
2023/11/Le-Metroid-Saturne.mp3
2023/11/Le-Metroid-Speed-Light.wav
2023/11/Le-Metroid-Earth-21.wav
2023/11/Le-Metroid-Sleepwalker.mp3
2023/11/Le-Metroid-Blackhole.mp3
2023/09/01-Akraa-Lightyears-Kupla-master.mp3
2023/09/02-Virtua-_-Robert-Iver-Analogue-Pulsar-Kupla-master.mp3
2023/09/03-Starwave-Soundgo-Kupla-Master.mp3
2023/09/04-Forhill-_-Eagle-Eyed-Tiger-Infuse-Kupla-Master.mp3
2023/09/05-VIQ-x-Krosia-Echodrift-Kupla-Master.mp3
2023/09/06-Tbeauthetraveler-Soare-Kupla-master.mp3
2023/09/07-Boy-From-Nowhere-Bleue-Astrale-Kupla-Master-.mp3
2023/09/08-Virtua-Searching-Kupla-Master.mp3
2023/09/09-Electrosky-Soundgo-Kupla-Master.mp3
2023/09/10-Protocols-Orion-Kupla-Master.mp3
2023/09/11-Hotel_Pools_Satin_Kupla-Master.mp3
2023/09/12-Downtown-Binary-Icarus-Kupla-Master.mp3
2023/09/13-A.L.I.S.O.N-Erebus-Kupla-Master.mp3
2023/09/14-Girl-From-Nowhere-x-Boy-From-Nowhere-Diamond-Kupla-Master.mp3
2023/09/15-Kabes-x-Protocols-Static-Kupla-Master.mp3
2023/09/16-VIQ-Somewhere-Kupla-Master.mp3
2023/09/17-Akraa-Virtua-Portal-Kupla-Master-2.mp3
2023/09/18-Nitewalk-x-SwuM-Last-Cosmo-Kupla-Master.mp3
2023/09/19-Nitewalk-Prismatic-Waves-Kupla-Master3.mp3
2023/09/20-Theo-Aabel-Night-Owl-Kupla-Master.mp3
2023/06/Foudroie-Journey-2023.mp3
2023/06/Foudroie-Odyssey-MASTER.mp3
2023/06/Foudroie-Solar-Wind-MASTER.mp3
2023/06/Foudroie-x-Forhill-Interstellar-MASTER.mp3
2023/06/Foudroie-The-Traveler-2023.mp3
2023/06/Foudroie-Moon-Dust-2023.mp3
2023/06/Foudroie-Departure-2023.mp3
2023/06/Foudroie-Interpolation-2023.mp3
2023/06/Foudroie-Lunar-2023.mp3
2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/06/A1Descend.mp3
2023/06/A3S.O.L.O.mp3
2023/06/B4K.E.Y.S.mp3
2023/06/EmilRottmayerW.A.V.E.mp3
2023/06/EmilRottmayerMEGA.mp3
2023/06/1.-steezy-prime-better-days-w_-devon-rea-_-fnonose.mp3
2023/06/2.-steezy-prime-deep-thoughts-w_-beyond-pluto.mp3
2023/06/3.-steezy-prime-in-the-air.mp3
2023/06/4.-steezy-prime-gemini-w_-laffey.mp3
2023/06/5.-steezy-prime-it_s-getting-late-w_-mildred-_-devon-rea.mp3
2023/06/6.-steezy-prime-slow-blink-w_-fnonose.mp3
2023/05/Polaris.mp3
2023/05/Taking-Flight.mp3
2023/05/Astral.mp3
2023/05/Atlantis.mp3
2023/05/Cirrus.mp3
2023/05/Light-Cycles.mp3
2023/05/Umbra.mp3
2023/05/Shuto-Expressway.mp3
2023/05/Fantasia.mp3
2023/05/Winter.mp3
2023/05/Pandora.mp3
2023/05/4am.mp3
2023/05/Aurora.mp3
2023/05/Lost-Signal.mp3
2023/05/Reflect.mp3
2023/05/City-Lights.mp3
2023/05/Pools.mp3
2023/05/Void.mp3
2023/05/Distant.mp3
2023/05/1.-Voyage-Beyond.mp3
2023/05/2.-A.L.I.S.O.N-always-in-my-dreams.mp3
2023/05/3.Hotel-Pools-Limits.mp3
2023/05/4.-Downtown-Binary-Atlantis.mp3
2023/05/5.-Voyage-Moon-Phase.mp3
2023/05/6.Downtown-Binary-_-The-Present-Sound-Polaris.mp3
2023/05/7.-Hotel-Pools-_-A.L.I.S.O.N_Lunar.mp3
2023/05/8.Hotel-Pools-Snowfall.mp3
2023/05/9.-oDDling-Drifting.mp3
2023/05/10.-Hotel-Pools-_-oDDling-Remain.mp3
2023/05/11.-A.L.I.S.O.N-Subtract.mp3
2023/05/13.-oDDling-Void.mp3
2023/05/14.-EmilRottmayer-Elevate.mp3
2023/05/15.-Transparent-transparent.mp3
2023/05/16.-Neon-Galaxy-Portal.mp3
2023/05/17.-KING-PALM-Luxury.mp3
2023/05/18.-Departure-Imagine.mp3
2023/05/19.-Voyage-Center-Point.mp3
2023/05/20.-A.L.I.S.O.N-Nightride-Revisited.mp3
2023/05/21.-VIQ-Orbit.mp3
2023/05/22.-KING-PALM-Journey.mp3
2023/05/23.-Krosia-Crystal-Bells.mp3
2023/05/24.-EVANS-third-planet.mp3
2023/05/25.-Hotel-Pools-Evolve.mp3
2023/05/26.-KING-PALM-Coast.mp3
2023/05/27.-Monolism-infinitespace.mp3
2023/05/28.-oDDling-Divide.mp3
2023/05/29.-GRAEDA-Deluge.mp3
2023/05/30.-Xtract-Audiotool-Day-2016.mp3
2023/05/31.-MEGAS-Stargazer.mp3
2023/05/32.-Hotel-Pools-_-Forhill-Descent.mp3
2023/05/33.-Hotel-Pool-_-Memorex-Memories-Distance.mp3
2023/05/34.-GRAEDA-Barranca.mp3
2023/05/35.-Davz-Mindless.mp3
2023/05/36.-Unfound-Rise.mp3
2023/05/37.-oDDling-Reverie.mp3
2023/05/38.-Unfound-Heaven.mp3
2023/05/39.-Novus-Sana-Sound-of-the-Sky.mp3
2023/05/40.-Krosia-Sonar.mp3

View File

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

View File

@ -1,61 +1,33 @@
//! An extremely simple lofi player.
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod messages;
mod play;
mod player;
mod scrape;
mod tracks;
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
#[cfg(feature = "scrape")]
mod scrapers;
#[cfg(feature = "scrape")]
use crate::scrapers::Source;
/// An extremely simple lofi player.
#[derive(Parser, Clone)]
#[derive(Parser)]
#[command(about, version)]
#[allow(clippy::struct_excessive_bools)]
struct Args {
/// Use an alternate terminal screen.
/// Whether to use an alternate terminal screen.
#[clap(long, short)]
alternate: bool,
/// Hide the bottom control bar.
/// Whether to hide the bottom control bar.
#[clap(long, short)]
minimalist: bool,
/// Exclude borders in UI.
#[clap(long, short)]
borderless: bool,
/// Start lowfi paused.
/// Whether to start lowfi paused.
#[clap(long, short)]
paused: bool,
/// FPS of the UI.
#[clap(long, short, default_value_t = 12)]
fps: u8,
/// Include ALSA & other logs.
/// Whether to include ALSA & other logs.
#[clap(long, short)]
debug: bool,
/// Width of the player, from 0 to 32.
#[clap(long, short, default_value_t = 3)]
width: usize,
/// Use a custom track list
/// This is either a path, or a name of a file in the data directory (eg. ~/.local/share/lowfi).
#[clap(long, short, alias = "list", short_alias = 'l')]
track_list: Option<String>,
/// Internal song buffer size.
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
buffer_size: usize,
tracks: Option<String>,
/// The command that was ran.
/// This is [None] if no command was specified.
@ -64,44 +36,32 @@ struct Args {
}
/// Defines all of the extra commands lowfi can run.
#[derive(Subcommand, Clone)]
#[derive(Subcommand)]
enum Commands {
/// Scrapes a music source for files.
#[cfg(feature = "scrape")]
/// Scrapes the lofi girl website file server for files.
Scrape {
// The source to scrape from.
source: scrapers::Source,
/// 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,
},
}
/// Gets lowfi's data directory.
pub fn data_dir() -> eyre::Result<PathBuf, player::Error> {
let dir = dirs::data_dir()
.ok_or(player::Error::DataDir)?
.join("lowfi");
Ok(dir)
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
color_eyre::install()?;
let cli = Args::parse();
if let Some(command) = cli.command {
match command {
// TODO: Actually distinguish between sources.
#[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?,
},
Commands::Scrape {
extension,
include_full,
} => scrape::scrape(extension, include_full).await,
}
} else {
play::play(cli).await?;
};
Ok(())
play::play(cli).await
}
}

View File

@ -1,37 +0,0 @@
/// Handles communication between the frontend & audio player.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum Message {
/// Notifies the audio server that it should update the track.
Next,
/// Special in that this isn't sent in a "client to server" sort of way,
/// but rather is sent by a child of the server when a song has not only
/// been requested but also downloaded aswell.
NewSong,
/// This signal is only sent if a track timed out. In that case,
/// lowfi will try again and again to retrieve the track.
TryAgain,
/// Similar to Next, but specific to the first track.
Init,
/// Unpause the [Sink].
#[allow(dead_code, reason = "this code may not be dead depending on features")]
Play,
/// Pauses the [Sink].
Pause,
/// Pauses the [Sink]. This will also unpause it if it is paused.
PlayPause,
/// Change the volume of playback.
ChangeVolume(f32),
/// Bookmark the current track.
Bookmark,
/// Quits gracefully.
Quit,
}

View File

@ -1,76 +1,94 @@
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
use crossterm::cursor::Show;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::terminal::{self, Clear, ClearType};
use std::io::{stdout, IsTerminal};
use std::process::exit;
use std::path::PathBuf;
use std::sync::Arc;
use std::{env, panic};
use eyre::eyre;
use tokio::fs;
use tokio::{sync::mpsc, task};
use crate::messages::Message;
use crate::player::persistent_volume::PersistentVolume;
use crate::player::Player;
use crate::player::{self, ui};
use crate::player::{ui, Messages};
use crate::Args;
/// This is the representation of the persistent volume,
/// which is loaded at startup and saved on shutdown.
#[derive(Clone, Copy)]
pub struct PersistentVolume {
/// The volume, as a percentage.
inner: u16,
}
impl PersistentVolume {
/// Retrieves the config directory.
async fn config() -> eyre::Result<PathBuf> {
let config = dirs::config_dir()
.ok_or(eyre!("Couldn't find config directory"))?
.join(PathBuf::from("lowfi"));
if !config.exists() {
fs::create_dir_all(&config).await?;
}
Ok(config)
}
/// Returns the volume as a float from 0 to 1.
pub fn float(&self) -> f32 {
self.inner as f32 / 100.0
}
/// Loads the [PersistentVolume] from [dirs::config_dir()].
pub async fn load() -> eyre::Result<Self> {
let config = Self::config().await?;
let volume = config.join(PathBuf::from("volume.txt"));
// Basically just read from the volume file if it exists, otherwise return 100.
let volume = if volume.exists() {
let contents = fs::read_to_string(volume).await?;
let trimmed = contents.trim();
let stripped = trimmed.strip_suffix("%").unwrap_or(&trimmed);
stripped
.parse()
.map_err(|_| eyre!("volume.txt file is invalid"))?
} else {
fs::write(&volume, "100").await?;
100u16
};
Ok(PersistentVolume { inner: volume })
}
/// Saves `volume` to `volume.txt`.
pub async fn save(volume: f32) -> eyre::Result<()> {
let config = Self::config().await?;
let path = config.join(PathBuf::from("volume.txt"));
fs::write(path, ((volume * 100.0).abs().round() as u16).to_string()).await?;
Ok(())
}
}
/// Initializes the audio server, and then safely stops
/// it when the frontend quits.
pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
// TODO: This isn't a great way of doing things,
// but it's better than vanilla behaviour at least.
let eyre_hook = panic::take_hook();
panic::set_hook(Box::new(move |x| {
let mut lock = stdout().lock();
crossterm::execute!(
lock,
Clear(ClearType::FromCursorDown),
Show,
PopKeyboardEnhancementFlags
)
.unwrap();
terminal::disable_raw_mode().unwrap();
eyre_hook(x);
exit(1)
}));
pub async fn play(args: Args) -> eyre::Result<()> {
// Actually initializes the player.
// Stream kept here in the master thread to keep it alive.
let (player, stream) = Player::new(&args).await?;
let player = Arc::new(player);
let player = Arc::new(Player::new(&args).await?);
// Initialize the UI, as well as the internal communication channel.
let (tx, rx) = mpsc::channel(8);
let ui = if stdout().is_terminal() && !(env::var("LOWFI_DISABLE_UI") == Ok("1".to_owned())) {
Some(task::spawn(ui::start(
Arc::clone(&player),
tx.clone(),
args.clone(),
)))
} else {
None
};
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
// Sends the player an "init" signal telling it to start playing a song straight away.
tx.send(Message::Init).await?;
tx.send(Messages::Init).await?;
// Actually starts the player.
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
// Save the volume.txt file for the next session.
PersistentVolume::save(player.sink.volume())
.await
.map_err(player::Error::PersistentVolumeSave)?;
// Save the bookmarks for the next session.
player.bookmarks.save().await?;
drop(stream);
PersistentVolume::save(player.sink.volume()).await?;
player.sink.stop();
ui.map(|x| x.abort());
ui.abort();
Ok(())
}

View File

@ -2,13 +2,13 @@
//! This also has the code for the underlying
//! audio server which adds new tracks.
use std::{collections::VecDeque, sync::Arc, time::Duration};
use std::{collections::VecDeque, ffi::CString, sync::Arc, time::Duration};
use arc_swap::ArcSwapOption;
use atomic_float::AtomicF32;
use downloader::Downloader;
use libc::freopen;
use reqwest::Client;
use rodio::{OutputStream, OutputStreamBuilder, Sink};
use rodio::{OutputStream, OutputStreamHandle, Sink};
use tokio::{
select,
sync::{
@ -18,36 +18,62 @@ use tokio::{
task,
};
#[cfg(feature = "mpris")]
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
use crate::{
messages::Message,
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
play::PersistentVolume,
tracks::{self, list::List},
Args,
};
pub mod audio;
pub mod bookmark;
pub mod downloader;
pub mod error;
pub mod persistent_volume;
pub mod queue;
pub mod ui;
pub use error::Error;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Handles communication between the frontend & audio player.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum Messages {
/// Notifies the audio server that it should update the track.
Next,
/// Special in that this isn't sent in a "client to server" sort of way,
/// but rather is sent by a child of the server when a song has not only
/// been requested but also downloaded aswell.
NewSong,
/// This signal is only sent if a track timed out. In that case,
/// lowfi will try again and again to retrieve the track.
TryAgain,
/// Similar to Next, but specific to the first track.
Init,
/// Unpause the [Sink].
Play,
/// Pauses the [Sink].
Pause,
/// Pauses the [Sink]. This will also unpause it if it is paused.
PlayPause,
/// Change the volume of playback.
ChangeVolume(f32),
/// Quits gracefully.
Quit,
}
/// The time to wait in between errors.
/// TODO: Make this configurable.
const TIMEOUT: Duration = Duration::from_secs(3);
const TIMEOUT: Duration = Duration::from_secs(5);
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
/// Main struct responsible for queuing up & playing tracks.
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
// TODO: In other words, this would change the type from `Arc<Player>` to just `Player`.
// TODO: Consider refactoring [Player] from being stored in an [Arc],
// TODO: so `Arc<Player>` into containing many smaller [Arc]s, being just
// TODO: `Player` as the type.
// TODO:
// TODO: This is conflicting, since then it'd clone ~10 smaller [Arc]s
// TODO: every single time, which could be even worse than having an
@ -56,25 +82,18 @@ pub struct Player {
/// [rodio]'s [`Sink`] which can control playback.
pub sink: Sink,
/// The internal buffer size.
pub buffer_size: usize,
/// The [`TrackInfo`] of the current track.
/// This is [`None`] when lowfi is buffering/loading.
current: ArcSwapOption<tracks::Info>,
/// The current progress for downloading tracks, if
/// `current` is None.
progress: AtomicF32,
/// This is the MPRIS server, which is initialized later on in the
/// user interface.
#[cfg(feature = "mpris")]
mpris: tokio::sync::OnceCell<mpris_server::Server<mpris::Player>>,
/// The tracks, which is a [`VecDeque`] that holds
/// 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,
tracks: RwLock<VecDeque<tracks::Track>>,
/// The actual list of tracks to be played.
list: List,
@ -82,15 +101,63 @@ pub struct Player {
/// The initial volume level.
volume: PersistentVolume,
/// The web client, which can contain a `UserAgent` & some
/// The web client, which can contain a UserAgent & some
/// settings that help lowfi work more effectively.
client: Client,
/// The [OutputStreamHandle], which also can control some
/// playback, is for now unused and is here just to keep it
/// alive so the playback can function properly.
_handle: OutputStreamHandle,
/// The [OutputStream], which is just here to keep the playback
/// alive and functioning.
_stream: OutputStream,
}
// SAFETY: This is necessary because [OutputStream] does not implement [Send],
// due to some limitation with Android's Audio API.
// I'm pretty sure nobody will use lowfi with android, so this is safe.
unsafe impl Send for Player {}
// SAFETY: See implementation for [Send].
unsafe impl Sync for Player {}
impl Player {
/// This gets the output stream while also shutting up alsa with [libc].
fn silent_get_output_stream() -> eyre::Result<(OutputStream, OutputStreamHandle)> {
// Get the file descriptor to stderr from libc.
extern "C" {
static stderr: *mut libc::FILE;
}
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
// output to `/dev/null` so that it wont be shoved down our throats.
// The mode which to redirect terminal output with.
let mode = CString::new("w")?.as_ptr();
// First redirect to /dev/null, which basically silences alsa.
let null = CString::new("/dev/null")?.as_ptr();
// SAFETY: Simple enough to be impossible to fail. Hopefully.
unsafe { freopen(null, mode, stderr) };
// Make the OutputStream while stderr is still redirected to /dev/null.
let (stream, handle) = OutputStream::try_default()?;
// Redirect back to the current terminal, so that other output isn't silenced.
let tty = CString::new("/dev/tty")?.as_ptr();
// SAFETY: See the first call to `freopen`.
unsafe { freopen(tty, mode, stderr) };
Ok((stream, handle))
}
/// Just a shorthand for setting `current`.
fn set_current(&self, info: tracks::Info) {
async fn set_current(&self, info: tracks::Info) -> eyre::Result<()> {
self.current.store(Some(Arc::new(info)));
Ok(())
}
/// A shorthand for checking if `self.current` is [Some].
@ -100,40 +167,27 @@ impl Player {
/// Sets the volume of the sink, and also clamps the value to avoid negative/over 100% values.
pub fn set_volume(&self, volume: f32) {
self.sink.set_volume(volume.clamp(0.0, 1.0));
self.sink.set_volume(volume.clamp(0.0, 1.0))
}
/// Initializes the entire player, including audio devices & sink.
///
/// This also will load the track list & persistent volume.
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
// Load the bookmarks.
let bookmarks = Bookmarks::load().await?;
pub async fn new(args: &Args) -> eyre::Result<Self> {
// Load the volume file.
let volume = PersistentVolume::load()
.await
.map_err(player::Error::PersistentVolumeLoad)?;
let volume = PersistentVolume::load().await?;
// Load the track list.
let list = List::load(args.track_list.as_ref())
.await
.map_err(player::Error::TrackListLoad)?;
let list = List::load(&args.tracks).await?;
// We should only shut up alsa forcefully on Linux if we really have to.
#[cfg(target_os = "linux")]
let mut stream = if !args.alternate && !args.debug {
audio::silent_get_output_stream()?
// We should only shut up alsa forcefully if we really have to.
let (_stream, handle) = if cfg!(target_os = "linux") && !args.alternate && !args.debug {
Self::silent_get_output_stream()?
} else {
OutputStreamBuilder::open_default_stream()?
OutputStream::try_default()?
};
#[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());
let sink = Sink::try_new(&handle)?;
if args.paused {
sink.pause();
}
@ -144,53 +198,104 @@ impl Player {
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(TIMEOUT * 5)
.timeout(TIMEOUT)
.build()?;
let player = Self {
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
buffer_size: args.buffer_size,
tracks: RwLock::new(VecDeque::with_capacity(5)),
current: ArcSwapOption::new(None),
progress: AtomicF32::new(-1.0),
bookmarks,
client,
sink,
volume,
list,
_handle: handle,
_stream,
#[cfg(feature = "mpris")]
mpris: tokio::sync::OnceCell::new(),
};
Ok((player, stream))
Ok(player)
}
/// This will play the next track, as well as refilling the buffer in the background.
///
/// This will also set `current` to the newly loaded song.
pub async fn next(&self) -> eyre::Result<tracks::Decoded> {
let track = match self.tracks.write().await.pop_front() {
Some(x) => x,
// If the queue is completely empty, then fallback to simply getting a new track.
// This is relevant particularly at the first song.
None => {
// Serves as an indicator that the queue is "loading".
// We're doing it here so that we don't get the "loading" display
// for only a frame in the other case that the buffer is not empty.
self.current.store(None);
self.list.random(&self.client).await?
}
};
let decoded = track.decode()?;
// Set the current track.
self.set_current(decoded.info.clone()).await?;
Ok(decoded)
}
/// This basically just calls [`Player::next`], and then appends the new track to the player.
///
/// This also notifies the background thread to get to work, and will send `TryAgain`
/// if it fails. This functions purpose is to be called in the background, so that
/// when the audio server recieves a `Next` signal it will still be able to respond to other
/// signals while it's loading.
async fn handle_next(
player: Arc<Self>,
itx: Sender<()>,
tx: Sender<Messages>,
) -> eyre::Result<()> {
// Stop the sink.
player.sink.stop();
let track = player.next().await;
match track {
Ok(track) => {
// Start playing the new track.
player.sink.append(track.data);
// Notify the background downloader that there's an empty spot
// in the buffer.
Downloader::notify(&itx).await?;
// Notify the audio server that the next song has actually been downloaded.
tx.send(Messages::NewSong).await?
}
Err(error) => {
if !error.downcast::<reqwest::Error>()?.is_timeout() {
tokio::time::sleep(TIMEOUT).await;
}
tx.send(Messages::TryAgain).await?
}
};
Ok(())
}
/// This is the main "audio server".
///
/// `rx` & `tx` are used to communicate with it, for example when to
/// skip tracks or pause.
///
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
/// The [Downloader]s internal buffer size is determined by `buf_size`.
pub async fn play(
player: Arc<Self>,
tx: Sender<Message>,
mut rx: Receiver<Message>,
debug: bool,
) -> eyre::Result<(), player::Error> {
// Initialize the mpris player.
//
// We're initializing here, despite MPRIS being a "user interface",
// since we need to be able to *actively* write new information to MPRIS
// specifically when it occurs, unlike the UI which passively reads the
// information each frame. Blame MPRIS, not me.
#[cfg(feature = "mpris")]
let mpris = mpris::Server::new(Arc::clone(&player), tx.clone())
.await
.inspect_err(|x| {
dbg!(x);
})?;
tx: Sender<Messages>,
mut rx: Receiver<Messages>,
) -> eyre::Result<()> {
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
let downloader = Downloader::new(Arc::clone(&player));
let (itx, downloader) = downloader.start(debug);
let downloader = Downloader::new(player.clone());
let (itx, downloader) = downloader.start().await;
// Start buffering tracks immediately.
Downloader::notify(&itx).await?;
@ -198,11 +303,9 @@ impl Player {
// Set the initial sink volume to the one specified.
player.set_volume(player.volume.float());
// Whether the last signal was a `NewSong`. This is helpful, since we
// only want to autoplay if there hasn't been any manual intervention.
//
// In other words, this will be `true` after a new track has been fully
// loaded and it'll be `false` if a track is still currently loading.
// Whether the last signal was a `NewSong`.
// This is helpful, since we only want to autoplay
// if there hasn't been any manual intervention.
let mut new = false;
loop {
@ -223,87 +326,52 @@ impl Player {
//
// It's also important to note that the condition is only checked at the
// beginning of the loop, not throughout.
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
if new => Message::Next,
Ok(_) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
if new => Messages::Next,
};
match msg {
Message::Next | Message::Init | Message::TryAgain => {
Messages::Next | Messages::Init | Messages::TryAgain => {
// We manually skipped, so we shouldn't actually wait for the song
// to be over until we recieve the `NewSong` signal.
new = false;
// This basically just prevents `Next` while a song is still currently loading.
if msg == Message::Next && !player.current_exists() {
if msg == Messages::Next && !player.current_exists() {
continue;
}
// Handle the rest of the signal in the background,
// as to not block the main audio server thread.
task::spawn(Self::next(
Arc::clone(&player),
itx.clone(),
tx.clone(),
debug,
));
// as to not block the main audio thread.
task::spawn(Self::handle_next(player.clone(), itx.clone(), tx.clone()));
}
Message::Play => {
Messages::Play => {
player.sink.play();
#[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Playing).await?;
}
Message::Pause => {
Messages::Pause => {
player.sink.pause();
#[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Paused).await?;
}
Message::PlayPause => {
Messages::PlayPause => {
if player.sink.is_paused() {
player.sink.play();
} else {
player.sink.pause();
}
#[cfg(feature = "mpris")]
mpris
.playback(mpris.player().playback_status().await?)
.await?;
}
Message::ChangeVolume(change) => {
Messages::ChangeVolume(change) => {
player.set_volume(player.sink.volume() + change);
#[cfg(feature = "mpris")]
mpris
.changed(vec![Property::Volume(player.sink.volume().into())])
.await?;
}
// This basically just continues, but more importantly, it'll re-evaluate
// the select macro at the beginning of the loop.
// See the top section to find out why this matters.
Message::NewSong => {
Messages::NewSong => {
// We've recieved `NewSong`, so on the next loop iteration we'll
// begin waiting for the song to be over in order to autoplay.
new = true;
#[cfg(feature = "mpris")]
mpris
.changed(vec![
Property::Metadata(mpris.player().metadata().await?),
Property::PlaybackStatus(mpris.player().playback_status().await?),
])
.await?;
continue;
}
Message::Bookmark => {
let current = player.current.load();
let current = current.as_ref().unwrap();
player.bookmarks.bookmark(&&current).await?;
}
Message::Quit => break,
Messages::Quit => break,
}
}

View File

@ -1,40 +0,0 @@
/// This gets the output stream while also shutting up alsa with [libc].
/// Uses raw libc calls, and therefore is functional only on Linux.
#[cfg(target_os = "linux")]
pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::player::Error> {
use libc::freopen;
use rodio::OutputStreamBuilder;
use std::ffi::CString;
// Get the file descriptor to stderr from libc.
extern "C" {
static stderr: *mut libc::FILE;
}
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
// output to `/dev/null` so that it wont be shoved down our throats.
// The mode which to redirect terminal output with.
let mode = CString::new("w")?;
// First redirect to /dev/null, which basically silences alsa.
let null = CString::new("/dev/null")?;
// SAFETY: Simple enough to be impossible to fail. Hopefully.
unsafe {
freopen(null.as_ptr(), mode.as_ptr(), stderr);
}
// Make the OutputStream while stderr is still redirected to /dev/null.
let stream = OutputStreamBuilder::open_default_stream()?;
// Redirect back to the current terminal, so that other output isn't silenced.
let tty = CString::new("/dev/tty")?;
// SAFETY: See the first call to `freopen`.
unsafe {
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
}
Ok(stream)
}

View File

@ -1,103 +0,0 @@
use std::io::SeekFrom;
use std::sync::atomic::AtomicBool;
use tokio::fs::{create_dir_all, File, OpenOptions};
use tokio::io::{self, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::RwLock;
use crate::{data_dir, tracks};
#[derive(Debug, thiserror::Error)]
pub enum BookmarkError {
#[error("data directory not found")]
DataDir,
#[error("io failure")]
Io(#[from] io::Error),
}
/// Manages the bookmarks in the current player.
pub struct Bookmarks {
entries: RwLock<Vec<String>>,
file: RwLock<File>,
bookmarked: AtomicBool,
}
impl Bookmarks {
pub async fn load() -> eyre::Result<Self, BookmarkError> {
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
create_dir_all(data_dir.clone()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.read(true)
.append(false)
.truncate(false)
.open(data_dir.join("bookmarks.txt"))
.await?;
let mut text = String::new();
file.read_to_string(&mut text).await?;
let lines: Vec<String> = text
.trim_start_matches("noheader")
.trim()
.lines()
.filter_map(|x| {
if !x.is_empty() {
Some(x.to_string())
} else {
None
}
})
.collect();
Ok(Self {
entries: RwLock::new(lines),
file: RwLock::new(file),
bookmarked: AtomicBool::new(false),
})
}
pub async fn save(&self) -> eyre::Result<(), BookmarkError> {
let text = format!("noheader\n{}", self.entries.read().await.join("\n"));
let mut lock = self.file.write().await;
lock.seek(SeekFrom::Start(0)).await?;
lock.set_len(0).await?;
lock.write_all(text.as_bytes()).await?;
lock.flush().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(())
}
pub fn bookmarked(&self) -> bool {
self.bookmarked.load(std::sync::atomic::Ordering::Relaxed)
}
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,14 +1,13 @@
//! Contains the [`Downloader`] struct.
use std::{error::Error, sync::Arc};
use std::sync::Arc;
use tokio::{
sync::mpsc::{self, Receiver, Sender},
task::{self, JoinHandle},
time::sleep,
};
use super::{Player, TIMEOUT};
use super::{Player, BUFFER_SIZE, TIMEOUT};
/// This struct is responsible for downloading tracks in the background.
///
@ -42,37 +41,26 @@ impl Downloader {
Self { player, rx, tx }
}
/// Push a new, random track onto the internal buffer.
pub async fn push_buffer(&self, debug: bool) {
let data = self.player.list.random(&self.player.client, None).await;
match data {
Ok(track) => self.player.tracks.write().await.push_back(track),
Err(error) => {
if debug {
panic!("{error} - {:?}", error.source())
}
if !error.is_timeout() {
sleep(TIMEOUT).await;
}
}
}
}
/// Actually starts & consumes the [Downloader].
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
let tx = self.tx.clone();
let handle = task::spawn(async move {
// Loop through each update notification.
while self.rx.recv().await == Some(()) {
// For each update notification, we'll push tracks until the buffer is completely full.
while self.player.tracks.read().await.len() < self.player.buffer_size {
self.push_buffer(debug).await;
pub async fn start(mut self) -> (Sender<()>, JoinHandle<()>) {
(
self.tx,
task::spawn(async move {
// Loop through each update notification.
while self.rx.recv().await == Some(()) {
// For each update notification, we'll push tracks until the buffer is completely full.
while self.player.tracks.read().await.len() < BUFFER_SIZE {
match self.player.list.random(&self.player.client).await {
Ok(track) => self.player.tracks.write().await.push_back(track),
Err(error) => {
if !error.is_timeout() {
tokio::time::sleep(TIMEOUT).await;
}
}
}
}
}
}
});
(tx, handle)
}),
)
}
}

View File

@ -1,51 +0,0 @@
use std::ffi::NulError;
use crate::{messages::Message, player::bookmark::BookmarkError};
use tokio::sync::mpsc::error::SendError;
#[cfg(feature = "mpris")]
use mpris_server::zbus::{self, fdo};
/// Any errors which might occur when running or initializing the lowfi player.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("unable to load the persistent volume")]
PersistentVolumeLoad(eyre::Error),
#[error("unable to save the persistent volume")]
PersistentVolumeSave(eyre::Error),
#[error("sending internal message failed")]
Communication(#[from] SendError<Message>),
#[error("unable to load track list")]
TrackListLoad(eyre::Error),
#[error("interfacing with audio failed")]
Stream(#[from] rodio::StreamError),
#[error("NUL error, if you see this, something has gone VERY wrong")]
Nul(#[from] NulError),
#[error("unable to send or prepare network request")]
Reqwest(#[from] reqwest::Error),
#[cfg(feature = "mpris")]
#[error("mpris bus error")]
ZBus(#[from] zbus::Error),
// TODO: This has a terrible error message, mainly because I barely understand
// what this error even represents. What does fdo mean?!?!? Why, MPRIS!?!?
#[cfg(feature = "mpris")]
#[error("mpris fdo (zbus interface) error")]
Fdo(#[from] fdo::Error),
#[error("unable to notify downloader")]
DownloaderNotify(#[from] SendError<()>),
#[error("unable to find data directory")]
DataDir,
#[error("bookmarking load/unload failed")]
Bookmark(#[from] BookmarkError),
}

View File

@ -1,27 +1,20 @@
//! Contains the code for the MPRIS server & other helper functions.
use std::{env, process, sync::Arc};
use std::sync::Arc;
use mpris_server::{
zbus::{self, fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Time, TrackId, Volume,
zbus::{fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, RootInterface, Time,
TrackId, Volume,
};
use tokio::sync::mpsc::Sender;
use super::ui;
use super::Message;
use super::Messages;
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
/// The actual MPRIS player.
/// The actual MPRIS server.
pub struct Player {
/// A reference to the [`super::Player`] itself.
pub player: Arc<super::Player>,
/// The audio server sender, which is used to communicate with
/// the audio sender for skips and a few other inputs.
pub sender: Sender<Message>,
pub sender: Sender<Messages>,
}
impl RootInterface for Player {
@ -30,10 +23,7 @@ impl RootInterface for Player {
}
async fn quit(&self) -> fdo::Result<()> {
self.sender
.send(Message::Quit)
.await
.map_err(|_error| ERROR)
self.sender.send(Messages::Quit).await.map_err(|_| ERROR)
}
async fn can_quit(&self) -> fdo::Result<bool> {
@ -61,28 +51,25 @@ impl RootInterface for Player {
}
async fn identity(&self) -> fdo::Result<String> {
Ok("lowfi".to_owned())
Ok("lowfi".to_string())
}
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok("dev.talwat.lowfi".to_owned())
Ok("dev.talwat.lowfi".to_string())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["https".to_owned()])
Ok(vec!["https".to_string()])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["audio/mpeg".to_owned()])
Ok(vec!["audio/mpeg".to_string()])
}
}
impl PlayerInterface for Player {
async fn next(&self) -> fdo::Result<()> {
self.sender
.send(Message::Next)
.await
.map_err(|_error| ERROR)
self.sender.send(Messages::Next).await.map_err(|_| ERROR)
}
async fn previous(&self) -> fdo::Result<()> {
@ -90,17 +77,14 @@ impl PlayerInterface for Player {
}
async fn pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::Pause)
.await
.map_err(|_error| ERROR)
self.sender.send(Messages::Pause).await.map_err(|_| ERROR)
}
async fn play_pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::PlayPause)
.send(Messages::PlayPause)
.await
.map_err(|_error| ERROR)
.map_err(|_| ERROR)
}
async fn stop(&self) -> fdo::Result<()> {
@ -108,10 +92,7 @@ impl PlayerInterface for Player {
}
async fn play(&self) -> fdo::Result<()> {
self.sender
.send(Message::Play)
.await
.map_err(|_error| ERROR)
self.sender.send(Messages::Play).await.map_err(|_| ERROR)
}
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
@ -162,25 +143,20 @@ impl PlayerInterface for Player {
}
async fn metadata(&self) -> fdo::Result<Metadata> {
let metadata = self
.player
.current
.load()
.as_ref()
.map_or_else(Metadata::new, |track| {
let mut metadata = Metadata::builder()
.title(track.display_name.clone())
.album(self.player.list.name.clone())
.build();
let metadata = match self.player.current.load().as_ref() {
Some(track) => {
let mut metadata = Metadata::builder().title(track.name.clone()).build();
metadata.set_length(
track
.duration
.map(|x| Time::from_micros(x.as_micros() as i64)),
.and_then(|x| Some(Time::from_micros(x.as_micros() as i64))),
);
metadata
});
}
None => Metadata::new(),
};
Ok(metadata)
}
@ -191,7 +167,6 @@ impl PlayerInterface for Player {
async fn set_volume(&self, volume: Volume) -> Result<()> {
self.player.set_volume(volume as f32);
ui::flash_audio();
Ok(())
}
@ -203,11 +178,11 @@ impl PlayerInterface for Player {
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(0.2f64)
Ok(0.2)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(3.0f64)
Ok(3.0)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
@ -234,48 +209,3 @@ impl PlayerInterface for Player {
Ok(true)
}
}
/// A struct which contains the MPRIS [Server], and has some helper functions
/// to make it easier to work with.
pub struct Server {
/// The inner MPRIS server.
inner: mpris_server::Server<Player>,
}
impl Server {
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
pub async fn changed(
&self,
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
) -> zbus::Result<()> {
self.inner.properties_changed(properties).await
}
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
pub async fn playback(&self, new: PlaybackStatus) -> zbus::Result<()> {
self.inner
.properties_changed(vec![Property::PlaybackStatus(new)])
.await
}
/// Shorthand to get the inner mpris player object.
pub fn player(&self) -> &Player {
self.inner.imp()
}
/// Creates a new MPRIS server.
pub async fn new(
player: Arc<super::Player>,
sender: Sender<Message>,
) -> eyre::Result<Self, zbus::Error> {
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
String::from("lowfi")
} else {
format!("lowfi.{}.instance{}", player.list.name, process::id())
};
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
Ok(Self { inner: server })
}
}

View File

@ -1,70 +0,0 @@
use eyre::eyre;
use std::path::PathBuf;
use tokio::fs;
/// This is the representation of the persistent volume,
/// which is loaded at startup and saved on shutdown.
#[derive(Clone, Copy)]
pub struct PersistentVolume {
/// The volume, as a percentage.
inner: u16,
}
impl PersistentVolume {
/// Retrieves the config directory.
async fn config() -> eyre::Result<PathBuf> {
let config = dirs::config_dir()
.ok_or_else(|| eyre!("Couldn't find config directory"))?
.join(PathBuf::from("lowfi"));
if !config.exists() {
fs::create_dir_all(&config).await?;
}
Ok(config)
}
/// Returns the volume as a float from 0 to 1.
pub fn float(self) -> f32 {
f32::from(self.inner) / 100.0
}
/// Loads the [`PersistentVolume`] from [`dirs::config_dir()`].
pub async fn load() -> eyre::Result<Self> {
let config = Self::config().await?;
let volume = config.join(PathBuf::from("volume.txt"));
// Basically just read from the volume file if it exists, otherwise return 100.
let volume = if volume.exists() {
let contents = fs::read_to_string(volume).await?;
let trimmed = contents.trim();
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
stripped
.parse()
.map_err(|_error| eyre!("volume.txt file is invalid"))?
} else {
fs::write(&volume, "100").await?;
100u16
};
Ok(Self { inner: volume })
}
/// Saves `volume` to `volume.txt`.
pub async fn save(volume: f32) -> eyre::Result<()> {
let config = Self::config().await?;
let path = config.join(PathBuf::from("volume.txt"));
// Already rounded & absolute, therefore this should be safe.
#[expect(
clippy::as_conversions,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
let percentage = (volume * 100.0).abs().round() as u16;
fs::write(path, percentage.to_string()).await?;
Ok(())
}
}

View File

@ -1,88 +0,0 @@
use std::{
error::Error,
sync::{atomic::Ordering, Arc},
};
use tokio::{sync::mpsc::Sender, time::sleep};
use crate::{
messages::Message,
player::{downloader::Downloader, Player, TIMEOUT},
tracks,
};
impl Player {
/// Fetches the next track from the queue, or a random track if the queue is empty.
/// This will also set the current track to the fetched track's info.
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::Error> {
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
let track = self.tracks.write().await.pop_front();
let track = if let Some(track) = track {
track
} else {
// If the queue is completely empty, then fallback to simply getting a new track.
// This is relevant particularly at the first song.
// Serves as an indicator that the queue is "loading".
// We're doing it here so that we don't get the "loading" display
// for only a frame in the other case that the buffer is not empty.
self.current.store(None);
self.progress.store(0.0, Ordering::Relaxed);
self.list.random(&self.client, Some(&self.progress)).await?
};
let decoded = track.decode()?;
// Set the current track.
self.set_current(decoded.info.clone());
Ok(decoded)
}
/// Gets, decodes, and plays the next track in the queue while also handling the downloader.
///
/// This functions purpose is to be called in the background, so that when the audio server recieves a
/// `Next` signal it will still be able to respond to other signals while it's loading.
///
/// This also sends the either a `NewSong` or `TryAgain` signal to `tx`.
pub async fn next(
player: Arc<Self>,
itx: Sender<()>,
tx: Sender<Message>,
debug: bool,
) -> eyre::Result<()> {
// Stop the sink.
player.sink.stop();
let track = player.fetch().await;
match track {
Ok(track) => {
// Start playing the new track.
player.sink.append(track.data);
// Set whether it's bookmarked.
player.bookmarks.set_bookmarked(&track.info).await;
// Notify the background downloader that there's an empty spot
// in the buffer.
Downloader::notify(&itx).await?;
// Notify the audio server that the next song has actually been downloaded.
tx.send(Message::NewSong).await?;
}
Err(error) => {
if debug {
panic!("{error} - {:?}", error.source())
}
if !error.is_timeout() {
sleep(TIMEOUT).await;
}
tx.send(Message::TryAgain).await?;
}
};
Ok(())
}
}

View File

@ -1,15 +1,6 @@
//! The module which manages all user interface, including inputs.
#![allow(
clippy::as_conversions,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "the ui is full of these because of various layout & positioning aspects, and for a simple music player making all casts safe is not worth the effort"
)]
use std::{
fmt::Write as _,
io::{stdout, Stdout},
sync::{
atomic::{AtomicUsize, Ordering},
@ -23,39 +14,33 @@ use crate::Args;
use crossterm::{
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
style::{Print, Stylize as _},
style::{Print, Stylize},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use lazy_static::lazy_static;
use thiserror::Error;
use tokio::{sync::mpsc::Sender, task, time::sleep};
use unicode_segmentation::UnicodeSegmentation;
use super::Player;
use crate::messages::Message;
use super::{Messages, Player};
mod components;
mod input;
/// The error type for the UI, which is used to handle errors that occur
/// while drawing the UI or handling input.
#[derive(Debug, Error)]
pub enum UIError {
#[error("unable to convert number")]
Conversion(#[from] std::num::TryFromIntError),
/// The total width of the UI.
const WIDTH: usize = 27;
#[error("unable to write output")]
Write(#[from] std::io::Error),
#[error("sending message to backend from ui failed")]
Communication(#[from] tokio::sync::mpsc::error::SendError<Message>),
}
/// Self explanitory.
const FPS: usize = 12;
/// How long the audio bar will be visible for when audio is adjusted.
/// This is in frames.
const AUDIO_BAR_DURATION: usize = 10;
/// How long to wait in between frames.
/// This is fairly arbitrary, but an ideal value should be enough to feel
/// snappy but not require too many resources.
const FRAME_DELTA: f32 = 1.0 / FPS as f32;
lazy_static! {
/// The volume timer, which controls how long the volume display should
/// show up and when it should disappear.
@ -65,83 +50,52 @@ lazy_static! {
static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0);
}
/// Sets the volume timer to one, effectively flashing the audio display in lowfi's UI.
///
/// The amount of frames the audio display is visible for is determined by [`AUDIO_BAR_DURATION`].
pub fn flash_audio() {
VOLUME_TIMER.store(1, Ordering::Relaxed);
}
/// Represents an abstraction for drawing the actual lowfi window itself.
///
/// The main purpose of this struct is just to add the fancy border,
/// as well as clear the screen before drawing.
pub struct Window {
/// Whether or not to include borders in the output.
borderless: bool,
/// The top & bottom borders, which are here since they can be
/// prerendered, as they don't change from window to window.
///
/// If the option to not include borders is set, these will just be empty [String]s.
borders: [String; 2],
/// The width of the window.
width: usize,
/// The output, currently just an [`Stdout`].
out: Stdout,
}
impl Window {
/// Initializes a new [Window].
///
/// * `width` - Width of the windows.
/// * `borderless` - Whether to include borders in the window, or not.
pub fn new(width: usize, borderless: bool) -> Self {
let borders = if borderless {
[String::new(), String::new()]
} else {
let middle = "".repeat(width + 2);
[format!("{middle}"), format!("{middle}")]
};
pub fn new() -> Self {
Self {
borders,
borderless,
width,
borders: [
format!("{}\r\n", "".repeat(WIDTH + 2)),
// This one doesn't have a leading \r\n to avoid extra space under the window.
format!("{}", "".repeat(WIDTH + 2)),
],
out: stdout(),
}
}
/// Actually draws the window, with each element in `content` being on a new line.
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<(), UIError> {
let len: u16 = content.len().try_into()?;
pub fn draw(&mut self, content: Vec<String>) -> eyre::Result<()> {
let len = content.len() as u16;
// Note that this will have a trailing newline, which we use later.
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
// Horizontal Padding & Border
let padding = if self.borderless { " " } else { "" };
let space = if space {
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
} else {
String::new()
};
write!(output, "{padding} {}{space} {padding}\r\n", x.reset()).unwrap();
output
});
let menu: String = content
.into_iter()
.map(|x| format!("{}\r\n", x.reset()).to_string())
.collect();
// We're doing this because Windows is stupid and can't stand
// writing to the last line repeatedly.
// writing to the last line repeatedly. Again, it's stupid.
#[cfg(windows)]
let (height, suffix) = (len + 2, "\r\n");
#[cfg(not(windows))]
let (height, suffix) = (len + 1, "");
let (rendered, height) = (
format!("{}{}{}\r\n", self.borders[0], menu, self.borders[1]),
len + 2,
);
// There's no need for another newline after the main menu content, because it already has one.
let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]);
// Unix has no such ridiculous limitations, so we calculate
// the height of the window accurately.
#[cfg(not(windows))]
let (rendered, height) = (
format!("{}{}{}", self.borders[0], menu, self.borders[1]),
len + 1,
);
crossterm::execute!(
self.out,
@ -159,17 +113,8 @@ 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 || debug);
async fn interface(player: Arc<Player>, minimalist: bool) -> eyre::Result<()> {
let mut window = Window::new();
loop {
// Load `current` once so that it doesn't have to be loaded over and over
@ -177,40 +122,52 @@ async fn interface(
let current = player.current.load();
let current = current.as_ref();
let action = components::action(&player, current, width);
let action = components::action(&player, current, WIDTH);
let volume = player.sink.volume();
let percentage = format!("{}%", (volume * 100.0).round().abs());
let timer = VOLUME_TIMER.load(Ordering::Relaxed);
let middle = match timer {
0 => components::progress_bar(&player, current, width - 16),
_ => components::audio_bar(volume, &percentage, width - 17),
0 => components::progress_bar(&player, current, WIDTH - 16),
_ => components::audio_bar(volume, &percentage, WIDTH - 17),
};
if timer > 0 && timer <= AUDIO_BAR_DURATION {
// We'll keep increasing the timer until it eventually hits `AUDIO_BAR_DURATION`.
VOLUME_TIMER.fetch_add(1, Ordering::Relaxed);
} else {
} else if timer > AUDIO_BAR_DURATION {
// If enough time has passed, we'll reset it back to 0.
VOLUME_TIMER.store(0, Ordering::Relaxed);
}
let controls = components::controls(width);
let controls = components::controls(WIDTH);
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],
let menu = if minimalist {
vec![action, middle]
} else {
vec![action, middle, controls]
};
window.draw(menu, false)?;
window.draw(menu)?;
let delta = 1.0 / f32::from(fps);
sleep(Duration::from_secs_f32(delta)).await;
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
}
}
/// The mpris server additionally needs a reference to the player,
/// since it frequently accesses the sink directly as well as
/// the current track.
#[cfg(feature = "mpris")]
async fn mpris(
player: Arc<Player>,
sender: Sender<Messages>,
) -> mpris_server::Server<crate::player::mpris::Player> {
mpris_server::Server::new("lowfi", crate::player::mpris::Player { player, sender })
.await
.unwrap()
}
/// Represents the terminal environment, and is used to properly
/// initialize and clean up the terminal.
pub struct Environment {
@ -224,7 +181,7 @@ pub struct Environment {
impl Environment {
/// This prepares the terminal, returning an [Environment] helpful
/// for cleaning up afterwards.
pub fn ready(alternate: bool) -> eyre::Result<Self, UIError> {
pub fn ready(alternate: bool) -> eyre::Result<Self> {
let mut lock = stdout().lock();
crossterm::execute!(lock, Hide)?;
@ -251,7 +208,7 @@ impl Environment {
/// Uses the information collected from initialization to safely close down
/// the terminal & restore it to it's previous state.
pub fn cleanup(&self) -> eyre::Result<(), UIError> {
pub fn cleanup(&self) -> eyre::Result<()> {
let mut lock = stdout().lock();
if self.alternate {
@ -273,7 +230,7 @@ impl Environment {
}
impl Drop for Environment {
/// Just a wrapper for [`Environment::cleanup`] which ignores any errors thrown.
/// Just a wrapper for [Environment::cleanup] which ignores any errors thrown.
fn drop(&mut self) {
// Well, we're dropping it, so it doesn't really matter if there's an error.
let _ = self.cleanup();
@ -282,23 +239,20 @@ impl Drop for Environment {
/// Initializes the UI, this will also start taking input from the user.
///
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
/// `alternate` controls whether to use [EnterAlternateScreen] in order to hide
/// previous terminal history.
pub async fn start(
player: Arc<Player>,
sender: Sender<Message>,
args: Args,
) -> eyre::Result<(), UIError> {
pub async fn start(player: Arc<Player>, sender: Sender<Messages>, args: Args) -> eyre::Result<()> {
let environment = Environment::ready(args.alternate)?;
let interface = task::spawn(interface(
Arc::clone(&player),
args.minimalist,
args.borderless,
args.debug,
args.fps,
21 + args.width.min(32) * 2,
));
#[cfg(feature = "mpris")]
{
player
.mpris
.get_or_init(|| mpris(player.clone(), sender.clone()))
.await;
}
let interface = task::spawn(interface(Arc::clone(&player), args.minimalist));
input::listen(sender.clone()).await?;
interface.abort();

View File

@ -1,10 +1,6 @@
//! Various different individual components that
//! appear in lowfi's UI, like the progress bar.
use std::{ops::Deref, sync::Arc, time::Duration};
use std::{ops::Deref as _, sync::Arc, time::Duration};
use crossterm::style::Stylize as _;
use unicode_segmentation::UnicodeSegmentation as _;
use crossterm::style::Stylize;
use crate::{player::Player, tracks::Info};
@ -13,7 +9,7 @@ pub fn format_duration(duration: &Duration) -> String {
let seconds = duration.as_secs() % 60;
let minutes = duration.as_secs() / 60;
format!("{minutes:02}:{seconds:02}")
format!("{:02}:{:02}", minutes, seconds)
}
/// Creates the progress bar, as well as all the padding needed.
@ -59,44 +55,27 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
/// This represents the main "action" bars state.
enum ActionBar {
/// When the app is paused.
Paused(Info),
/// When the app is playing.
Playing(Info),
/// When the app is loading.
Loading(f32),
/// When the app is muted.
Muted,
Loading,
}
impl ActionBar {
/// Formats the action bar to be displayed.
/// The second value is the character length of the result.
fn format(&self, star: bool) -> (String, usize) {
fn format(&self) -> (String, usize) {
let (word, subject) = match self {
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
Self::Loading(progress) => {
let progress = format!("{: <2.0}%", (progress * 100.0).min(99.0));
("loading", Some((progress, 3)))
}
Self::Muted => {
let msg = "+ to increase volume";
("muted", Some((String::from(msg), msg.len())))
}
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
Self::Loading => ("loading", None),
};
subject.map_or_else(
|| (word.to_owned(), word.len()),
|(subject, len)| {
(
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
word.len() + 1 + len + usize::from(star),
format!("{} {}", word, subject.clone().bold()),
word.len() + 1 + len,
)
},
)
@ -107,28 +86,19 @@ 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_else(
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|info| {
let info = info.deref().clone();
.map_or(ActionBar::Loading, |info| {
let info = info.deref().clone();
if player.sink.volume() < 0.01 {
return ActionBar::Muted;
}
if player.sink.is_paused() {
ActionBar::Paused(info)
} else {
ActionBar::Playing(info)
}
},
)
.format(player.bookmarks.bookmarked());
if player.sink.is_paused() {
ActionBar::Paused(info)
} else {
ActionBar::Playing(info)
}
})
.format();
if len > width {
let chopped: String = main.graphemes(true).take(width + 1).collect();
format!("{chopped}...")
format!("{}...", &main[..=width])
} else {
format!("{}{}", main, " ".repeat(width - len))
}
@ -141,12 +111,5 @@ pub fn controls(width: usize) -> String {
let len: usize = controls.concat().iter().map(|x| x.len()).sum();
let controls = controls.map(|x| format!("{}{}", x[0].bold(), x[1]));
let mut controls = controls.join(&" ".repeat((width - len) / (controls.len() - 1)));
// This is needed because changing the above line
// only works for when the width is even
controls.push_str(match width % 2 {
0 => " ",
_ => "",
});
controls
controls.join(&" ".repeat((width - len) / (controls.len() - 1)))
}

View File

@ -1,17 +1,15 @@
//! Responsible for specifically recieving terminal input
//! using [`crossterm`].
use std::sync::atomic::Ordering;
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
use futures::{FutureExt as _, StreamExt as _};
use futures::{FutureExt, StreamExt};
use tokio::sync::mpsc::Sender;
use crate::player::{
ui::{self, UIError},
Message,
};
use crate::player::Messages;
use super::VOLUME_TIMER;
/// Starts the listener to recieve input from the terminal for various events.
pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
let mut reader = EventStream::new();
loop {
@ -25,49 +23,48 @@ pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
let messages = match event.code {
// Arrow key volume controls.
KeyCode::Up => Message::ChangeVolume(0.1),
KeyCode::Right => Message::ChangeVolume(0.01),
KeyCode::Down => Message::ChangeVolume(-0.1),
KeyCode::Left => Message::ChangeVolume(-0.01),
KeyCode::Up => Messages::ChangeVolume(0.1),
KeyCode::Right => Messages::ChangeVolume(0.01),
KeyCode::Down => Messages::ChangeVolume(-0.1),
KeyCode::Left => Messages::ChangeVolume(-0.01),
KeyCode::Char(character) => match character.to_ascii_lowercase() {
// Ctrl+C
'c' if event.modifiers == KeyModifiers::CONTROL => Message::Quit,
'c' if event.modifiers == KeyModifiers::CONTROL => Messages::Quit,
// Quit
'q' => Message::Quit,
'q' => Messages::Quit,
// Skip/Next
's' | 'n' | 'l' => Message::Next,
's' | 'n' => Messages::Next,
// Pause
'p' | ' ' => Message::PlayPause,
'p' => Messages::PlayPause,
// Volume up & down
'+' | '=' | 'k' => Message::ChangeVolume(0.1),
'-' | '_' | 'j' => Message::ChangeVolume(-0.1),
// Bookmark
'b' => Message::Bookmark,
'+' | '=' => Messages::ChangeVolume(0.1),
'-' | '_' => Messages::ChangeVolume(-0.1),
_ => continue,
},
// Media keys
KeyCode::Media(media) => match media {
event::MediaKeyCode::Pause
| event::MediaKeyCode::Play
| event::MediaKeyCode::PlayPause => Message::PlayPause,
event::MediaKeyCode::Stop => Message::Pause,
event::MediaKeyCode::TrackNext => Message::Next,
event::MediaKeyCode::LowerVolume => Message::ChangeVolume(-0.1),
event::MediaKeyCode::RaiseVolume => Message::ChangeVolume(0.1),
event::MediaKeyCode::MuteVolume => Message::ChangeVolume(-1.0),
event::MediaKeyCode::Play => Messages::PlayPause,
event::MediaKeyCode::Pause => Messages::PlayPause,
event::MediaKeyCode::PlayPause => Messages::PlayPause,
event::MediaKeyCode::Stop => Messages::Pause,
event::MediaKeyCode::TrackNext => Messages::Next,
event::MediaKeyCode::LowerVolume => Messages::ChangeVolume(-0.1),
event::MediaKeyCode::RaiseVolume => Messages::ChangeVolume(0.1),
event::MediaKeyCode::MuteVolume => Messages::ChangeVolume(-1.0),
_ => continue,
},
_ => continue,
};
if let Message::ChangeVolume(_) = messages {
ui::flash_audio();
// If it's modifying the volume, then we'll set the `VOLUME_TIMER` to 1
// so that the UI thread will know that it should show the audio bar.
if let Messages::ChangeVolume(_) = messages {
VOLUME_TIMER.store(1, Ordering::Relaxed);
}
sender.send(messages).await?;

View File

@ -3,21 +3,21 @@
//! 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 futures::{stream::FuturesUnordered, StreamExt};
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{Html, Selector};
use crate::scrapers::get;
const BASE_URL: &str = "https://lofigirl.com/wp-content/uploads/";
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);
async fn parse(path: &str) -> eyre::Result<Vec<String>> {
let response = reqwest::get(format!("{}{}", BASE_URL, path)).await?;
let document = response.text().await?;
let html = Html::parse_document(&document);
Ok(html
.select(&SELECTOR)
.skip(5)
@ -29,11 +29,12 @@ 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.
/// 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 items = parse(&client, "/").await?;
async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>> {
let extension = &format!(".{}", extension);
let mut years: Vec<u32> = items
let items = parse("").await?;
let years: Vec<u32> = items
.iter()
.filter_map(|x| {
let year = x.strip_suffix("/")?;
@ -41,25 +42,26 @@ async fn scan() -> eyre::Result<Vec<String>> {
})
.collect();
years.sort();
// A little bit of async to run all of the months concurrently.
let mut futures = FuturesOrdered::new();
let mut futures = FuturesUnordered::new();
for year in years {
let months = parse(&client, &year.to_string()).await?;
let months = parse(&year.to_string()).await?;
for month in months {
let client = client.clone();
futures.push_back(async move {
futures.push(async move {
let path = format!("{}/{}", year, month);
let items = parse(&client, &path).await.unwrap();
let items = parse(&path).await.unwrap();
items
.into_iter()
.filter_map(|x| {
if x.ends_with(".mp3") {
Some(format!("{path}{x}"))
if x.ends_with(extension) {
if include_full {
Some(format!("{BASE_URL}{path}{x}"))
} else {
Some(format!("{path}{x}"))
}
} else {
None
}
@ -77,10 +79,10 @@ async fn scan() -> eyre::Result<Vec<String>> {
eyre::Result::Ok(files)
}
pub async fn scrape() -> eyre::Result<()> {
let files = scan().await?;
pub async fn scrape(extension: String, include_full: bool) -> eyre::Result<()> {
let files = scan(&extension, include_full).await?;
for file in files {
println!("{file}");
println!("{}", file);
}
Ok(())

View File

@ -1,85 +0,0 @@
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;
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
pub enum Source {
Lofigirl,
Archive,
Chillhop,
}
impl Source {
pub fn cache_dir(&self) -> &'static str {
match self {
Source::Lofigirl => "lofigirl",
Source::Archive => "archive",
Source::Chillhop => "chillhop",
}
}
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)
}
}

View File

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

View File

@ -1,224 +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; 6] = [
74707, // 404
21655, // Lyrics
21773, // Lyrics
8172, // Lyrics
55397, // Lyrics
75135, // Lyrics
];
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

@ -1,92 +1,28 @@
//! Has all of the structs for managing the state
//! of tracks, as well as downloading them & finding new ones.
//!
//! There are several structs which represent the different stages
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
//! and what structs are relevant in each step, are as follows.
//!
//! First Stage, when a track is initially fetched.
//! 1. Raw entry selected from track list.
//! 2. Raw entry split into path & display name.
//! 3. Track data fetched, and [`QueuedTrack`] is created which includes a [`TrackName`] that may be raw.
//!
//! Second Stage, when a track is played.
//! 1. Track data is decoded.
//! 2. [`Info`] created from decoded data.
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
//! of tracks, as well as downloading them &
//! finding new ones.
use std::{io::Cursor, path::Path, time::Duration};
use std::{io::Cursor, time::Duration};
use bytes::Bytes;
use convert_case::{Case, Casing};
use regex::Regex;
use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation;
use inflector::Inflector;
use rodio::{Decoder, Source};
use unicode_width::UnicodeWidthStr;
use url::form_urlencoded;
pub mod error;
pub mod list;
pub use error::Error;
use crate::tracks::error::Context;
use lazy_static::lazy_static;
/// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>;
/// Specifies a track's name, and specifically,
/// whether it has already been formatted or if it
/// is still in it's raw path form.
#[derive(Debug, Clone)]
pub enum TrackName {
/// Pulled straight from the list,
/// with no splitting done at all.
Raw(String),
/// If a track has a custom specified name
/// in the list, then it should be defined with this variant.
Formatted(String),
}
/// Tracks which are still waiting in the queue, and can't be played yet.
///
/// This means that only the data & track name are included.
pub struct QueuedTrack {
/// Name of the track, which may be raw.
pub name: TrackName,
/// Full downloadable path/url of the track.
pub full_path: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
impl QueuedTrack {
/// This will actually decode and format the track,
/// returning a [`DecodedTrack`] which can be played
/// and also has a duration & formatted name.
pub fn decode(self) -> eyre::Result<DecodedTrack, Error> {
DecodedTrack::new(self)
}
}
/// The [`Info`] struct, which has the name and duration of a track.
/// The TrackInfo struct, which has the name and duration of a track.
///
/// This is not included in [Track] as the duration has to be acquired
/// from the decoded data and not from the raw data.
#[derive(Debug, Eq, PartialEq, Clone)]
#[derive(Debug, PartialEq, Clone)]
pub struct Info {
/// The full downloadable path/url of the track.
pub full_path: String,
/// Whether the track entry included a custom name, or not.
pub custom_name: bool,
/// This is a formatted name, so it doesn't include the full path.
pub display_name: String,
pub name: String,
/// This is the *actual* terminal width of the track name, used to make
/// the UI consistent.
@ -97,128 +33,67 @@ 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()`.
#[allow(clippy::tuple_array_conversions)]
form_urlencoded::parse(text.as_bytes())
.map(|(key, val)| [key, val].concat())
.collect()
}
/// Formats a name with [convert_case].
///
/// Formats a name with [Inflector].
/// This will also strip the first few numbers that are
/// usually present on most lofi tracks and do some other
/// formatting operations.
fn format_name(name: &str) -> eyre::Result<String, Error> {
let path = Path::new(name);
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or((name, error::Kind::InvalidName))?;
let name = Self::decode_url(name).to_lowercase();
let mut name = name
.replace("masster", "master")
.replace("(online-audio-converter.com)", "") // Some of these names, man...
.replace('_', " ");
// Get rid of "master" suffix with a few regex patterns.
for regex in MASTER_PATTERNS.iter() {
name = regex.replace(&name, "").to_string();
}
name = ID_PATTERN.replace(&name, "").to_string();
let name = name
.replace("13lufs", "")
.to_case(Case::Title)
.replace(" .", "")
.replace(" Ft ", " ft. ")
.replace("Ft.", "ft.")
.replace("Feat.", "ft.")
.replace(" W ", " w/ ");
/// usually present on most lofi tracks.
fn format_name(name: &str) -> String {
let formatted = Self::decode_url(
name.split("/")
.last()
.unwrap()
.strip_suffix(".mp3")
.unwrap(),
)
.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 ");
// This is incremented for each digit in front of the song name.
let mut skip = 0;
for character in name.as_bytes() {
if character.is_ascii_digit()
|| *character == b'.'
|| *character == b')'
|| *character == b'('
{
for character in formatted.as_bytes() {
if character.is_ascii_digit() {
skip += 1;
} else {
break;
}
}
// If the entire name of the track is a number, then just return it.
if skip == name.len() {
Ok(name.trim().to_string())
} else {
// We've already checked before that the bound is at an ASCII digit.
#[allow(clippy::string_slice)]
Ok(String::from(name[skip..].trim()))
}
String::from(&formatted[skip..])
}
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
pub fn new(
name: TrackName,
full_path: String,
decoded: &DecodedData,
) -> eyre::Result<Self, Error> {
let (display_name, custom_name) = match name {
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
TrackName::Formatted(custom) => (custom, true),
};
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
pub fn new(name: String, decoded: &DecodedData) -> Self {
let name = Self::format_name(&name);
Ok(Self {
Self {
duration: decoded.total_duration(),
width: display_name.graphemes(true).count(),
full_path,
custom_name,
display_name,
})
width: name.width(),
name,
}
}
}
/// This struct is seperate from [Track] since it is generated lazily from
/// a track, and not when the track is first downloaded.
pub struct DecodedTrack {
pub struct Decoded {
/// Has both the formatted name and some information from the decoded data.
pub info: Info,
@ -226,18 +101,32 @@ pub struct DecodedTrack {
pub data: DecodedData,
}
impl DecodedTrack {
impl Decoded {
/// Creates a new track.
/// This is equivalent to [`QueuedTrack::decode`].
pub fn new(track: QueuedTrack) -> eyre::Result<Self, Error> {
let data = Decoder::builder()
.with_byte_len(track.data.len().try_into().unwrap())
.with_data(Cursor::new(track.data))
.build()
.track(track.full_path.clone())?;
let info = Info::new(track.name, track.full_path, &data)?;
/// This is equivalent to [Track::decode].
pub fn new(track: Track) -> eyre::Result<Self> {
let data = Decoder::new(Cursor::new(track.data))?;
let info = Info::new(track.name, &data);
Ok(Self { info, data })
}
}
/// The main track struct, which only includes data & the track name.
pub struct Track {
/// This name is not formatted, and also includes the month & year of the track.
pub name: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
impl Track {
/// 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<Decoded> {
Decoded::new(self)
}
}

View File

@ -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 {
Error {
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,39 +1,21 @@
//! 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 mp3 data.
use std::{cmp::min, sync::atomic::Ordering};
use atomic_float::AtomicF32;
use bytes::{BufMut, Bytes, BytesMut};
use eyre::OptionExt as _;
use futures::StreamExt;
use rand::Rng as _;
use bytes::Bytes;
use rand::Rng;
use reqwest::Client;
use tokio::fs;
use crate::{
data_dir,
tracks::{self, error::Context},
};
use super::QueuedTrack;
use super::Track;
/// Represents a list of tracks that can be played.
///
/// See the [README](https://github.com/talwat/lowfi?tab=readme-ov-file#the-format) for more details about the format.
#[derive(Clone)]
pub struct List {
/// The "name" of the list, usually derived from a filename.
#[allow(dead_code)]
pub name: String,
/// Just the raw file, but seperated by `/n` (newlines).
/// `lines[0]` is the base/heaeder, with the rest being tracks.
/// `lines[0]` is the base, 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 {
@ -42,151 +24,69 @@ impl List {
self.lines[0].trim()
}
/// Gets the path of a random track.
///
/// The second value in the tuple specifies whether the
/// track has a custom display name.
fn random_path(&self) -> (String, Option<String>) {
/// Gets the name of a random track.
fn random_name(&self) -> String {
// We're getting from 1 here, since the base is at `self.lines[0]`.
//
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
// how rust vectors work, since it is slower to drain only a single element from
// how rust vectors work, sinceslow 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 line = self.lines[random].clone();
if let Some((first, second)) = line.split_once('!') {
(first.to_owned(), Some(second.to_owned()))
} else {
(line, None)
}
self.lines[random].to_owned()
}
/// Downloads a raw track, but doesn't decode it.
async fn download(
&self,
track: &str,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<(Bytes, String), tracks::Error> {
async fn download(&self, track: &str, client: &Client) -> reqwest::Result<Bytes> {
// If the track has a protocol, then we should ignore the base for it.
let full_path = if track.contains("://") {
let url = if track.contains("://") {
track.to_owned()
} else {
format!("{}{}", self.base(), track)
};
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
let path = if x.starts_with('~') {
let home_path =
dirs::home_dir().ok_or((track, tracks::error::Kind::InvalidPath))?;
let home = home_path
.to_str()
.ok_or((track, tracks::error::Kind::InvalidPath))?;
let response = client.get(url).send().await?;
let data = response.bytes().await?;
x.replace('~', home)
} else {
x.to_owned()
};
let result = tokio::fs::read(path.clone()).await.track(track)?;
result.into()
} else {
let response = client.get(full_path.clone()).send().await.track(track)?;
if let Some(progress) = progress {
let total = response
.content_length()
.ok_or((track, tracks::error::Kind::UnknownLength))?;
let mut stream = response.bytes_stream();
let mut bytes = BytesMut::new();
let mut downloaded: u64 = 0;
while let Some(item) = stream.next().await {
let chunk = item.track(track)?;
let new = min(downloaded + (chunk.len() as u64), total);
downloaded = new;
progress.store((new as f32) / (total as f32), Ordering::Relaxed);
bytes.put(chunk);
}
bytes.into()
} else {
response.bytes().await.track(track)?
}
};
Ok((data, full_path))
Ok(data)
}
/// Fetches and downloads a random track from the [List].
///
/// The Result's error is a bool, which is true if a timeout error occured,
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
pub async fn random(
&self,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<QueuedTrack, tracks::Error> {
let (path, custom_name) = self.random_path();
let (data, full_path) = self.download(&path, client, progress).await?;
pub async fn random(&self, client: &Client) -> reqwest::Result<Track> {
let name = self.random_name();
let data = self.download(&name, client).await?;
let name = custom_name.map_or_else(
|| super::TrackName::Raw(path.clone()),
|formatted| super::TrackName::Formatted(formatted),
);
Ok(QueuedTrack {
name,
full_path,
data,
})
Ok(Track { name, data })
}
/// Parses text into a [List].
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
pub fn new(text: &str) -> eyre::Result<Self> {
let lines: Vec<String> = text
.trim_end()
.lines()
.map(|x| x.trim_end().to_owned())
.split_ascii_whitespace()
.map(|x| x.to_owned())
.collect();
Self {
lines,
path: path.map(|s| s.to_owned()),
name: name.to_owned(),
}
Ok(Self { lines })
}
/// Reads a [List] from the filesystem using the CLI argument provided.
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 {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let path = data_dir()?.join(format!("{arg}.txt"));
let path = if path.exists() { path } else { arg.into() };
let name = dirs::data_dir()
.unwrap()
.join("lowfi")
.join(arg)
.join(".txt");
let raw = fs::read_to_string(path.clone()).await?;
// Get rid of special noheader case for tracklists without a header.
let raw = if let Some(stripped) = raw.strip_prefix("noheader") {
stripped
let raw = if name.exists() {
fs::read_to_string(name).await?
} else {
&raw
fs::read_to_string(arg).await?
};
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or_eyre("invalid track path")?;
Ok(Self::new(name, raw, path.to_str()))
List::new(&raw)
} else {
Ok(Self::new(
"chillhop",
include_str!("../../data/chillhop.txt"),
None,
))
List::new(include_str!("../../data/lofigirl.txt"))
}
}
}