Compare commits

..

10 Commits

Author SHA1 Message Date
266b580df7
rename genres to playlists 2024-09-14 00:31:55 +03:00
7eca925a8e
moved files to root 2024-09-14 00:23:48 +03:00
b977e9cea5
Added gui 2024-04-22 19:14:48 +03:00
9fef257bfc
manifest refractor 2024-04-19 01:21:03 +03:00
3cad0b0651
refractor 2024-04-19 00:19:11 +03:00
b05a20d724
removed platform dependant config file 2024-04-17 15:24:42 +03:00
0ea98848e5 asd 2024-04-17 15:21:53 +03:00
e05b00609c
new music 2024-04-15 21:50:51 +03:00
e377c0bd77
song updates 2024-04-15 20:58:38 +03:00
057816bd65
BUGBUG: Fixed config not saving (i forgor) 2024-04-15 17:53:14 +03:00
27 changed files with 6361 additions and 2959 deletions

5
.gitignore vendored
View File

@ -1,3 +1,4 @@
/out
/music_mgr/target
/.venv
/target
/config.json
/manifest.json

4619
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
[package]
name = "music_mgr"
name = "mcmg"
version = "0.1.0"
edition = "2021"
@ -10,6 +10,8 @@ anstyle = "1.0.6"
anyhow = "1.0.81"
camino = "1.1.6"
clap = { version = "4.5.4", features = ["derive"] }
eframe = "0.27.2"
egui = "0.27.2"
env_logger = "0.11.3"
lazy_static = "1.4.0"
libc = "0.2.153"
@ -17,6 +19,8 @@ log = "0.4.21"
reqwest = "0.12.3"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.115"
# serde_traitobject = "0.2.8"
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread", "process", "sync"] }
url = "2.5.0"
windows = { version = "0.56.0", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
zip-extensions = "0.6.2"

4
manifest.default.json Normal file
View File

@ -0,0 +1,4 @@
{
"format": "m4a",
"genres": {}
}

File diff suppressed because it is too large Load Diff

1814
music_mgr/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
use crate::{config::ConfigWrapper, downloader::Downloader, manifest::{Manifest, ManifestSong}};
pub async fn add(cfg: &ConfigWrapper, manifest: &mut Manifest, downloader: &mut Downloader, url: &Option<String>, name: &Option<String>, genre: &Option<String>) -> anyhow::Result<()> {
let genres = manifest.genres.keys().map(|f| f.clone()).collect::<Vec<String>>();
let genre = genre.clone().unwrap_or(
crate::prompt::prompt_with_list_or_str("Enter song genre", &genres)
);
log::debug!("Genre: {genre}");
let url = url.clone().unwrap_or(
crate::prompt::simple_prompt("Enter song youtube url, make sure its not a playlist, (yt only for now)")
);
let name = name.clone().unwrap_or(
crate::prompt::simple_prompt("Enter song name with like this: {Author} - {Song name}")
);
manifest.add_song(genre.clone(), name.clone(), url.clone())?;
manifest.save()?;
let should_download = crate::prompt::prompt_bool("Download song now?", Some(true));
if should_download {
let song = &ManifestSong {
name,
url,
};
downloader.download_song(cfg, song, &genre, &manifest.format()?).await?;
}
Ok(())
}

View File

@ -1,27 +0,0 @@
mod add;
use crate::{config::{cli::CliCommand, ConfigWrapper}, downloader::Downloader, manifest::Manifest};
pub async fn command_run(cfg: &ConfigWrapper, manifest: &mut Manifest) -> anyhow::Result<()> {
let mut downloader = Downloader::new(cfg.cfg.ytdlp.path.clone());
match &cfg.cli.command {
None | Some(CliCommand::Download) => {
if let Ok(count) = downloader.download_all(manifest, &cfg).await {
log::info!("Downloaded {count} songs");
} else {
log::error!("Failed to download songs");
return Ok(());
}
},
Some(c) => {
match c {
CliCommand::Download => unreachable!(),
CliCommand::Add { url, name, genre } => add::add(cfg, manifest, &mut downloader, url, name, genre).await?,
}
}
}
Ok(())
}

View File

@ -1,132 +0,0 @@
pub mod cli;
use std::path::PathBuf;
use clap::Parser;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use crate::util::{self, dl_to_file, isatty};
use self::cli::CliArgs;
const YTDLP_DL_URL: &'static str = "https://github.com/yt-dlp/yt-dlp/archive/refs/heads/master.zip";
const SPOTDL_DL_URL: &'static str = "https://github.com/spotDL/spotify-downloader/archive/refs/heads/master.zip";
#[derive(Debug, Default)]
pub struct ConfigWrapper {
pub cfg: Config,
pub cli: cli::CliArgs,
pub isatty: bool
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub ytdlp: ConfigYtdlp,
pub spotdl: ConfigSpotdl,
pub python: ConfigPython,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigYtdlp {
pub path: PathBuf,
pub is_python: bool,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigSpotdl {
pub path: PathBuf,
pub is_python: bool
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigPython {
pub path: PathBuf,
}
impl ConfigWrapper {
pub async fn parse() -> Result<Self> {
let mut s = Self::default();
s.cli = cli::CliArgs::parse();
crate::logger::init_logger(s.cli.debug);
s.cfg = Config::parse(&s.cli).await?;
s.isatty = isatty();
Ok(s)
}
}
impl Config {
pub async fn parse(cli: &CliArgs) -> Result<Self> {
if !cli.config.exists() {
return Self::setup_config(&cli).await;
}
let data = std::fs::read_to_string(&cli.config)?;
let data: Self = serde_json::from_str(&data)?;
Ok(data)
}
async fn setup_config(cli: &CliArgs) -> Result<Self> {
let mut s = Self::default();
let bin_dir = cli.output.clone().into_std_path_buf().join(".bin/");
let mut python_needed = false;
match util::is_program_in_path("yt-dlp") {
Some(p) => {
s.ytdlp.path = p;
s.ytdlp.is_python = false;
},
None => {
python_needed = true;
s.ytdlp.is_python = true;
s.ytdlp.path = bin_dir.join("ytdlp");
dl_to_file(YTDLP_DL_URL, s.ytdlp.path.join("ytdlp.zip")).await?;
zip_extensions::zip_extract(&s.ytdlp.path.join("ytdlp.zip"), &s.ytdlp.path)?;
}
}
match util::is_program_in_path("spotdl") {
Some(p) => {
s.spotdl.path = p;
s.spotdl.is_python = false;
},
None => {
python_needed = true;
s.spotdl.is_python = true;
s.spotdl.path = bin_dir.join("ytdlp");
dl_to_file(SPOTDL_DL_URL, s.spotdl.path.join("spotdl.zip")).await?;
zip_extensions::zip_extract(&s.spotdl.path.join("spotdl.zip"), &s.ytdlp.path)?;
}
}
let python_paths = &[
util::is_program_in_path("python"),
util::is_program_in_path("python3")
];
if python_needed {
let mut found = false;
for p in python_paths {
match p {
Some(p) => {
s.python.path = p.clone();
found = true;
break
}
None => {
}
}
}
if !found {
panic!("Python needs to be installed for this to work, or install ytdlp and spotdl manually, (dont forget to delete the config file after doing so)");
}
}
Ok(s)
}
}

View File

@ -1,117 +0,0 @@
use std::{collections::HashMap, path::PathBuf, process::Stdio};
use lazy_static::lazy_static;
use log::Level;
use tokio::sync::{Mutex, RwLock};
use crate::{config::ConfigWrapper, manifest::{Manifest, ManifestSong}};
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct Proc {
url: String,
path: String,
finished: bool
}
lazy_static!(
static ref PROCESSES: Mutex<RwLock<HashMap<usize, Proc>>> = Mutex::new(RwLock::new(HashMap::new()));
);
pub struct Downloader {
count: usize,
ytdlp_path: PathBuf,
id_itr: usize,
}
impl Downloader {
pub fn new(ytdlp_path: PathBuf) -> Self {
Self {
count: 0,
ytdlp_path,
id_itr: 0,
}
}
pub async fn download_all(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<usize> {
let format = manifest.format()?;
for (genre, songs) in &manifest.genres {
for song in songs {
self.download_song(cfg, &song, &genre, &format).await?;
self.wait_for_procs(10).await?;
}
}
self.wait_for_procs(0).await?;
Ok(self.count)
}
pub async fn download_song(&mut self, cfg: &ConfigWrapper, song: &ManifestSong, genre: &String, format: &String) -> anyhow::Result<()> {
let path = format!("{}/{genre}/{}.{}", cfg.cli.output, song.name, &format);
if PathBuf::from(&path).exists() {
log::debug!("File {path} exists, skipping");
return Ok(())
}
let mut cmd = tokio::process::Command::new(&self.ytdlp_path);
let cmd = cmd.args([
"-x",
"--audio-format",
format.as_str(),
"-o",
path.as_str(),
song.url.as_str()
]);
let cmd = if log::max_level() < Level::Debug {
cmd.stdout(Stdio::null()).stderr(Stdio::null())
} else {cmd};
let mut proc = cmd.spawn()?;
let id = self.id_itr;
tokio::spawn(async move {
let id = id;
proc.wait().await
.expect("child process encountered an error");
PROCESSES.lock().await.write().await.get_mut(&id).unwrap().finished = true;
});
log::info!("Downloading {path}");
PROCESSES.lock().await.write().await.insert(id, Proc {
url: song.url.clone(),
path,
finished: false,
});
self.id_itr += 1;
Ok(())
}
async fn wait_for_procs(&mut self, until: usize) -> anyhow::Result<()> {
// NOTE: This looks really fucked because i dont want to deadlock the processes so i lock PROCESSES for as little as possible
// NOTE: So its also kinda really slow
loop {
{
if PROCESSES.lock().await.read().await.len() <= until {
return Ok(());
}
}
let procs = {
PROCESSES.lock().await.read().await.clone()
};
for (idx, proc) in procs {
if proc.finished {
{
PROCESSES.lock().await.write().await.remove(&idx);
}
log::info!("Finished downloading {}", proc.path);
self.count += 1;
}
}
}
#[allow(unreachable_code)] //? rust_analizer not smart enough for this
Ok(())
}
}

View File

@ -1,71 +0,0 @@
use std::{collections::HashMap, fs::read_to_string, path::{Path, PathBuf}};
use anyhow::bail;
use serde::{Deserialize, Serialize};
const ALLOWED_FORMATS: &[&'static str] = &["m4a", "aac", "flac", "mp3", "vaw"];
type Genre = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(skip)]
path: PathBuf,
format: String,
pub genres: HashMap<Genre, Vec<ManifestSong>>
}
impl Manifest {
pub fn format(&self) -> anyhow::Result<String> {
if !ALLOWED_FORMATS.contains(&self.format.as_str()) {
log::error!("Unknown format, allowed formats: {}", ALLOWED_FORMATS.join(", "));
bail!("")
}
Ok(self.format.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSong {
pub name: String,
pub url: String
}
impl Manifest {
fn from_string(s: String) -> anyhow::Result<Self> {
let s = serde_json::from_str(&s)?;
Ok(s)
}
pub fn from_path(p: &Path) -> anyhow::Result<Self> {
let data = read_to_string(p)?;
let mut s = Self::from_string(data)?;
s.path = p.to_path_buf();
Ok(s)
}
pub fn add_song(&mut self, genre: String, name: String, url: String) -> anyhow::Result<()> {
let Some(genre_ref) = self.genres.get_mut(&genre) else {
log::error!("Invalid genre '{}'", genre);
bail!("Invalid genre")
};
genre_ref.push(ManifestSong {
name,
url,
});
Ok(())
}
pub fn save(&self) -> anyhow::Result<()> {
let data = serde_json::to_string_pretty(self)?;
std::fs::write(&self.path, data)?;
Ok(())
}
}

50
src/commands/add.rs Normal file
View File

@ -0,0 +1,50 @@
use std::str::FromStr;
use crate::{config::ConfigWrapper, downloader::Downloader, manifest::{song::Song, Manifest}, util::is_supported_host};
pub async fn add(cfg: &ConfigWrapper, manifest: &mut Manifest, downloader: &mut Downloader, url: &Option<String>, name: &Option<String>, genre: &Option<String>) -> anyhow::Result<()> {
log::debug!("Genre: {genre:?}");
log::debug!("url: {url:?}");
log::debug!("name: {name:?}");
let mut genres = manifest.get_playlists().keys().map(|f| f.clone()).collect::<Vec<String>>();
genres.sort();
let genre = genre.clone().unwrap_or_else( || {
let g = crate::prompt::prompt_with_list_or_str("Enter song genre", &genres);
log::info!("Genre: {g}");
g
});
let url = url.clone().unwrap_or_else( ||
crate::prompt::simple_prompt("Enter song youtube url, make sure its not a playlist, (yt only for now)")
);
if !is_supported_host(url::Url::from_str(&url)?) {
log::error!("Invalid or unsupported host name");
return Ok(());
}
let name = name.clone().unwrap_or_else( ||
crate::prompt::simple_prompt("Enter song name with like this: {Author} - {Song name}")
);
let song = Song::from_url_str(url)?;
manifest.add_song(genre.clone(), name.clone(), song.clone());
manifest.save(None)?;
let should_download = crate::prompt::prompt_bool("Download song now?", Some(false));
if should_download {
downloader.download_song(cfg, &name, &song, &genre, manifest.get_format()).await?;
crate::process_manager::wait_for_procs_untill(0).await?;
}
Ok(())
}

105
src/commands/gui/mod.rs Normal file
View File

@ -0,0 +1,105 @@
mod nav_bar;
mod song_edit_window;
use egui::{Color32, Label, Sense};
use crate::manifest::Manifest;
use self::song_edit_window::GuiSongEditor;
pub struct Gui {
manifest: Manifest,
song_editor: GuiSongEditor
}
impl Gui {
fn new(_: &eframe::CreationContext<'_>, manifest: Manifest) -> Self {
Self {
manifest,
song_editor: GuiSongEditor {
is_open: false,
song: Default::default(),
ed_url: String::new(),
ed_name: String::new(),
},
}
}
pub fn start(manifest: Manifest) -> anyhow::Result<()> {
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 300.0])
.with_min_inner_size([300.0, 220.0]),
// .with_icon(
// // NOTE: Adding an icon is optional
// eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
// .expect("Failed to load icon"),
// ),
..Default::default()
};
if let Err(e) = eframe::run_native(
"eframe template",
native_options,
Box::new(|cc| Box::new(Gui::new(cc, manifest))),
) {
log::error!("Failed to create window: {e}");
};
Ok(())
}
}
impl eframe::App for Gui {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.draw_nav(ctx, frame);
self.draw_song_edit_window(ctx, frame);
egui::CentralPanel::default().show(ctx, |ui| {
// The central panel the region left after adding TopPanel's and SidePanel's
ui.heading(format!("Songs ({})", self.manifest.get_song_count()));
egui::ScrollArea::vertical()
.max_width(f32::INFINITY)
.auto_shrink(false)
.show(ui, |ui| {
for (genre, songs) in self.manifest.get_playlists() {
for (song_name, song) in songs {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("[");
ui.hyperlink_to("link", song.get_url().unwrap());
ui.label("] ");
ui.colored_label(Color32::LIGHT_BLUE, genre);
ui.label(": ");
if ui.add(Label::new(song_name).sense(Sense::click())).clicked() {
self.song_editor.song = (
genre.clone(),
song_name.clone(),
);
log::debug!("Label pressed");
self.song_editor.is_open = true;
self.song_editor.ed_name = song_name.clone();
self.song_editor.ed_url = song.get_url_str().clone();
}
});
// ui.label(RichText::new(""))
}
}
});
ui.separator();
ui.add(egui::github_link_file!(
"https://github.com/emilk/eframe_template/blob/main/",
"Source code."
));
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
egui::warn_if_debug_build(ui);
});
});
}
}

