mirror of
https://github.com/talwat/lowfi
synced 2024-12-26 03:01:55 +00:00
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:
parent
b2c225256f
commit
543aeee78c
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
48
README.md
48
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`
|
||||
|
@ -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
16
data/micropop.txt
Normal 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
4
data/sample.txt
Normal 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
|
@ -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)]
|
||||
|
39
src/play.rs
39
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<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();
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
110
src/tracks.rs
110
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<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
96
src/tracks/list.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user