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

@ -30,14 +30,28 @@ and as such it buffers 5 whole songs at a time instead of parts of the same song
> [!NOTE]
>
> If you're interested in maintaining a package for `lowfi`
> on package managers such as homebrew and the like, open an issue.
> Distro repositories may be slightly behind cargo, but lowfi is not security critical
> 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.
@ -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.
### Cargo
The recommended installation method is to use cargo:
Then, simply run:
```sh
cargo install lowfi
@ -57,7 +69,7 @@ cargo install lowfi
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.
### 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 |
| `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI |
| `-c`, `--clock` | Include a clock |
| `-p`, `--paused` | Start lowfi paused |
| `-f`, `--fps` | FPS of the UI [default: 12] |
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
| `-d`, `--debug` | Include ALSA & other logs |
| `-d`, `--debug` | Include ALSA & other logs meant for debugging |
| `-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] |
@ -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.
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,
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.
> [!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.
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,
};
use crate::tracks;
use crate::{tracks, ui::interface::Logger};
use reqwest::Client;
use tokio::sync::mpsc;
@ -68,20 +68,60 @@ pub struct Downloader {
/// The list of tracks to download from.
tracks: tracks::List,
/// Whether to log more debug information.
debug: bool,
/// The [`reqwest`] client to use for downloads.
client: Client,
/// The RNG generator to use.
rng: fastrand::Rng,
/// Logger, to report any download errors to the UI.
logger: Logger,
}
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
/// the cycle of downloading tracks and reporting to the
/// rest of the program.
async fn run(mut self) -> crate::Result<()> {
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
loop {
let result = self
.tracks
@ -90,18 +130,19 @@ impl Downloader {
match result {
Ok(track) => {
if self.debug {
self.logger
.info(format!("debug: adding {} to queue", track.display))
.await?;
}
self.queue.send(track).await?;
if LOADING.load(atomic::Ordering::Relaxed) {
self.tx.send(crate::Message::Loaded).await?;
LOADING.store(false, atomic::Ordering::Relaxed);
}
}
Err(error) => {
PROGRESS.store(0, atomic::Ordering::Relaxed);
if !error.timeout() {
tokio::time::sleep(ERROR_TIMEOUT).await;
}
}
Err(error) => self.handle_error(error).await?,
}
}
}
@ -146,8 +187,8 @@ impl crate::Tasks {
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
pub fn downloader(
&mut self,
size: usize,
timeout: u64,
args: &crate::Args,
logger: Logger,
tracks: tracks::List,
) -> crate::Result<Handle> {
let client = Client::builder()
@ -156,11 +197,13 @@ impl crate::Tasks {
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(timeout))
.timeout(Duration::from_secs(args.timeout))
.build()?;
let (qtx, qrx) = mpsc::channel(size - 1);
let (qtx, qrx) = mpsc::channel(args.buffer_size as usize - 1);
let downloader = Downloader {
debug: args.debug,
logger,
queue: qtx,
tx: self.tx(),
tracks,

View File

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

View File

@ -1,7 +1,7 @@
//! The module containing all of the logic behind track lists,
//! 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 futures_util::StreamExt as _;
@ -28,8 +28,8 @@ pub struct List {
#[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.
/// Just the raw file, but separated by `/n` (newlines).
/// `lines[0]` is the base/header, with the rest being tracks.
pub lines: Vec<String>,
/// 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)?;
result.into()
} 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 bytes = response.bytes().await.track(track)?;
return Ok((bytes, path));
@ -139,8 +145,27 @@ impl List {
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].
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
.trim_end()
.lines()
@ -156,6 +181,15 @@ impl List {
/// Reads a [List] from the filesystem using the CLI argument provided.
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" {
#[cfg(feature = "default-tracklist")]
return Ok(Self::new(
@ -168,23 +202,6 @@ impl List {
return Err(tracks::error::Kind::NoTrackList.into());
}
// 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"));
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()))
Self::from_file(tracks, None).await
}
}

View File

@ -2,7 +2,7 @@
use std::sync::Arc;
use crate::player::Current;
use crate::{player::Current, ui::interface::Logger};
use tokio::{sync::broadcast, time::Instant};
pub mod environment;
@ -34,6 +34,9 @@ pub enum Error {
#[error("sharing state between backend and frontend failed: {0}")]
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!")]
RejectedDisable,
@ -114,6 +117,9 @@ pub struct Handle {
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
#[cfg(feature = "mpris")]
pub mpris: mpris::Server,
/// Logger which can be used to log important events.
logger: Logger,
}
impl Handle {
@ -122,6 +128,11 @@ impl Handle {
self.updater.send(update)?;
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
@ -134,11 +145,9 @@ impl Handle {
/// and `params` specifies aesthetic options that are specified by the user.
pub async fn run(
mut updater: broadcast::Receiver<Update>,
mut interface: Interface,
mut state: State,
params: interface::Params,
) -> Result<()> {
let mut interface = Interface::new(params)?;
loop {
if let Ok(message) = updater.try_recv() {
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 params = interface::Params::try_from(args)?;
let interface = interface::Interface::new(params)?;
let logger = interface.logger.clone();
if params.enabled {
self.spawn(ui::run(urx, state, params));
self.spawn(ui::run(urx, interface, state));
self.spawn(input::listen(self.tx()));
}
Ok(ui::Handle {
updater: utx,
logger,
#[cfg(feature = "mpris")]
mpris,
})

View File

@ -13,8 +13,21 @@ pub mod window;
pub use clock::Clock;
pub use titlebar::TitleBar;
use tokio::sync::mpsc;
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.
#[derive(Copy, Clone, Debug)]
pub struct Params {
@ -93,6 +106,13 @@ pub struct Interface {
/// The interface parameters that control smaller
/// aesthetic features and options.
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 {
@ -106,10 +126,13 @@ impl Interface {
/// Creates a new interface.
pub fn new(params: Params) -> ui::Result<Self> {
let mut window = Window::new(params.width, params.borderless, false, true);
let (sender, logs) = mpsc::channel(8);
Ok(Self {
clock: params.clock.then(|| Clock::new(&mut window)),
interval: tokio::time::interval(params.delta),
logger: Logger(sender),
logs,
window,
params,
})
@ -148,7 +171,8 @@ impl Interface {
}
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;
Ok(())

View File

@ -99,14 +99,22 @@ impl Window {
}
/// 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(
&mut self,
mut writer: impl std::io::Write,
log: Option<String>,
content: Vec<String>,
) -> ui::Result<()> {
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,
Clear(ClearType::FromCursorDown),
MoveToColumn(0),
@ -115,6 +123,7 @@ impl Window {
MoveUp(height - 1),
)?;
writer.flush()?;
Ok(())
}
}