View File

@ -0,0 +1,27 @@
use super::Gui;
impl Gui {
pub fn draw_nav(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui.button("Save").clicked() {
if let Err(e) = self.manifest.save(None) {
log::error!("Failed to save manifest: {e}");
}
}
});
ui.add_space(16.0);
ui.with_layout(egui::Layout::bottom_up(egui::Align::RIGHT), |ui| {
egui::widgets::global_dark_light_mode_buttons(ui);
});
});
});
}
}

View File

@ -0,0 +1,79 @@
use egui::Color32;
use crate::manifest::{GenreName, SongName};
use super::Gui;
pub struct GuiSongEditor {
pub is_open: bool,
pub song: (GenreName, SongName),
pub ed_url: String,
pub ed_name: String,
}
impl Gui {
pub fn draw_song_edit_window(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
let mut save = false;
let (genre, song_name) = self.song_editor.song.clone();
let Some(song) = self.manifest.get_song(genre.clone(), &song_name) else {
return;
};
let song = song.clone();
egui::Window::new("Song editor")
.open(&mut self.song_editor.is_open)
.show(ctx,
|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("[");
ui.hyperlink_to("link", song.get_url().unwrap());
ui.label("] ");
ui.colored_label(Color32::LIGHT_BLUE, &genre);
ui.label(": ");
ui.label(&song_name)
});
ui.horizontal(|ui| {
ui.label("Type: ");
ui.label(&song.get_type().to_string());
});
ui.horizontal(|ui| {
ui.label("Name: ");
ui.text_edit_singleline(&mut self.song_editor.ed_name);
});
ui.horizontal(|ui| {
ui.label("Url: ");
ui.text_edit_singleline(&mut self.song_editor.ed_url);
});
if ui.button("Save").clicked() {
save = true;
}
});
if save {
{
let Some(song) = self.manifest.get_song_mut(genre.clone(), &song_name) else {
return;
};
*song.get_url_str_mut() = self.song_editor.ed_url.clone();
}
let Some(genre) = self.manifest.get_playlist_mut(genre.clone()) else {
return;
};
genre.remove(&song_name);
genre.insert(self.song_editor.ed_name.clone(), song);
let _ = self.manifest.save(None);
}
}
}

