feat: prepare for 1.7.0 release

docs: explain music situation
docs: more internal documentation
feat: make timeout configurable
chore: clean up some sections of code
This commit is contained in:
Tal 2025-09-06 21:37:50 +02:00
parent dd9aab7118
commit 84887ae01b
15 changed files with 150 additions and 51 deletions

View File

@ -1,5 +1,10 @@
# Using the chillhop list # Using the chillhop list
> [!WARNING]
> As of lowfi 1.7.0, the chillhop list is included by default. For a more
> detailed explanation, see [MUSIC.md](MUSIC.md). This document is included
> to preserve any old links or references. The instructions are still valid.
## Linux ## Linux
```sh ```sh

2
Cargo.lock generated
View File

@ -1508,7 +1508,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]] [[package]]
name = "lowfi" name = "lowfi"
version = "1.7.1-dev" version = "1.7.2-dev"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"atomic_float", "atomic_float",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lowfi" name = "lowfi"
version = "1.7.1-dev" version = "1.7.2-dev"
edition = "2021" edition = "2021"
description = "An extremely simple lofi player." description = "An extremely simple lofi player."
license = "MIT" license = "MIT"

75
MUSIC.md Normal file
View File

@ -0,0 +1,75 @@
# The State of Lowfi's Music
> [!WARNING]
> This document will be a bit long and has almost nothing to do with the actual
> usage of lowfi, just the music embedded by default.
Before that though, some context. lowfi includes an extensive track list
embedded into the software, so you can download it and have it "just work"
out of the box.
I always hated apps that required extensive configuration just to be usable.
Sometimes it's justified, but often, it's just pointless when most will end up
with the same set of "defaults" that aren't really defaults.
Lowfi is so nice and simple because of the "plug and play" aspect,
but it's become a lot harder to continue it as of late.
## The Lofi Girl List
Originally, it was planned that lowfi would use music scraped from Lofi Girl's own
website. The scraper actually came before the rest of the program, believe it or not.
However, after a long period of downtime, the Lofi Girl website was redone without the
mp3 track files. Those are now pretty much inaccessible aside from paying for individual
albums on bandcamp which gets very expensive very quickly.
Doing this was never actually disallowed, but it is now simply impossible. So, the question was,
what to do next after losing lowfi's primary source of music?
## Tracklists
I was originally against the idea of custom tracklists, because of my almost purist
ideals of a 100% no config at all vision for lowfi. But eventually, I gave in, which proved
to be a very good decision in hindsight. Now, regardless of what choices I make on the music
which is embedded, all may opt out of that and choose whatever they like.
This culminated in a few templates located in the `data` directory of this repository
which included a handful of tracklists, and in particular, the chillhop list by user
[danielwerg](https://github.com/danielwerg).
## The Switch
After `lofigirl.com` went down, I thought a bit and eventually decided
to just bite the bullet and switch to the chillhop list. This was despite the fact
that chillhop entirely bans third party players in their TOS. They also ban
scrapers, which I only learned after writing one.
So, is lowfi really going to have to violate the TOS of it's own music provider?
Well, yes. I thought about it, and came to the conclusion that lowfi is probably
not much of a threat for a few reasons.
Firstly, it emulates exactly the behavior of chillhop's own radio player.
The only difference is that one shoves you into a web browser, and the other,
into a nice terminal window.
Then, I also realize that lowfi is just a small program used by few.
I'm not making money on any of this, and I think degrading the experience for my
fellow nerds who just want to listen to some lowfi without all the crap is not worth it.
At the end of the day, lowfi has a distinct UserAgent. Should chillhop ever take issue with
it's behaviour, banning it is extremely simple. I don't want that to happen, but I
understand if it does.
## Well, *I* Hate the Chillhop Music
It's not as "lofi". It is almost certainly a compromise, that much I cannot even pretend to
deny. I find myself hitting the skip button almost three times as often with chillhop.
If you are undeterred enough by TOS's to read this far, then you can use the `archive.txt`
list in the `data` folder. The list is a product of me worrying that the tracks on `lofigirl.com`
could've possibly been lost somehow, relating to the website going down.
It's hosted on `archive.org`, and could be taken down at any point for any reason.
Being derived from my own local archive, it retains ~2700 out of the ~3700 tracks.
That's not perfect, the organization is also *bad*, but it exists.

View File

@ -8,9 +8,8 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
## Disclaimer ## Disclaimer
As of the 1.7.0 version of lowfi, **all** of the audio files embedded As of the 1.7.0 version of lowfi, **all** of the audio files embedded
by default are from [chillhop](https://chillhop.com/). by default are from [chillhop](https://chillhop.com/). Read
[MUSIC.md] for more information.
<!-- TODO: Make seperate write-up about using chillhop. -->
## Why? ## Why?
@ -154,15 +153,10 @@ slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lo
### Scraping ### Scraping
lowfi also has a `scrape` command which is usually not relevant, but lowfi also has an optional `scrape` command enabled by the `scrape` feature.
if you're trying to download some files from Lofi Girls' website, It's usually not very useful, but is included for transparency's sake.
it can be useful.
An example of scrape is as follows, More information can be found by running `lowfi help scrape`.
`lowfi scrape --extension zip --include-full`
where more information can be found by running `lowfi help scrape`.
### Custom Track Lists ### Custom Track Lists

View File

@ -16,6 +16,7 @@ mod scrapers;
#[cfg(feature = "scrape")] #[cfg(feature = "scrape")]
use crate::scrapers::Source; use crate::scrapers::Source;
/// An extremely simple lofi player. /// An extremely simple lofi player.
#[derive(Parser, Clone)] #[derive(Parser, Clone)]
#[command(about, version)] #[command(about, version)]
@ -41,6 +42,10 @@ struct Args {
#[clap(long, short, default_value_t = 12)] #[clap(long, short, default_value_t = 12)]
fps: u8, fps: u8,
/// Timeout in seconds for music downloads.
#[clap(long, default_value_t = 3)]
timeout: u64,
/// Include ALSA & other logs. /// Include ALSA & other logs.
#[clap(long, short)] #[clap(long, short)]
debug: bool, debug: bool,
@ -50,7 +55,7 @@ struct Args {
width: usize, width: usize,
/// Use a custom track list /// Use a custom track list
#[clap(long, short, alias = "list", short_alias = 'l')] #[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
track_list: Option<String>, track_list: Option<String>,
/// Internal song buffer size. /// Internal song buffer size.

View File

@ -70,7 +70,9 @@ pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
drop(stream); drop(stream);
player.sink.stop(); player.sink.stop();
ui.map(|x| x.abort()); if let Some(x) = ui {
x.abort();
}
Ok(()) Ok(())
} }

View File

@ -41,10 +41,6 @@ pub use error::Error;
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
pub mod mpris; pub mod mpris;
/// The time to wait in between errors.
/// TODO: Make this configurable.
const TIMEOUT: Duration = Duration::from_secs(3);
/// Main struct responsible for queuing up & playing tracks. /// Main struct responsible for queuing up & playing tracks.
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s. // TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
// TODO: In other words, this would change the type from `Arc<Player>` to just `Player`. // TODO: In other words, this would change the type from `Arc<Player>` to just `Player`.
@ -76,6 +72,9 @@ pub struct Player {
/// The bookmarks, which are saved on quit. /// The bookmarks, which are saved on quit.
pub bookmarks: Bookmarks, pub bookmarks: Bookmarks,
/// The timeout for track downloads, as a [Duration].
timeout: Duration,
/// The actual list of tracks to be played. /// The actual list of tracks to be played.
list: List, list: List,
@ -144,7 +143,7 @@ impl Player {
"/", "/",
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
)) ))
.timeout(TIMEOUT * 5) .timeout(Duration::from_secs(args.timeout * 5))
.build()?; .build()?;
let player = Self { let player = Self {
@ -152,6 +151,7 @@ impl Player {
buffer_size: args.buffer_size, buffer_size: args.buffer_size,
current: ArcSwapOption::new(None), current: ArcSwapOption::new(None),
progress: AtomicF32::new(0.0), progress: AtomicF32::new(0.0),
timeout: Duration::from_secs(args.timeout),
bookmarks, bookmarks,
client, client,
sink, sink,
@ -301,7 +301,7 @@ impl Player {
let current = player.current.load(); let current = player.current.load();
let current = current.as_ref().unwrap(); let current = current.as_ref().unwrap();
player.bookmarks.bookmark(&&current).await?; player.bookmarks.bookmark(current).await?;
} }
Message::Quit => break, Message::Quit => break,
} }

