Compare commits

...

10 Commits
2.0.6 ... main

Author SHA1 Message Date
talwat
4d46c41df7 docs: add notice about distros being behind 2026-04-27 14:32:09 +02:00
Tal
4891e626e1
feat: logging (#127)
* feat: add basic logging for error codes on tracks

* fix: refine log printing to make sure nothing overlaps

* docs: add comments for logger
2026-04-17 20:23:22 +02:00
talwat
5efecea14e docs: move repology badge to the right 2026-04-16 20:48:50 +02:00
danielwerg
64b7694fb9
docs: add repology badge (#125)
* feat(docs): add repology badge

* docs: move repology badge
2026-04-16 20:47:12 +02:00
talwat
c0ebdb057f fix: cleanup and linter warnings 2026-04-16 15:50:28 +02:00
talwat
1d7963fdc8 feat: allow for track named chillhop.txt 2026-04-16 15:48:59 +02:00
talwat
767f8e8017 docs: capitalize and in cargo section 2026-04-16 00:31:19 +02:00
talwat
4e9ff033d8 docs: add methods section with links to install guide 2026-04-16 00:29:37 +02:00
talwat
68766b8607 docs: mention clock in readme 2026-04-16 00:20:21 +02:00
talwat
fb75cc1a28 docs: remove trailing whitespace 2026-04-15 13:52:12 +02:00
9 changed files with 177 additions and 73 deletions

View File

@ -66,19 +66,3 @@ indicatif = { version = "0.18.4", optional = true }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.182" libc = "0.2.182"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
unwrap_in_result = "warn"
missing_docs_in_private_items = "warn"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
cast_possible_truncation = "allow"
struct_excessive_bools = "allow"

View File

@ -20,7 +20,7 @@ by default are from [chillhop](https://chillhop.com/). Read
## Why? ## Why?
I really hate modern music platforms, and I wanted a small, simple I really hate modern music platforms, and I wanted a small, simple
app that would just play random ambient music without video and other fluff. app that would just play random ambient music without video and other fluff.
Beyond that, it was also designed to be fairly resilient to inconsistent networks, Beyond that, it was also designed to be fairly resilient to inconsistent networks,
@ -30,14 +30,28 @@ and as such it buffers 5 whole songs at a time instead of parts of the same song
> [!NOTE] > [!NOTE]
> >
> If you're interested in maintaining a package for `lowfi` > Distro repositories may be slightly behind cargo, but lowfi is not security critical
> on package managers such as homebrew and the like, open an issue. > so they are still perfectly fine to use.
### Dependencies <a href="https://repology.org/project/lowfi/versions">
<img src="https://repology.org/badge/vertical-allrepos/lowfi.svg" alt="Packaging status" align="right">
</a>
You'll need Rust 1.83.0+. ### Methods
On MacOS & Windows, no extra dependencies are needed. - [Cargo](#cargo)
- [Release Binaries](#release-binaries)
- [AUR](#aur)
- [openSUSE](#opensuse)
- [Debian and Ubuntu](#debian-and-ubuntu)
- [Fedora (COPR)](#fedora-copr)
- [Manual](#manual)
### Cargo
Installing with cargo is universal, but carries a few dependencies with it.
Firstly, you'll need Rust 1.83.0+. On MacOS & Windows, no extra dependencies are needed.
On Linux, you'll also need openssl & alsa, as well as their headers. On Linux, you'll also need openssl & alsa, as well as their headers.
@ -46,9 +60,7 @@ On Linux, you'll also need openssl & alsa, as well as their headers.
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 Then, simply run:
The recommended installation method is to use cargo:
```sh ```sh
cargo install lowfi cargo install lowfi
@ -57,7 +69,7 @@ cargo install lowfi
cargo install lowfi --features mpris cargo install lowfi --features mpris
``` ```
and making sure `$HOME/.cargo/bin` is added to `$PATH`. And make sure `$HOME/.cargo/bin` is added to `$PATH`.
Also see [Extra Features](#extra-features) for extended functionality. Also see [Extra Features](#extra-features) for extended functionality.
### Release Binaries ### Release Binaries
@ -159,10 +171,11 @@ slightly tweak the UI or behavior of the menu. The flags can be viewed with `low
| `-a`, `--alternate` | Use an alternate terminal screen | | `-a`, `--alternate` | Use an alternate terminal screen |
| `-m`, `--minimalist` | Hide the bottom control bar | | `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI | | `-b`, `--borderless` | Exclude borders in UI |
| `-c`, `--clock` | Include a clock |
| `-p`, `--paused` | Start lowfi paused | | `-p`, `--paused` | Start lowfi paused |
| `-f`, `--fps` | FPS of the UI [default: 12] | | `-f`, `--fps` | FPS of the UI [default: 12] |
| `--timeout` | Timeout in seconds for music downloads [default: 3] | | `--timeout` | Timeout in seconds for music downloads [default: 3] |
| `-d`, `--debug` | Include ALSA & other logs | | `-d`, `--debug` | Include ALSA & other logs meant for debugging |
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] | | `-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) | | `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] | | `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
@ -204,7 +217,7 @@ If you are dealing with the 1% using another audio format which is in
> Feel free to contribute your own list with a PR. > Feel free to contribute your own list with a PR.
lowfi also supports custom track lists, although the default one from chillhop lowfi also supports custom track lists, although the default one from chillhop
is embedded into the binary. is embedded into the binary by default.
To use a custom list, use the `--track-list` flag. This can either be a path to some file, To use a custom list, use the `--track-list` flag. This can either be a path to some file,
or it could also be the name of a file (without the `.txt` extension) in the data or it could also be the name of a file (without the `.txt` extension) in the data
@ -231,7 +244,7 @@ Each track will be first appended to the header, and then use the combination to
the track. the track.
> [!NOTE] > [!NOTE]
> lowfi *will not* put a `/` between the base & track for added flexibility, > lowfi _will not_ put a `/` between the base & track for added flexibility,
> so for most cases you should have a trailing `/` in your header. > so for most cases you should have a trailing `/` in your header.
The exception to this is if the track name begins with a protocol like `https://`, The exception to this is if the track name begins with a protocol like `https://`,

View File

@ -5,7 +5,7 @@ use std::{
time::Duration, time::Duration,
}; };
use crate::tracks; use crate::{tracks, ui::interface::Logger};
use reqwest::Client; use reqwest::Client;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -68,20 +68,60 @@ pub struct Downloader {
/// The list of tracks to download from. /// The list of tracks to download from.
tracks: tracks::List, tracks: tracks::List,
/// Whether to log more debug information.
debug: bool,
/// The [`reqwest`] client to use for downloads. /// The [`reqwest`] client to use for downloads.
client: Client, client: Client,
/// The RNG generator to use. /// The RNG generator to use.
rng: fastrand::Rng, rng: fastrand::Rng,
/// Logger, to report any download errors to the UI.
logger: Logger,
} }
impl Downloader { impl Downloader {
/// Handles an error encountered by the downloader.
///
/// If the error isn't a timeout, it will also stall for some arbitrary error timeout.
async fn handle_error(&mut self, error: tracks::Error) -> crate::Result<()> {
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
PROGRESS.store(0, atomic::Ordering::Relaxed);
let track_suffix = if let Some(x) = error.track.as_ref() {
format!(" track: {x}")
} else {
String::new()
};
match &error.kind {
tracks::error::Kind::Request(x) => {
if let Some(status) = x.status() {
let message =
format!("track list error code: {}{track_suffix}", status.as_u16());
self.logger.info(message).await?;
}
}
error if self.debug => {
let message = format!("debug: error: {}{track_suffix}", error);
self.logger.info(message).await?;
}
_ => {}
}
if !error.timeout() {
tokio::time::sleep(ERROR_TIMEOUT).await;
}
Ok(())
}
/// Actually runs the downloader, consuming it and beginning /// Actually runs the downloader, consuming it and beginning
/// the cycle of downloading tracks and reporting to the /// the cycle of downloading tracks and reporting to the
/// rest of the program. /// rest of the program.
async fn run(mut self) -> crate::Result<()> { async fn run(mut self) -> crate::Result<()> {
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
loop { loop {
let result = self let result = self
.tracks .tracks
@ -90,18 +130,19 @@ impl Downloader {
match result { match result {
Ok(track) => { Ok(track) => {
if self.debug {
self.logger
.info(format!("debug: adding {} to queue", track.display))
.await?;
}
self.queue.send(track).await?; self.queue.send(track).await?;
if LOADING.load(atomic::Ordering::Relaxed) { if LOADING.load(atomic::Ordering::Relaxed) {
self.tx.send(crate::Message::Loaded).await?; self.tx.send(crate::Message::Loaded).await?;
LOADING.store(false, atomic::Ordering::Relaxed); LOADING.store(false, atomic::Ordering::Relaxed);
} }
} }
Err(error) => { Err(error) => self.handle_error(error).await?,
PROGRESS.store(0, atomic::Ordering::Relaxed);
if !error.timeout() {
tokio::time::sleep(ERROR_TIMEOUT).await;
}
}
} }
} }
} }
@ -146,8 +187,8 @@ impl crate::Tasks {
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`]. /// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
pub fn downloader( pub fn downloader(
&mut self, &mut self,
size: usize, args: &crate::Args,
timeout: u64, logger: Logger,
tracks: tracks::List, tracks: tracks::List,
) -> crate::Result<Handle> { ) -> crate::Result<Handle> {
let client = Client::builder() let client = Client::builder()
@ -156,11 +197,13 @@ impl crate::Tasks {
"/", "/",
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
)) ))
.timeout(Duration::from_secs(timeout)) .timeout(Duration::from_secs(args.timeout))
.build()?; .build()?;
let (qtx, qrx) = mpsc::channel(size - 1); let (qtx, qrx) = mpsc::channel(args.buffer_size as usize - 1);
let downloader = Downloader { let downloader = Downloader {
debug: args.debug,
logger,
queue: qtx, queue: qtx,
tx: self.tx(), tx: self.tx(),
tracks, tracks,

View File

@ -111,9 +111,10 @@ impl Player {
let volume = PersistentVolume::load().await?; let volume = PersistentVolume::load().await?;
sink.set_volume(volume.float()); sink.set_volume(volume.float());
let ui = tasks.ui(state, &args).await?;
let player = Self { let player = Self {
ui: tasks.ui(state, &args).await?, downloader: tasks.downloader(&args, ui.logger(), list)?,
downloader: tasks.downloader(args.buffer_size as usize, args.timeout, list)?, ui,
waiter: tasks.waiter(Arc::clone(&sink)), waiter: tasks.waiter(Arc::clone(&sink)),
bookmarks: Bookmarks::load().await?, bookmarks: Bookmarks::load().await?,
current: Current::default(), current: Current::default(),

View File

@ -1,7 +1,7 @@
//! The module containing all of the logic behind track lists, //! The module containing all of the logic behind track lists,
//! as well as obtaining track names & downloading the raw audio data //! as well as obtaining track names & downloading the raw audio data
use std::cmp::min; use std::{cmp::min, path::Path};
use bytes::{BufMut as _, Bytes, BytesMut}; use bytes::{BufMut as _, Bytes, BytesMut};
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
@ -28,8 +28,8 @@ pub struct List {
#[allow(dead_code)] #[allow(dead_code)]
pub name: String, pub name: String,
/// Just the raw file, but seperated by `/n` (newlines). /// Just the raw file, but separated by `/n` (newlines).
/// `lines[0]` is the base/heaeder, with the rest being tracks. /// `lines[0]` is the base/header, with the rest being tracks.
pub lines: Vec<String>, pub lines: Vec<String>,
/// The file path which the list was read from. /// The file path which the list was read from.
@ -95,7 +95,13 @@ impl List {
let result = tokio::fs::read(path.clone()).await.track(x)?; let result = tokio::fs::read(path.clone()).await.track(x)?;
result.into() result.into()
} else { } else {
let response = client.get(path.clone()).send().await.track(track)?; let response = client
.get(path.clone())
.send()
.await?
.error_for_status()
.track(track)?;
let Some(progress) = progress else { let Some(progress) = progress else {
let bytes = response.bytes().await.track(track)?; let bytes = response.bytes().await.track(track)?;
return Ok((bytes, path)); return Ok((bytes, path));
@ -139,8 +145,27 @@ impl List {
Queued::new(path, data, display) Queued::new(path, data, display)
} }
/// Reads a file and parses it into a list.
pub async fn from_file(path: impl AsRef<Path>, name: Option<&str>) -> tracks::Result<Self> {
let path = path.as_ref();
let text = fs::read_to_string(path).await?;
let name = match name {
Some(name) => name,
None => path
.file_stem()
.and_then(|x| x.to_str())
.ok_or(tracks::error::Kind::InvalidName)?,
};
Ok(Self::new(name, &text, path.to_str()))
}
/// Parses text into a [List]. /// Parses text into a [List].
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self { pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
// Get rid of special noheader case for tracklists without a header.
let text = text.strip_prefix("noheader").unwrap_or(text);
let lines: Vec<String> = text let lines: Vec<String> = text
.trim_end() .trim_end()
.lines() .lines()
@ -156,6 +181,15 @@ impl List {
/// Reads a [List] from the filesystem using the CLI argument provided. /// Reads a [List] from the filesystem using the CLI argument provided.
pub async fn load(tracks: &str) -> tracks::Result<Self> { pub async fn load(tracks: &str) -> tracks::Result<Self> {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let path = data_dir()
.map_err(|_| error::Kind::InvalidPath)?
.join(format!("{tracks}.txt"));
if path.exists() {
return Self::from_file(path, Some(tracks)).await;
}
if tracks == "chillhop" { if tracks == "chillhop" {
#[cfg(feature = "default-tracklist")] #[cfg(feature = "default-tracklist")]
return Ok(Self::new( return Ok(Self::new(
@ -168,23 +202,6 @@ impl List {
return Err(tracks::error::Kind::NoTrackList.into()); return Err(tracks::error::Kind::NoTrackList.into());
} }
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that. Self::from_file(tracks, None).await
let path = data_dir()
.map_err(|_| error::Kind::InvalidPath)?
.join(format!("{tracks}.txt"));
let path = if path.exists() { path } else { tracks.into() };
let raw = fs::read_to_string(path.clone()).await?;
// Get rid of special noheader case for tracklists without a header.
let raw = raw.strip_prefix("noheader").unwrap_or_else(|| raw.as_ref());
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or(tracks::error::Kind::InvalidName)
.track(tracks)?;
Ok(Self::new(name, raw, path.to_str()))
} }
} }

View File

@ -2,7 +2,7 @@
use std::sync::Arc; use std::sync::Arc;
use crate::player::Current; use crate::{player::Current, ui::interface::Logger};
use tokio::{sync::broadcast, time::Instant}; use tokio::{sync::broadcast, time::Instant};
pub mod environment; pub mod environment;
@ -34,6 +34,9 @@ pub enum Error {
#[error("sharing state between backend and frontend failed: {0}")] #[error("sharing state between backend and frontend failed: {0}")]
StateSend(#[from] tokio::sync::broadcast::error::SendError<Update>), StateSend(#[from] tokio::sync::broadcast::error::SendError<Update>),
#[error("error sending a log: {0}")]
LogSend(#[from] tokio::sync::mpsc::error::SendError<String>),
#[error("you can't disable the UI without MPRIS!")] #[error("you can't disable the UI without MPRIS!")]
RejectedDisable, RejectedDisable,
@ -114,6 +117,9 @@ pub struct Handle {
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread. /// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
pub mpris: mpris::Server, pub mpris: mpris::Server,
/// Logger which can be used to log important events.
logger: Logger,
} }
impl Handle { impl Handle {
@ -122,6 +128,11 @@ impl Handle {
self.updater.send(update)?; self.updater.send(update)?;
Ok(()) Ok(())
} }
/// Creates a new handle to log events.
pub fn logger(&self) -> Logger {
self.logger.clone()
}
} }
/// The main UI process, which will both render the UI to the terminal /// The main UI process, which will both render the UI to the terminal
@ -134,11 +145,9 @@ impl Handle {
/// and `params` specifies aesthetic options that are specified by the user. /// and `params` specifies aesthetic options that are specified by the user.
pub async fn run( pub async fn run(
mut updater: broadcast::Receiver<Update>, mut updater: broadcast::Receiver<Update>,
mut interface: Interface,
mut state: State, mut state: State,
params: interface::Params,
) -> Result<()> { ) -> Result<()> {
let mut interface = Interface::new(params)?;
loop { loop {
if let Ok(message) = updater.try_recv() { if let Ok(message) = updater.try_recv() {
match message { match message {

View File

@ -13,13 +13,17 @@ impl crate::Tasks {
let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?; let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?;
let params = interface::Params::try_from(args)?; let params = interface::Params::try_from(args)?;
let interface = interface::Interface::new(params)?;
let logger = interface.logger.clone();
if params.enabled { if params.enabled {
self.spawn(ui::run(urx, state, params)); self.spawn(ui::run(urx, interface, state));
self.spawn(input::listen(self.tx())); self.spawn(input::listen(self.tx()));
} }
Ok(ui::Handle { Ok(ui::Handle {
updater: utx, updater: utx,
logger,
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
mpris, mpris,
}) })

View File

@ -13,8 +13,21 @@ pub mod window;
pub use clock::Clock; pub use clock::Clock;
pub use titlebar::TitleBar; pub use titlebar::TitleBar;
use tokio::sync::mpsc;
pub use window::Window; pub use window::Window;
/// Wrapper around [`mpsc::Sender`] which provides a nice API for logging.
#[derive(Clone)]
pub struct Logger(mpsc::Sender<String>);
impl Logger {
/// Send an informational log.
pub async fn info(&self, message: String) -> ui::Result<()> {
self.0.send(message).await?;
Ok(())
}
}
/// UI-specific parameters and options. /// UI-specific parameters and options.
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub struct Params { pub struct Params {
@ -93,6 +106,13 @@ pub struct Interface {
/// The interface parameters that control smaller /// The interface parameters that control smaller
/// aesthetic features and options. /// aesthetic features and options.
params: Params, params: Params,
/// Receiver for any truly import live time logs about tracks.
pub logs: mpsc::Receiver<String>,
/// An instance of the logger which can be cloned to give tasks
/// access to logging.
pub(super) logger: Logger,
} }
impl Default for Interface { impl Default for Interface {
@ -106,10 +126,13 @@ impl Interface {
/// Creates a new interface. /// Creates a new interface.
pub fn new(params: Params) -> ui::Result<Self> { pub fn new(params: Params) -> ui::Result<Self> {
let mut window = Window::new(params.width, params.borderless, false, true); let mut window = Window::new(params.width, params.borderless, false, true);
let (sender, logs) = mpsc::channel(8);
Ok(Self { Ok(Self {
clock: params.clock.then(|| Clock::new(&mut window)), clock: params.clock.then(|| Clock::new(&mut window)),
interval: tokio::time::interval(params.delta), interval: tokio::time::interval(params.delta),
logger: Logger(sender),
logs,
window, window,
params, params,
}) })
@ -148,7 +171,8 @@ impl Interface {
} }
let menu = self.menu(state); let menu = self.menu(state);
self.window.draw(stdout().lock(), menu)?; self.window
.draw(stdout().lock(), self.logs.try_recv().ok(), menu)?;
self.interval.tick().await; self.interval.tick().await;
Ok(()) Ok(())

View File

@ -99,14 +99,22 @@ impl Window {
} }
/// Actually draws the window, with each element in `content` being on a new line. /// Actually draws the window, with each element in `content` being on a new line.
///
/// If `log` is [`Some`], then it will also print it after clearing, but before the lowfi window.
pub fn draw( pub fn draw(
&mut self, &mut self,
mut writer: impl std::io::Write, mut writer: impl std::io::Write,
log: Option<String>,
content: Vec<String>, content: Vec<String>,
) -> ui::Result<()> { ) -> ui::Result<()> {
let (rendered, height) = self.render(content)?; let (rendered, height) = self.render(content)?;
crossterm::queue!(writer, Clear(ClearType::FromCursorDown), MoveToColumn(0))?;
crossterm::execute!( if let Some(log) = log {
crossterm::queue!(writer, Print(log), Print("\n"), MoveToColumn(0))?;
}
crossterm::queue!(
writer, writer,
Clear(ClearType::FromCursorDown), Clear(ClearType::FromCursorDown),
MoveToColumn(0), MoveToColumn(0),
@ -115,6 +123,7 @@ impl Window {
MoveUp(height - 1), MoveUp(height - 1),
)?; )?;
writer.flush()?;
Ok(()) Ok(())
} }
} }