42
src/commands/mod.rs Normal file
View File

@ -0,0 +1,42 @@
mod add;
pub mod gui;
use crate::{config::{cli::CliCommand, ConfigWrapper}, downloader::Downloader, manifest::Manifest};
pub async fn command_run(cfg: &ConfigWrapper, manifest: &mut Manifest) -> anyhow::Result<()> {
log::info!("Is in term: {}", cfg.isatty);
//std::fs::write("./isatty", format!("{}\n", cfg.isatty))?;
let mut downloader = Downloader::new();
match (&cfg.cli.command, cfg.isatty) {
(None | Some(CliCommand::Download), true) => {
match downloader.download_all(manifest, &cfg).await {
Ok(count) => log::info!("Downloaded {count} songs"),
Err(e) => {
log::error!("Failed to download songs: {e}");
return Ok(());
}
}
},
(Some(c), _) => {
match c {
CliCommand::Download => unreachable!(),
CliCommand::Add { url, name, genre } => {
if let Err(e) = add::add(cfg, manifest, &mut downloader, url, name, genre).await {
log::error!("Failed to run 'add' command: {e}");
}
}
CliCommand::Gui => {
gui::Gui::start(manifest.clone())?;
},
}
}
(None, false) => {
gui::Gui::start(manifest.clone())?;
},
}
Ok(())
}