View File

@ -1,12 +1,15 @@
use std::io::SeekFrom; //! Module for handling saving, loading, and adding
//! bookmarks.
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use tokio::fs::{create_dir_all, File, OpenOptions}; use tokio::fs::{create_dir_all, File, OpenOptions};
use tokio::io::{self, AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{data_dir, tracks}; use crate::{data_dir, tracks};
/// Errors that might occur while managing bookmarks.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BookmarkError { pub enum BookmarkError {
#[error("data directory not found")] #[error("data directory not found")]
@ -18,24 +21,36 @@ pub enum BookmarkError {
/// Manages the bookmarks in the current player. /// Manages the bookmarks in the current player.
pub struct Bookmarks { pub struct Bookmarks {
/// The different entries in the bookmarks file.
entries: RwLock<Vec<String>>, entries: RwLock<Vec<String>>,
file: RwLock<File>,
/// The internal bookmarked register, which keeps track
/// of whether a track is bookmarked or not.
///
/// This is much more efficient than checking every single frame.
bookmarked: AtomicBool, bookmarked: AtomicBool,
} }
impl Bookmarks { impl Bookmarks {
pub async fn load() -> eyre::Result<Self, BookmarkError> { /// Actually opens the bookmarks file itself.
pub async fn open(write: bool) -> eyre::Result<File, BookmarkError> {
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?; let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
create_dir_all(data_dir.clone()).await?; create_dir_all(data_dir.clone()).await?;
let mut file = OpenOptions::new() OpenOptions::new()
.create(true) .create(true)
.write(true) .write(write)
.read(true) .read(true)
.append(false) .append(false)
.truncate(false) .truncate(true)
.open(data_dir.join("bookmarks.txt")) .open(data_dir.join("bookmarks.txt"))
.await?; .await
.map_err(BookmarkError::Io)
}
/// Loads bookmarks from the `bookmarks.txt` file.
pub async fn load() -> eyre::Result<Self, BookmarkError> {
let mut file = Self::open(false).await?;
let mut text = String::new(); let mut text = String::new();
file.read_to_string(&mut text).await?; file.read_to_string(&mut text).await?;
@ -45,29 +60,27 @@ impl Bookmarks {
.trim() .trim()
.lines() .lines()
.filter_map(|x| { .filter_map(|x| {
if !x.is_empty() { if x.is_empty() {
Some(x.to_string())
} else {
None None
} else {
Some(x.to_string())
} }
}) })
.collect(); .collect();
Ok(Self { Ok(Self {
entries: RwLock::new(lines), entries: RwLock::new(lines),
file: RwLock::new(file),
bookmarked: AtomicBool::new(false), bookmarked: AtomicBool::new(false),
}) })
} }
// Saves the bookmarks to the `bookmarks.txt` file.
pub async fn save(&self) -> eyre::Result<(), BookmarkError> { pub async fn save(&self) -> eyre::Result<(), BookmarkError> {
let mut file = Self::open(true).await?;
let text = format!("noheader\n{}", self.entries.read().await.join("\n")); let text = format!("noheader\n{}", self.entries.read().await.join("\n"));
let mut lock = self.file.write().await; file.write_all(text.as_bytes()).await?;
lock.seek(SeekFrom::Start(0)).await?; file.flush().await?;
lock.set_len(0).await?;
lock.write_all(text.as_bytes()).await?;
lock.flush().await?;
Ok(()) Ok(())
} }
@ -91,10 +104,14 @@ impl Bookmarks {
Ok(()) Ok(())
} }
/// Returns whether a track is bookmarked or not by using the internal
/// bookmarked register.
pub fn bookmarked(&self) -> bool { pub fn bookmarked(&self) -> bool {
self.bookmarked.load(std::sync::atomic::Ordering::Relaxed) self.bookmarked.load(std::sync::atomic::Ordering::Relaxed)
} }
/// Sets the internal bookmarked register by checking against
/// the current track's info.
pub async fn set_bookmarked(&self, track: &tracks::Info) { pub async fn set_bookmarked(&self, track: &tracks::Info) {
let val = self.entries.read().await.contains(&track.to_entry()); let val = self.entries.read().await.contains(&track.to_entry());
self.bookmarked self.bookmarked

View File

@ -8,7 +8,7 @@ use tokio::{
time::sleep, time::sleep,
}; };
use super::{Player, TIMEOUT}; use super::Player;
/// This struct is responsible for downloading tracks in the background. /// This struct is responsible for downloading tracks in the background.
/// ///
@ -53,7 +53,7 @@ impl Downloader {
} }
if !error.is_timeout() { if !error.is_timeout() {
sleep(TIMEOUT).await; sleep(self.player.timeout).await;
} }
} }
} }

