mirror of
https://github.com/talwat/lowfi
synced 2025-01-12 11:21:54 +00:00
feat: initial commit
This commit is contained in:
commit
f0e56ea2aa
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
2537
Cargo.lock
generated
Normal file
2537
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "lowifi"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5.18", features = ["derive", "cargo"] }
|
||||||
|
reqwest = { version = "0.12.7", features = ["blocking"] }
|
||||||
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
|
scraper = "0.20.0"
|
||||||
|
rodio = { version = "0.19.0", features = ["minimp3"], default-features = false }
|
||||||
|
eyre = "0.6.12"
|
||||||
|
futures = "0.3.30"
|
||||||
|
bytes = "1.7.2"
|
||||||
|
rand = "0.8.5"
|
10
README.md
Normal file
10
README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# lowfi
|
||||||
|
|
||||||
|
lowfi is a tiny rust app that serves a single purpose: play lofi.
|
||||||
|
It'll do this as simply as it can: no albums, no ads, just lofi.
|
||||||
|
|
||||||
|
## Why?
|
||||||
|
|
||||||
|
I really hate modern music platforms, and I wanted a small, "suckless"
|
||||||
|
app that would literally just play lofi without video so I could use it
|
||||||
|
whenever.
|
3771
data/tracks.txt
Normal file
3771
data/tracks.txt
Normal file
File diff suppressed because it is too large
Load Diff
30
src/main.rs
Normal file
30
src/main.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
mod scrape;
|
||||||
|
mod tracks;
|
||||||
|
|
||||||
|
/// An extremely simple lofi player.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(about)]
|
||||||
|
struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Scrapes the lofi girl website file server for mp3 files.
|
||||||
|
Scrape,
|
||||||
|
/// Plays a single, random, track.
|
||||||
|
Play
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
let cli = Args::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Scrape => scrape::scrape().await,
|
||||||
|
Commands::Play => tracks::random().await
|
||||||
|
}
|
||||||
|
}
|
34
src/play.rs
Normal file
34
src/play.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
use std::{io::Cursor, time::Duration};
|
||||||
|
|
||||||
|
use rodio::{Decoder, OutputStream, Sink, Source};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
pub async fn download() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play(track: &str) -> eyre::Result<()> {
|
||||||
|
eprintln!("downloading {}...", track);
|
||||||
|
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
|
||||||
|
let file = Cursor::new(reqwest::get(url).await?.bytes().await?);
|
||||||
|
|
||||||
|
let source = Decoder::new(file).unwrap();
|
||||||
|
|
||||||
|
let (stream, stream_handle) = OutputStream::try_default().unwrap();
|
||||||
|
let sink = Sink::try_new(&stream_handle).unwrap();
|
||||||
|
sink.append(source);
|
||||||
|
|
||||||
|
eprintln!("playing {}...", track);
|
||||||
|
sink.sleep_until_end();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn random() -> eyre::Result<()> {
|
||||||
|
let tracks = include_str!("../data/tracks.txt");
|
||||||
|
let tracks: Vec<&str> = tracks.split_ascii_whitespace().collect();
|
||||||
|
|
||||||
|
play(tracks[0]).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
69
src/scrape.rs
Normal file
69
src/scrape.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
static SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
|
||||||
|
Selector::parse("html > body > pre > a").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
||||||
|
let response = reqwest::get(format!("https://lofigirl.com/wp-content/uploads/{}", path)).await?;
|
||||||
|
let document = response.text().await?;
|
||||||
|
|
||||||
|
let html = Html::parse_document(&document);
|
||||||
|
Ok(html.select(&SELECTOR).skip(5).map(|x| String::from(x.attr("href").unwrap())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function basically just scans the entire file server, and returns a list of paths to mp3 files.
|
||||||
|
///
|
||||||
|
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
||||||
|
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||||
|
async fn scan() -> eyre::Result<Vec<String>> {
|
||||||
|
let items = parse("").await?;
|
||||||
|
|
||||||
|
let years: Vec<u32> = items.iter().filter_map(|x| {
|
||||||
|
let year = x.strip_suffix("/")?;
|
||||||
|
year.parse().ok()
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// A little bit of async to run all of the months concurrently.
|
||||||
|
let mut futures = FuturesUnordered::new();
|
||||||
|
|
||||||
|
for year in years {
|
||||||
|
let months = parse(&year.to_string()).await?;
|
||||||
|
|
||||||
|
for month in months {
|
||||||
|
futures.push(async move {
|
||||||
|
let path = format!("{}/{}", year, month);
|
||||||
|
|
||||||
|
let items = parse(&path).await.unwrap();
|
||||||
|
let items = items.into_iter().filter_map(|x| {
|
||||||
|
if x.ends_with(".mp3") {
|
||||||
|
Some(format!("{path}{x}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}).collect::<Vec<String>>();
|
||||||
|
|
||||||
|
items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
while let Some(mut result) = futures.next().await {
|
||||||
|
files.append(&mut result);
|
||||||
|
}
|
||||||
|
|
||||||
|
eyre::Result::Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scrape() -> eyre::Result<()> {
|
||||||
|
let files = scan().await?;
|
||||||
|
for file in files {
|
||||||
|
println!("{}", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
39
src/tracks.rs
Normal file
39
src/tracks.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use rand::Rng;
|
||||||
|
use rodio::{Decoder, OutputStream, Sink};
|
||||||
|
|
||||||
|
pub async fn download(track: &str) -> eyre::Result<Decoder<Cursor<Bytes>>> {
|
||||||
|
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
|
||||||
|
let file = Cursor::new(reqwest::get(url).await?.bytes().await?);
|
||||||
|
let source = Decoder::new(file).unwrap();
|
||||||
|
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play(source: Decoder<Cursor<Bytes>>) -> eyre::Result<()> {
|
||||||
|
let (stream, stream_handle) = OutputStream::try_default()?;
|
||||||
|
let sink = Sink::try_new(&stream_handle)?;
|
||||||
|
sink.append(source);
|
||||||
|
|
||||||
|
sink.sleep_until_end();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn random() -> eyre::Result<()> {
|
||||||
|
let tracks = include_str!("../data/tracks.txt");
|
||||||
|
let tracks: Vec<&str> = tracks.split_ascii_whitespace().collect();
|
||||||
|
|
||||||
|
let random = rand::thread_rng().gen_range(0..tracks.len());
|
||||||
|
let track = tracks[random];
|
||||||
|
|
||||||
|
eprintln!("downloading {}...", track);
|
||||||
|
let source = download(track).await?;
|
||||||
|
|
||||||
|
eprintln!("playing {}...", track);
|
||||||
|
play(source).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user