View File

@ -1,7 +1,6 @@
use camino::Utf8PathBuf;
use clap::{Parser, Subcommand};
use crate::util::isatty;
#[derive(Debug, Parser, Default)]
pub struct CliArgs {
@ -26,13 +25,16 @@ pub struct CliArgs {
}
#[derive(Debug, Subcommand, Default)]
#[derive(Debug, Subcommand)]
pub enum CliCommand {
#[default]
Download,
Add {
#[arg(long, short)]
url: Option<String>,
#[arg(long, short)]
name: Option<String>,
#[arg(long, short)]
genre: Option<String>
}
},
Gui
}

133
src/config/mod.rs Normal file
View File

@ -0,0 +1,133 @@
pub mod cli;
use std::path::PathBuf;
use clap::Parser;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use crate::util::{self, isatty};
use self::cli::CliArgs;
// const YTDLP_DL_URL: &'static str = "https://github.com/yt-dlp/yt-dlp/archive/refs/heads/master.zip";
// const SPOTDL_DL_URL: &'static str = "https://github.com/spotDL/spotify-downloader/archive/refs/heads/master.zip";
#[derive(Debug, Default)]
pub struct ConfigWrapper {
pub cfg: Config,
pub cli: cli::CliArgs,
pub isatty: bool
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub ytdlp: ConfigYtdlp,
pub spotdl: ConfigSpotdl,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigYtdlp {
pub path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigSpotdl {
pub path: PathBuf,
}
impl ConfigWrapper {
pub async fn parse() -> Result<Self> {
let mut s = Self::default();
s.cli = cli::CliArgs::parse();
crate::logger::init_logger(s.cli.debug);
s.cfg = Config::parse(&s.cli).await?;
s.isatty = isatty();
Ok(s)
}
}
impl Config {
pub async fn parse(cli: &CliArgs) -> Result<Self> {
if !cli.config.exists() {
log::info!("Config doesnt exist");
return Self::setup_config(&cli).await;
}
let data = std::fs::read_to_string(&cli.config)?;
let data: Self = serde_json::from_str(&data)?;
Ok(data)
}
async fn setup_config(cli: &CliArgs) -> Result<Self> {
let mut s = Self::default();
let mut error = false;
match util::is_program_in_path("yt-dlp") {
Some(p) => {
s.ytdlp.path = p;
},
None => {
error = true;
log::error!("could not find yt-dlp, please install it.");
log::info!(" - With winget (Windows only) (recommended):");
log::info!(" - Most new windows versions have winget installed, if not, instructions here: https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget");
log::info!(" - run `winget install yt-dlp`");
log::info!(" - With chocolatey (Windows only):");
log::info!(" - Make sure you have chocolatey installed - https://chocolatey.org/install");
log::info!(" - run `choco install yt-dlp` as Admin");
log::info!(" - With pip (from python) (Cross platform)");
log::info!(" - Make sure you have python installed");
log::info!(" - pip install yt-dlp");
log::info!(" - Using your distro's package manager (Unix/BSD only) (Not recommended)")
}
}
match util::is_program_in_path("spotdl") {
Some(p) => {
s.spotdl.path = p;
},
None => {
let res = crate::prompt::prompt_bool("Spotdl is not installed but if you dont need to download music from spotify you dont need it, skip it?", None);
if res {
s.spotdl.path = PathBuf::from("UNUSED");
} else {
error = true;
log::error!("could not find spotdl, please install it. ");
log::info!(" - With pip (from python) (Cross platform) (recommended)");
log::info!(" - Make sure you have python installed - https://www.python.org/downloads/");
log::info!(" - pip install spotdl");
}
}
}
match util::is_program_in_path("ffmpeg") {
Some(_) => (),
None => {
error = true;
log::error!("could not find ffmpeg, please install it.");
log::info!(" - With winget (Windows only) (recommended):");
log::info!(" - Most new windows versions have winget installed, if not, instructions here: https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget");
log::info!(" - run `winget install --id=Gyan.FFmpeg -e`");
log::info!(" - With chocolatey (Windows only):");
log::info!(" - Make sure you have chocolatey installed - https://chocolatey.org/install");
log::info!(" - run `choco install ffmpeg` as Admin");
}
}
if !error {
s.save(cli.config.clone().into_std_path_buf())?;
}
Ok(s)
}
fn save(&self, path: PathBuf) -> anyhow::Result<()> {
let data = serde_json::to_string_pretty(self)?;
std::fs::write(path, data)?;
Ok(())
}
}

95
src/downloader.rs Normal file
View File

@ -0,0 +1,95 @@
use std::{collections::HashMap, path::PathBuf, process::Stdio};
use lazy_static::lazy_static;
use log::Level;
use tokio::sync::{Mutex, RwLock};
use crate::{config::ConfigWrapper, manifest::{song::{Song, SongType}, Format, Manifest}};
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct Proc {
url: String,
path: String,
finished: bool
}
lazy_static!(
static ref PROCESSES: Mutex<RwLock<HashMap<usize, Proc>>> = Mutex::new(RwLock::new(HashMap::new()));
);
pub struct Downloader {
count: usize,
}
impl Downloader {
pub fn new() -> Self {
Self {
count: 0,
}
}
pub async fn download_all(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<usize> {
let format = manifest.get_format();
for (genre, songs) in manifest.get_playlists() {
for (song_name, song) in songs {
self.download_song(cfg, song_name, song, &genre, format).await?;
self.count += crate::process_manager::wait_for_procs_untill(10).await?;
}
}
self.count += crate::process_manager::wait_for_procs_untill(0).await?;
Ok(self.count)
}
pub async fn download_song(&mut self, cfg: &ConfigWrapper, name: &String, song: &Song, genre: &String, format: &Format) -> anyhow::Result<()> {
let dl_dir = format!("{}/{genre}", cfg.cli.output);
let dl_file = format!("{dl_dir}/{}.{}", name, &format);
if PathBuf::from(&dl_file).exists() {
log::debug!("File {dl_file} exists, skipping");
return Ok(())
}
log::debug!("File {dl_file} doesnt exist, downloading");
let mut cmd = match song.get_type() {
&SongType::Youtube => {
log::debug!("Song {} is from yotube", song.get_url_str());
let mut cmd = tokio::process::Command::new(&cfg.cfg.ytdlp.path);
cmd.args([
"-x",
"--audio-format",
&format.to_string(),
"-o",
dl_file.as_str(),
song.get_url_str().as_str()
]);
cmd
}
SongType::Spotify => {
let mut cmd = tokio::process::Command::new(&cfg.cfg.spotdl.path);
cmd.args([
"--format",
&format.to_string(),
"--output",
dl_dir.as_str(),
song.get_url_str().as_str()
]);
cmd
}
url => {
log::error!("Unknown or unsupported hostname '{:?}'", url);
return Ok(());
}
};
if log::max_level() < Level::Debug {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
};
crate::process_manager::add_proc(cmd, format!("Downloaded {dl_file}")).await?;
Ok(())
}
}

View File

@ -10,6 +10,7 @@ mod commands;
mod prompt;
mod config;
mod constants;
mod process_manager;
#[tokio::main]
async fn main() {
@ -17,7 +18,7 @@ async fn main() {
return;
};
let mut manifest = match manifest::Manifest::from_path(&cfg.cli.manifest.as_std_path()) {
let mut manifest = match manifest::Manifest::load_new(&cfg.cli.manifest.clone().into_std_path_buf()) {
Ok(m) => m,
Err(e) => {
log::error!("Failed to parse manifest file {}: {e}", cfg.cli.manifest);

135
src/manifest/mod.rs Normal file
View File

@ -0,0 +1,135 @@
// pub mod v1;
pub mod song;
use song::Song;
use std::{collections::HashMap, fmt::{Debug, Display}, path::PathBuf};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
const DEFAULT_MANIFEST: &'static str = include_str!("../../manifest.default.json");
pub type GenreName = String;
pub type SongName = String;
pub type Genre = HashMap<SongName, song::Song>;
#[allow(non_camel_case_types)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub enum Format {
#[default]
m4a,
aac,
flac,
mp3,
vaw,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Manifest {
#[serde(skip)]
path: PathBuf,
format: Format,
playlists: HashMap<GenreName, Genre>
}
#[allow(dead_code)]
impl Manifest {
pub fn get_format(&self) -> &Format {
&self.format
}
pub fn add_song(&mut self, genre: GenreName, name: SongName, song: Song) -> Option<Song> {
self.get_playlist_mut(genre)?.insert(name, song)
}
pub fn get_song(&self, genre: GenreName, name: &SongName) -> Option<&Song> {
self.get_playlist(genre)?.get(name)
}
pub fn get_song_mut(&mut self, genre: GenreName, name: &SongName) -> Option<&mut Song> {
self.get_playlist_mut(genre)?.get_mut(name)
}
pub fn add_playlist(&mut self, name: GenreName) {
self.playlists.insert(name, Default::default());
}
pub fn get_playlist(&self, name: GenreName) -> Option<&Genre> {
self.playlists.get(&name)
}
pub fn get_playlist_mut(&mut self, name: GenreName) -> Option<&mut Genre> {
self.playlists.get_mut(&name)
}
pub fn get_playlists(&self) -> &HashMap<GenreName, Genre> {
&self.playlists
}
pub fn get_playlists_mut(&mut self) -> &mut HashMap<GenreName, Genre> {
&mut self.playlists
}
pub fn get_song_count(&self) -> usize {
let mut count = 0;
for (_, v) in &self.playlists {
count += v.len();
}
count
}
pub fn load(&mut self, p: Option<&PathBuf>) -> Result<()> {
let path = p.unwrap_or(&self.path);
log::debug!("Path: {path:?}");
let data = std::fs::read_to_string(path)?;
let s: Self = serde_json::from_str(data.as_str())?;
self.playlists = s.playlists;
self.format = s.format;
Ok(())
}
pub fn save(&self, p: Option<&PathBuf>) -> Result<()> {
let path = p.unwrap_or(&self.path);
log::debug!("Path: {path:?}");
let data = serde_json::to_string_pretty(self)?;
std::fs::write(path, data)?;
Ok(())
}
pub fn load_new(p: &PathBuf) -> Result<Self> {
if !p.exists() {
std::fs::write(p, DEFAULT_MANIFEST)?;
}
let mut s = Self::default();
log::debug!("Path: {p:?}");
s.path = p.clone();
s.load(Some(p))?;
Ok(s)
}
}
impl TryFrom<String> for Format {
type Error = anyhow::Error;
fn try_from(value: String) -> std::prelude::v1::Result<Self, Self::Error> {
match value.as_str() {
"m4a" => Ok(Self::m4a),
"aac" => Ok(Self::aac),
"flac" => Ok(Self::flac),
"mp3" => Ok(Self::mp3),
"vaw" => Ok(Self::vaw),
v => bail!("Unknown format {v}")
}
}
}
impl Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Format::m4a => write!(f, "m4a")?,
Format::aac => write!(f, "aac")?,
Format::flac => write!(f, "flac")?,
Format::mp3 => write!(f, "mp3")?,
Format::vaw => write!(f, "vaw")?,
}
Ok(())
}
}

77
src/manifest/song.rs Normal file
View File

@ -0,0 +1,77 @@
use std::str::FromStr;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum SongType {
Youtube,
Spotify,
Soundcloud,
}
impl ToString for SongType {
fn to_string(&self) -> String {
let s = match self {
SongType::Youtube => "Youtube",
SongType::Spotify => "Spotify",
SongType::Soundcloud => "Soundcloud",
};
String::from(s)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Song {
url: String,
typ: SongType
}
#[allow(dead_code)]
impl Song {
pub fn from_url_str(url: String) -> Result<Self> {
Self::from_url(url::Url::from_str(url.as_str())?)
}
pub fn from_url(url: url::Url) -> Result<Self> {
Ok(Self {
url: url.to_string(),
typ: url.try_into()?,
})
}
pub fn get_url(&self) -> Result<url::Url> {
Ok(url::Url::from_str(&self.url)?)
}
pub fn get_url_str(&self) -> &String {
&self.url
}
pub fn get_url_str_mut(&mut self) -> &mut String {
&mut self.url
}
pub fn get_type(&self) -> &SongType {
&self.typ
}
pub fn get_type_mut(&mut self) -> &mut SongType {
&mut self.typ
}
}
impl TryFrom<url::Url> for SongType {
type Error = anyhow::Error;
fn try_from(url: url::Url) -> std::prelude::v1::Result<Self, Self::Error> {
let Some(host) = url.host_str() else {
bail!("{url} does not have a host");
};
match host {
"youtube.com" | "youtu.be" | "www.youtube.com" => Ok(Self::Youtube),
"open.spotify.com" => Ok(Self::Spotify),
"SOUNDCLOUD" => Ok(Self::Soundcloud), // TODO: Fix this
_ => bail!("Unknwon host {url}")
}
}
}

67
src/process_manager.rs Normal file
View File

@ -0,0 +1,67 @@
use std::{collections::HashMap, sync::atomic::{AtomicUsize, Ordering}};
use tokio::{process::Command, sync::{Mutex, RwLock}};
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct Proc {
msg: String,
finished: bool
}
lazy_static::lazy_static!(
static ref PROCESSES: Mutex<RwLock<HashMap<usize, Proc>>> = Mutex::new(RwLock::new(HashMap::new()));
);
static PROC_INC: AtomicUsize = AtomicUsize::new(0);
pub async fn add_proc(mut cmd: Command, msg: String) -> anyhow::Result<()> {
let mut proc = cmd.spawn()?;
let id = PROC_INC.fetch_add(1, Ordering::AcqRel);
tokio::spawn(async move {
let id = id;
proc.wait().await
.expect("child process encountered an error");
PROCESSES.lock().await.write().await.get_mut(&id).unwrap().finished = true;
});
PROCESSES.lock().await.write().await.insert(id, Proc {
finished: false,
msg,
});
Ok(())
}
/// Waits for processes to finish untill the proc count is lower or equal to `max`
pub async fn wait_for_procs_untill(max: usize) -> anyhow::Result<usize> {
// NOTE: This looks really fucked because i dont want to deadlock the processes so i lock PROCESSES for as little as possible
// NOTE: So its also kinda really slow
let mut finish_count = 0;
loop {
{
if PROCESSES.lock().await.read().await.len() <= max {
return Ok(finish_count);
}
}
let procs = {
PROCESSES.lock().await.read().await.clone()
};
for (idx, proc) in procs {
if proc.finished {
{
PROCESSES.lock().await.write().await.remove(&idx);
}
log::info!("{}", proc.msg);
finish_count += 1;
}
}
}
}

View File

@ -2,7 +2,7 @@ use std::{collections::HashMap, io::Write};
pub fn simple_prompt(p: &str) -> String {
pub(crate) fn simple_prompt(p: &str) -> String {
print!("{c}prompt{r}: {p} > ",
c=anstyle::AnsiColor::Cyan.render_fg(),
@ -18,7 +18,8 @@ pub fn simple_prompt(p: &str) -> String {
buf.trim().to_string()
}
pub fn prompt_with_list(p: &str, options: &[&str]) -> usize {
#[allow(dead_code)]
pub(crate) fn prompt_with_list(p: &str, options: &[&str]) -> usize {
println!("{c}prompt{r}: {p}",
c=anstyle::AnsiColor::Cyan.render_fg(),
r=anstyle::Reset.render()
@ -46,7 +47,7 @@ pub fn prompt_with_list(p: &str, options: &[&str]) -> usize {
}
}
pub fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
pub(crate) fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
println!("{c}prompt{r}: {p} (select with number or input text)",
c=anstyle::AnsiColor::Cyan.render_fg(),
r=anstyle::Reset.render()
@ -75,8 +76,8 @@ pub fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
}
pub fn prompt_with_map(p: &str, options: HashMap<&str, &str>) -> String {
#[allow(dead_code)]
pub(crate) fn prompt_with_map(p: &str, options: HashMap<&str, &str>) -> String {
println!("{c}prompt{r}: {p}",
c=anstyle::AnsiColor::Cyan.render_fg(),
r=anstyle::Reset.render()

View File

@ -1,11 +1,20 @@
use std::{io::Write, path::PathBuf};
use std::path::PathBuf;
use crate::constants;
pub(crate) fn is_supported_host(url: url::Url) -> bool {
let host = url.host_str();
if host.is_none() {
return false;
}
match host.unwrap() {
"youtube.com" | "youtu.be" |
"open.spotify.com" => true,
_ => false
}
}
pub fn is_program_in_path(program: &str) -> Option<PathBuf> {
pub(crate) fn is_program_in_path(program: &str) -> Option<PathBuf> {
if let Ok(path) = std::env::var("PATH") {
for p in path.split(constants::PATH_VAR_SEP) {
let exec_path = PathBuf::from(p).join(program).with_extension(constants::EXEC_EXT);
@ -18,7 +27,7 @@ pub fn is_program_in_path(program: &str) -> Option<PathBuf> {
}
#[cfg(target_family="unix")]
pub fn isatty() -> bool {
pub(crate) fn isatty() -> bool {
use std::{ffi::c_int, os::fd::AsRawFd};
unsafe {
let fd = std::io::stdin().as_raw_fd();
@ -27,7 +36,7 @@ pub fn isatty() -> bool {
}
#[cfg(target_family="windows")]
pub fn isatty() -> bool {
pub(crate) fn isatty() -> bool {
unsafe {
use windows::Win32::System::Console;
use Console::{CONSOLE_MODE, STD_OUTPUT_HANDLE};
@ -43,12 +52,12 @@ pub fn isatty() -> bool {
}
}
pub async fn dl_to_file(url: &str, p: PathBuf) -> anyhow::Result<()> {
log::info!("Downloading {} -> {:?}", url, p);
let ytdlp_req = reqwest::get(url).await?.bytes().await?;
log::debug!("Downloading {:?} finished, writing to file", p);
let mut fd = std::fs::File::create(&p)?;
fd.write(&ytdlp_req)?;
log::debug!("Finished writing {:?}", p);
Ok(())
}
// pub async fn dl_to_file(url: &str, p: PathBuf) -> anyhow::Result<()> {
// log::info!("Downloading {} -> {:?}", url, p);
// let ytdlp_req = reqwest::get(url).await?.bytes().await?;
// log::debug!("Downloading {:?} finished, writing to file", p);
// let mut fd = std::fs::File::create(&p)?;
// fd.write(&ytdlp_req)?;
// log::debug!("Finished writing {:?}", p);
// Ok(())
// }