View File

@ -6,7 +6,7 @@ use tokio::{sync::mpsc::Sender, time::sleep};
use crate::{ use crate::{
messages::Message, messages::Message,
player::{downloader::Downloader, Player, TIMEOUT}, player::{downloader::Downloader, Player},
tracks, tracks,
}; };
@ -76,7 +76,7 @@ impl Player {
} }
if !error.is_timeout() { if !error.is_timeout() {
sleep(TIMEOUT).await; sleep(player.timeout).await;
} }
tx.send(Message::TryAgain).await?; tx.send(Message::TryAgain).await?;

View File

@ -12,6 +12,7 @@ pub mod archive;
pub mod chillhop; pub mod chillhop;
pub mod lofigirl; pub mod lofigirl;
/// Represents the different sources which can be scraped.
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)] #[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
pub enum Source { pub enum Source {
Lofigirl, Lofigirl,
@ -20,6 +21,7 @@ pub enum Source {
} }
impl Source { impl Source {
/// Gets the cache directory name, for example, `chillhop`.
pub fn cache_dir(&self) -> &'static str { pub fn cache_dir(&self) -> &'static str {
match self { match self {
Source::Lofigirl => "lofigirl", Source::Lofigirl => "lofigirl",
@ -28,6 +30,7 @@ impl Source {
} }
} }
/// Gets the full root URL of the source.
pub fn url(&self) -> &'static str { pub fn url(&self) -> &'static str {
match self { match self {
Source::Chillhop => "https://chillhop.com", Source::Chillhop => "https://chillhop.com",

View File

@ -135,7 +135,7 @@ impl Info {
.collect() .collect()
} }
/// Formats a name with [convert_case]. /// Formats a name with [`convert_case`].
/// ///
/// This will also strip the first few numbers that are /// This will also strip the first few numbers that are
/// usually present on most lofi tracks and do some other /// usually present on most lofi tracks and do some other

View File

@ -44,7 +44,7 @@ where
Kind: From<E>, Kind: From<E>,
{ {
fn from((track, err): (T, E)) -> Self { fn from((track, err): (T, E)) -> Self {
Error { Self {
track: track.into(), track: track.into(),
kind: Kind::from(err), kind: Kind::from(err),
} }

View File

@ -133,7 +133,7 @@ impl List {
let name = custom_name.map_or_else( let name = custom_name.map_or_else(
|| super::TrackName::Raw(path.clone()), || super::TrackName::Raw(path.clone()),
|formatted| super::TrackName::Formatted(formatted), super::TrackName::Formatted,
); );
Ok(QueuedTrack { Ok(QueuedTrack {
@ -153,7 +153,7 @@ impl List {
Self { Self {
lines, lines,
path: path.map(|s| s.to_owned()), path: path.map(ToOwned::to_owned),
name: name.to_owned(), name: name.to_owned(),
} }
} }
@ -168,11 +168,9 @@ impl List {
let raw = fs::read_to_string(path.clone()).await?; let raw = fs::read_to_string(path.clone()).await?;
// Get rid of special noheader case for tracklists without a header. // Get rid of special noheader case for tracklists without a header.
let raw = if let Some(stripped) = raw.strip_prefix("noheader") { let raw = raw
stripped .strip_prefix("noheader")
} else { .map_or(raw.as_ref(), |stripped| stripped);
&raw
};
let name = path let name = path
.file_stem() .file_stem()