feat: add custom sources (#21)

* feat: initial work on decoupling embedded list file & the rest of the app

* chore: rename tracks.txt to lofigirl.txt

* fix: make base optional

* feat: partially revert previous commit

* feat: fix loading tracks with explicit url

* fix: include list in main player struct

* chore: reduce timeout

* chore: remove unused import

* fix: rename InitialProperties to PersistentVolume

* feat: move persistent volume init to player init function

* feat: add micropop.txt as an example of a custom tracklist

* docs: add note about mp3

* docs: move format of lists to list struct docs

* docs: document custom track lists

* fix: fix silly spelling error

* docs: update formatting

* docs: fix sample formatting

* docs: add missing sample track

* fix: fix ui when track name has special characters

* fix: use proper char counting on subject, not word

* fix: use unicode-segmentation to finally fix handling special characters

* fix: precompute track len

* fix: switch to using the unicode-width crate

* style: split off list into it's own module

* fix: move logic to read a list from the fs into the list module
This commit is contained in:
Tal 2024-10-15 14:15:51 +02:00 committed by GitHub
parent b2c225256f
commit 543aeee78c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 295 additions and 111 deletions

10
Cargo.lock generated
View File

@ -962,7 +962,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -1330,6 +1330,8 @@ dependencies = [
"rodio",
"scraper",
"tokio",
"unicode-width 0.2.0",
"url",
]
[[package]]
@ -2671,6 +2673,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@ -49,3 +49,5 @@ scraper = "0.20.0"
Inflector = "0.11.4"
lazy_static = "1.5.0"
libc = "0.2.159"
url = "2.5.2"
unicode-width = "0.2.0"

View File

@ -105,3 +105,51 @@ An example of scrape is as follows,
`lowfi scrape --extension zip --include-full`
where more information can be found by running `lowfi help scrape`.
### Custom Track Lists
> [!WARNING]
>
> Custom track lists are going to be pretty particular.
> This is because I still want to keep `lowfi` as simple as possible,
> so custom lists will be very similar to how the built in list functions.
>
> This also means that there will be no added flexibility to these lists,
> so you'll have to work that out on your own.
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,
or it could also be the name of a file (without the `.txt` extension) in the data
directory, so on Linux it's `~/.local/share/lowfi`.
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
Whereas if you did `lowfi --tracks /home/user/Music/minipop.txt` it would load from that
specified directory.
#### The Format
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 should end in `.mp3` and as such must be in the MP3 format.
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:
```txt
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/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/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3`
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`

View File

@ -1,3 +1,4 @@
https://lofigirl.com/wp-content/uploads/
2023/06/01-gCoope-Odd-Panda-Passing-Time.mp3
2023/06/02-gCoope-Odd-Panda-cxlt.-When-The-Stars-Align.mp3
2023/06/03-gCoope-Odd-Panda-Wind-Rider.mp3

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

4
data/sample.txt Normal file
View File

@ -0,0 +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/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3

View File

@ -25,6 +25,10 @@ struct Args {
#[clap(long, short)]
debug: bool,
/// 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')]
tracks: Option<String>,
/// The command that was ran.
/// This is [None] if no command was specified.
#[command(subcommand)]

View File

@ -11,17 +11,15 @@ use crate::player::Player;
use crate::player::{ui, Messages};
use crate::Args;
/// The attributes that are applied at startup.
/// This includes the volume, but also the config file.
///
/// The volume is seperated from the config since it specifically
/// will be written by lowfi, whereas the config will not.
pub struct InitialProperties {
/// 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.
pub volume: u16,
inner: u16,
}
impl InitialProperties {
impl PersistentVolume {
/// Retrieves the config directory.
async fn config() -> eyre::Result<PathBuf> {
let config = dirs::config_dir()
@ -35,10 +33,14 @@ impl InitialProperties {
Ok(config)
}
/// Loads the [InitialProperties], including the config and volume file.
/// 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.
@ -54,11 +56,11 @@ impl InitialProperties {
100u16
};
Ok(InitialProperties { volume })
Ok(PersistentVolume { inner: volume })
}
/// Saves `volume.txt`, and uses the home directory which was previously acquired.
pub async fn save_volume(volume: f32) -> eyre::Result<()> {
/// 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"));
@ -71,19 +73,20 @@ impl InitialProperties {
/// Initializes the audio server, and then safely stops
/// it when the frontend quits.
pub async fn play(args: Args) -> eyre::Result<()> {
// Load the initial properties (volume & config).
let properties = InitialProperties::load().await?;
// Actually initializes the player.
let player = Arc::new(Player::new(&args).await?);
let (tx, rx) = mpsc::channel(8);
let player = Arc::new(Player::new(!args.alternate, &args).await?);
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(Messages::Init).await?;
Player::play(Arc::clone(&player), properties, tx.clone(), rx).await?;
// Actually starts the player.
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
// Save the volume.txt file for the next session.
InitialProperties::save_volume(player.sink.volume()).await?;
PersistentVolume::save(player.sink.volume()).await?;
player.sink.stop();
ui.abort();

View File

@ -19,8 +19,8 @@ use tokio::{
};
use crate::{
play::InitialProperties,
tracks::{DecodedTrack, Track, TrackInfo},
play::PersistentVolume,
tracks::{self, list::List},
Args,
};
@ -64,7 +64,8 @@ pub enum Messages {
Quit,
}
const TIMEOUT: Duration = Duration::from_secs(8);
/// The time to wait in between errors.
const TIMEOUT: Duration = Duration::from_secs(5);
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
@ -76,16 +77,22 @@ pub struct Player {
/// The [`TrackInfo`] of the current track.
/// This is [`None`] when lowfi is buffering/loading.
pub current: ArcSwapOption<TrackInfo>,
current: ArcSwapOption<tracks::Info>,
/// This is the MPRIS server, which is initialized later on in the
/// user interface.
#[cfg(feature = "mpris")]
pub mpris: tokio::sync::OnceCell<mpris_server::Server<mpris::Player>>,
mpris: tokio::sync::OnceCell<mpris_server::Server<mpris::Player>>,
/// The tracks, which is a [VecDeque] that holds
/// *undecoded* [Track]s.
tracks: RwLock<VecDeque<Track>>,
tracks: RwLock<VecDeque<tracks::Track>>,
/// The actual list of tracks to be played.
list: List,
/// The initial volume level.
volume: PersistentVolume,
/// The web client, which can contain a UserAgent & some
/// settings that help lowfi work more effectively.
@ -132,7 +139,7 @@ impl Player {
}
/// Just a shorthand for setting `current`.
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
async fn set_current(&self, info: tracks::Info) -> eyre::Result<()> {
self.current.store(Some(Arc::new(info)));
Ok(())
@ -150,11 +157,16 @@ impl Player {
/// Initializes the entire player, including audio devices & sink.
///
/// `silent` can control whether alsa's output should be redirected,
/// but this option is only applicable on Linux, as on MacOS & Windows
/// it will never be silent.
pub async fn new(silent: bool, args: &Args) -> eyre::Result<Self> {
let (_stream, handle) = if silent && cfg!(target_os = "linux") && !args.debug {
/// This also will load the track list & persistent volume.
pub async fn new(args: &Args) -> eyre::Result<Self> {
// Load the volume file.
let volume = PersistentVolume::load().await?;
// Load the track list.
let list = List::load(&args.tracks).await?;
// 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 {
OutputStream::try_default()?
@ -177,6 +189,8 @@ impl Player {
.timeout(TIMEOUT)
.build()?,
sink,
volume,
list,
_handle: handle,
_stream,
@ -188,12 +202,12 @@ impl Player {
}
/// This will play the next track, as well as refilling the buffer in the background.
pub async fn next(&self) -> eyre::Result<DecodedTrack> {
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 => Track::random(&self.client).await?,
None => self.list.random(&self.client).await?,
};
let decoded = track.decode()?;
@ -255,7 +269,6 @@ impl Player {
/// skip tracks or pause.
pub async fn play(
player: Arc<Self>,
properties: InitialProperties,
tx: Sender<Messages>,
mut rx: Receiver<Messages>,
) -> eyre::Result<()> {
@ -267,7 +280,7 @@ impl Player {
Downloader::notify(&itx).await?;
// Set the initial sink volume to the one specified.
player.set_volume(properties.volume as f32 / 100.0);
player.set_volume(player.volume.float());
// Whether the last signal was a `NewSong`.
// This is helpful, since we only want to autoplay

View File

@ -7,9 +7,7 @@ use tokio::{
task::{self, JoinHandle},
};
use crate::tracks::Track;
use super::{Player, BUFFER_SIZE};
use super::{Player, BUFFER_SIZE, TIMEOUT};
/// This struct is responsible for downloading tracks in the background.
///
@ -52,11 +50,14 @@ impl Downloader {
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 {
let Ok(track) = Track::random(&self.player.client).await else {
continue;
};
self.player.tracks.write().await.push_back(track);
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;
}
}
}
}
}
}),

View File

@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration};
use crossterm::style::Stylize;
use crate::{player::Player, tracks::TrackInfo};
use crate::{player::Player, tracks::Info};
/// Small helper function to format durations.
pub fn format_duration(duration: &Duration) -> String {
@ -51,8 +51,8 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
/// This represents the main "action" bars state.
enum ActionBar {
Paused(TrackInfo),
Playing(TrackInfo),
Paused(Info),
Playing(Info),
Loading,
}
@ -61,17 +61,17 @@ impl ActionBar {
/// The second value is the character length of the result.
fn format(&self) -> (String, usize) {
let (word, subject) = match self {
Self::Playing(x) => ("playing", Some(x.name.clone())),
Self::Paused(x) => ("paused", Some(x.name.clone())),
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| {
|(subject, len)| {
(
format!("{} {}", word, subject.clone().bold()),
word.len() + 1 + subject.len(),
word.len() + 1 + len,
)
},
)

View File

@ -6,28 +6,11 @@ use std::{io::Cursor, time::Duration};
use bytes::Bytes;
use inflector::Inflector;
use rand::Rng;
use reqwest::Client;
use rodio::{Decoder, Source};
use unicode_width::UnicodeWidthStr;
use url::form_urlencoded;
/// Downloads a raw track, but doesn't decode it.
async fn download(track: &str, client: &Client) -> eyre::Result<Bytes> {
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
let response = client.get(url).send().await?;
let data = response.bytes().await?;
Ok(data)
}
/// Gets a random track from `tracks.txt` and returns it.
fn random() -> &'static str {
let tracks: Vec<&str> = include_str!("../data/tracks.txt")
.split_ascii_whitespace()
.collect();
let random = rand::thread_rng().gen_range(0..tracks.len());
tracks[random]
}
pub mod list;
/// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>;
@ -37,45 +20,55 @@ pub type DecodedData = Decoder<Cursor<Bytes>>;
/// 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, PartialEq, Clone)]
pub struct TrackInfo {
pub struct Info {
/// This is a formatted name, so it doesn't include the full path.
pub name: String,
/// This is the *actual* terminal width of the track name, used to make
/// the UI consistent.
pub width: usize,
/// The duration of the track, this is an [Option] because there are
/// cases where the duration of a track is unknown.
pub duration: Option<Duration>,
}
impl TrackInfo {
impl Info {
/// Decodes a URL string into normal UTF-8.
fn decode_url(text: &str) -> String {
form_urlencoded::parse(text.as_bytes())
.map(|(key, val)| [key, val].concat())
.collect()
}
/// Formats a name with [Inflector].
/// This will also strip the first few numbers that are
/// usually present on most lofi tracks.
fn format_name(name: &'static str) -> String {
let mut formatted = name
.split("/")
.nth(2)
.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 ");
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;
// SAFETY: All of the track names originate with the `'static` lifetime,
// SAFETY: so basically this has already been checked.
for character in unsafe { formatted.as_bytes_mut() } {
for character in formatted.as_bytes() {
if character.is_ascii_digit() {
skip += 1;
} else {
@ -87,30 +80,33 @@ impl TrackInfo {
}
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
pub fn new(name: &'static str, decoded: &DecodedData) -> Self {
pub fn new(name: String, decoded: &DecodedData) -> Self {
let name = Self::format_name(&name);
Self {
duration: decoded.total_duration(),
name: Self::format_name(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: TrackInfo,
pub info: Info,
/// The decoded data, which is able to be played by [rodio].
pub data: DecodedData,
}
impl DecodedTrack {
impl Decoded {
/// Creates a new track.
/// This is equivalent to [Track::decode].
pub fn new(track: Track) -> eyre::Result<Self> {
let data = Decoder::new(Cursor::new(track.data))?;
let info = TrackInfo::new(track.name, &data);
let info = Info::new(track.name, &data);
Ok(Self { info, data })
}
@ -119,7 +115,7 @@ impl DecodedTrack {
/// 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: &'static str,
pub name: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
@ -127,18 +123,10 @@ pub struct Track {
}
impl Track {
/// Fetches and downloads a random track from the tracklist.
pub async fn random(client: &Client) -> eyre::Result<Self> {
let name = random();
let data = download(name, client).await?;
Ok(Self { data, name })
}
/// 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> {
DecodedTrack::new(self)
pub fn decode(self) -> eyre::Result<Decoded> {
Decoded::new(self)
}
}

96
src/tracks/list.rs Normal file
View File

@ -0,0 +1,96 @@
use bytes::Bytes;
use rand::Rng;
use reqwest::Client;
use tokio::fs;
use super::Track;
/// Represents a list of tracks that can be played.
///
/// # Format
///
/// 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 should end
/// in `.mp3` and as such must be in the MP3 format.
///
/// 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.
#[derive(Clone)]
pub struct List {
lines: Vec<String>,
}
impl List {
/// Gets the base URL of the [List].
pub fn base(&self) -> &str {
self.lines[0].trim()
}
/// Gets the name of a random track.
fn random_name(&self) -> String {
// We're getting from 1 here, since due to how rust vectors work it's
// slow to drain only a single element from the start, so we can just keep it in.
let random = rand::thread_rng().gen_range(1..self.lines.len());
self.lines[random].to_owned()
}
/// Downloads a raw track, but doesn't decode it.
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 url = if track.contains("://") {
track.to_owned()
} else {
format!("{}{}", self.base(), track)
};
let response = client.get(url).send().await?;
let data = response.bytes().await?;
Ok(data)
}
/// Fetches and downloads a random track from the [List].
pub async fn random(&self, client: &Client) -> reqwest::Result<Track> {
let name = self.random_name();
let data = self.download(&name, client).await?;
Ok(Track { name, data })
}
/// Parses text into a [List].
pub fn new(text: &str) -> eyre::Result<Self> {
let lines: Vec<String> = text
.split_ascii_whitespace()
.map(|x| x.to_owned())
.collect();
Ok(Self { lines })
}
/// Reads a [List] from the filesystem using the CLI argument provided.
pub async fn load(tracks: &Option<String>) -> eyre::Result<Self> {
if let Some(arg) = tracks {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let name = dirs::data_dir()
.unwrap()
.join("lowfi")
.join(arg)
.join(".txt");
let raw = if name.exists() {
fs::read_to_string(name).await?
} else {
fs::read_to_string(arg).await?
};
List::new(&raw)
} else {
List::new(include_str!("../../data/lofigirl.txt"))
}
}
}