diff --git a/Cargo.lock b/Cargo.lock index 81c5dd3..35b05d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 58b1e68..d9b5be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 7732232..f07523a 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/data/tracks.txt b/data/lofigirl.txt similarity index 99% rename from data/tracks.txt rename to data/lofigirl.txt index 5a20d1b..5747911 100644 --- a/data/tracks.txt +++ b/data/lofigirl.txt @@ -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 diff --git a/data/micropop.txt b/data/micropop.txt new file mode 100644 index 0000000..a5aaf6c --- /dev/null +++ b/data/micropop.txt @@ -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 \ No newline at end of file diff --git a/data/sample.txt b/data/sample.txt new file mode 100644 index 0000000..f34ad67 --- /dev/null +++ b/data/sample.txt @@ -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 \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index f012407..20f16a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + /// The command that was ran. /// This is [None] if no command was specified. #[command(subcommand)] diff --git a/src/play.rs b/src/play.rs index 044c3fc..85835d7 100644 --- a/src/play.rs +++ b/src/play.rs @@ -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 { 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 { 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(); diff --git a/src/player.rs b/src/player.rs index 3bee653..b5dd157 100644 --- a/src/player.rs +++ b/src/player.rs @@ -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, + current: ArcSwapOption, /// This is the MPRIS server, which is initialized later on in the /// user interface. #[cfg(feature = "mpris")] - pub mpris: tokio::sync::OnceCell>, + mpris: tokio::sync::OnceCell>, /// The tracks, which is a [VecDeque] that holds /// *undecoded* [Track]s. - tracks: RwLock>, + tracks: RwLock>, + + /// 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 { - 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 { + // 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 { + pub async fn next(&self) -> eyre::Result { 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, - properties: InitialProperties, tx: Sender, mut rx: Receiver, ) -> 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 diff --git a/src/player/downloader.rs b/src/player/downloader.rs index 33ba97c..e276e5a 100644 --- a/src/player/downloader.rs +++ b/src/player/downloader.rs @@ -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; + } + } + } } } }), diff --git a/src/player/ui/components.rs b/src/player/ui/components.rs index 736ea8a..8b33e10 100644 --- a/src/player/ui/components.rs +++ b/src/player/ui/components.rs @@ -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, ) }, ) diff --git a/src/tracks.rs b/src/tracks.rs index 21e7112..030b8bf 100644 --- a/src/tracks.rs +++ b/src/tracks.rs @@ -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 { - 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>; @@ -37,45 +20,55 @@ pub type DecodedData = Decoder>; /// 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, } -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 { 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 { - 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::new(self) + pub fn decode(self) -> eyre::Result { + Decoded::new(self) } } diff --git a/src/tracks/list.rs b/src/tracks/list.rs new file mode 100644 index 0000000..1f19aa4 --- /dev/null +++ b/src/tracks/list.rs @@ -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, +} + +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 { + // 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 { + 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 { + let lines: Vec = 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) -> eyre::Result { + 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")) + } + } +}