mirror of
				https://github.com/talwat/lowfi
				synced 2025-10-31 19:28:55 +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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user