Compare commits
15 Commits
3ba685448a
...
ft-dep-dl
| Author | SHA1 | Date | |
|---|---|---|---|
|
dc01b36771
|
|||
|
7d6d560d2b
|
|||
|
776a88c4cf
|
|||
|
29c7e452b0
|
|||
|
52a55d8be2
|
|||
|
266b580df7
|
|||
|
7eca925a8e
|
|||
|
b977e9cea5
|
|||
|
9fef257bfc
|
|||
|
3cad0b0651
|
|||
|
b05a20d724
|
|||
| 0ea98848e5 | |||
|
e05b00609c
|
|||
|
e377c0bd77
|
|||
|
057816bd65
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/out
|
/out
|
||||||
/music_mgr/target
|
/target
|
||||||
/.venv
|
/config.json
|
||||||
|
/manifest.json
|
||||||
|
|||||||
5352
Cargo.lock
generated
Normal file
5352
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "music_mgr"
|
name = "mcmg"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -10,13 +10,21 @@ anstyle = "1.0.6"
|
|||||||
anyhow = "1.0.81"
|
anyhow = "1.0.81"
|
||||||
camino = "1.1.6"
|
camino = "1.1.6"
|
||||||
clap = { version = "4.5.4", features = ["derive"] }
|
clap = { version = "4.5.4", features = ["derive"] }
|
||||||
|
eframe = "0.27.2"
|
||||||
|
egui = "0.27.2"
|
||||||
|
egui_extras = "0.27.2"
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.3"
|
||||||
|
futures = "0.3.30"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
libc = "0.2.153"
|
libc = "0.2.153"
|
||||||
log = "0.4.21"
|
log = "0.4.21"
|
||||||
reqwest = "0.12.3"
|
notify-rust = "4.11.3"
|
||||||
|
open = "5.3.0"
|
||||||
|
reqwest = { version = "0.12.3", features = ["blocking"] }
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
serde_json = "1.0.115"
|
serde_json = "1.0.115"
|
||||||
|
# serde_traitobject = "0.2.8"
|
||||||
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread", "process", "sync"] }
|
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"] }
|
windows = { version = "0.56.0", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||||
zip-extensions = "0.6.2"
|
zip-extensions = "0.6.2"
|
||||||
4
manifest.default.json
Normal file
4
manifest.default.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"format": "m4a",
|
||||||
|
"genres": {}
|
||||||
|
}
|
||||||
1138
manifest.json
1138
manifest.json
File diff suppressed because it is too large
Load Diff
1814
music_mgr/Cargo.lock
generated
1814
music_mgr/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
use std::{io::Write, path::PathBuf};
|
|
||||||
|
|
||||||
use crate::constants;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub 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);
|
|
||||||
if std::fs::metadata(&exec_path).is_ok() {
|
|
||||||
return Some(exec_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_family="unix")]
|
|
||||||
pub fn isatty() -> bool {
|
|
||||||
use std::{ffi::c_int, os::fd::AsRawFd};
|
|
||||||
unsafe {
|
|
||||||
let fd = std::io::stdin().as_raw_fd();
|
|
||||||
libc::isatty(fd as c_int) == 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_family="windows")]
|
|
||||||
pub fn isatty() -> bool {
|
|
||||||
unsafe {
|
|
||||||
use windows::Win32::System::Console;
|
|
||||||
use Console::{CONSOLE_MODE, STD_OUTPUT_HANDLE};
|
|
||||||
let Ok(handle) = Console::GetStdHandle(STD_OUTPUT_HANDLE) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out = CONSOLE_MODE(0);
|
|
||||||
|
|
||||||
let ret = Console::GetConsoleMode(handle, &mut out);
|
|
||||||
|
|
||||||
ret.is_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(())
|
|
||||||
}
|
|
||||||
55
src/commands/add.rs
Normal file
55
src/commands/add.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::bail;
|
||||||
|
|
||||||
|
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: &String, name: &String, playlist: &String) -> anyhow::Result<()> {
|
||||||
|
|
||||||
|
let mut playlists = manifest.get_playlists().keys().map(|f| f.clone()).collect::<Vec<String>>();
|
||||||
|
|
||||||
|
playlists.sort();
|
||||||
|
|
||||||
|
if !is_supported_host(url::Url::from_str(&url)?) {
|
||||||
|
log::error!("Invalid or unsupported host name");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let song = Song::from_url_str(url.clone())?;
|
||||||
|
manifest.add_song(playlist, 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, &playlist, manifest.get_format())?;
|
||||||
|
crate::process_manager::wait_for_procs_untill(0)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_playlist(cfg: &ConfigWrapper, manifest: &mut Manifest, downloader: &mut Downloader, url: &String, name: &String) -> anyhow::Result<()> {
|
||||||
|
let songs = downloader.download_playlist_nb(cfg, url, name, manifest.get_format())?;
|
||||||
|
|
||||||
|
if manifest.get_playlist(name).is_some() {
|
||||||
|
log::error!("Playlist {name} already exists");
|
||||||
|
bail!("")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.add_playlist(name.clone());
|
||||||
|
|
||||||
|
let playlist = manifest.get_playlist_mut(name).expect("Unreachable");
|
||||||
|
|
||||||
|
for (sname, song) in songs {
|
||||||
|
playlist.add_song(sname, song);
|
||||||
|
}
|
||||||
|
manifest.save(None)?;
|
||||||
|
|
||||||
|
while downloader.download_all_nb_poll(cfg)?.is_some() {};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
254
src/commands/gui/mod.rs
Normal file
254
src/commands/gui/mod.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
mod nav_bar;
|
||||||
|
mod song_edit_window;
|
||||||
|
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use egui::{Button, Color32, Label, RichText, Sense};
|
||||||
|
use egui_extras::{Column, TableBuilder};
|
||||||
|
use song_edit_window::{GuiError, GuiImportPlaylist, GuiNewSong};
|
||||||
|
|
||||||
|
use crate::{config::{Config, ConfigWrapper}, downloader::Downloader, manifest::{song::{Song, SongType}, Manifest}};
|
||||||
|
|
||||||
|
use self::song_edit_window::GuiSongEditor;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Gui {
|
||||||
|
manifest: Manifest,
|
||||||
|
song_edit_w: GuiSongEditor,
|
||||||
|
new_song_w: GuiNewSong,
|
||||||
|
import_playlist_w: GuiImportPlaylist,
|
||||||
|
error_w: GuiError,
|
||||||
|
filter: String,
|
||||||
|
downloader: Downloader,
|
||||||
|
cfg: ConfigWrapper,
|
||||||
|
downloading: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gui {
|
||||||
|
fn new(_: &eframe::CreationContext<'_>, manifest: Manifest, downloader: Downloader, cfg: ConfigWrapper) -> Self {
|
||||||
|
Self {
|
||||||
|
manifest,
|
||||||
|
downloader,
|
||||||
|
cfg,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(manifest: Manifest, downloader: Downloader, cfg: ConfigWrapper) -> 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, downloader, cfg))),
|
||||||
|
) {
|
||||||
|
log::error!("Failed to create window: {e}");
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn throw_error(&mut self, text: impl ToString) {
|
||||||
|
self.error_w.is_open = true;
|
||||||
|
self.error_w.text = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
self.draw_new_song_window(ctx, frame);
|
||||||
|
self.draw_import_playlist_window(ctx, frame);
|
||||||
|
self.draw_error_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()));
|
||||||
|
|
||||||
|
let fltr_by;
|
||||||
|
let filter_clean;
|
||||||
|
if self.filter.starts_with("playlist:") {
|
||||||
|
fltr_by = "playlist";
|
||||||
|
filter_clean = self.filter.strip_prefix("playlist:").unwrap_or("").to_string().to_lowercase();
|
||||||
|
} else if self.filter.starts_with("source:") {
|
||||||
|
fltr_by = "source";
|
||||||
|
filter_clean = self.filter.strip_prefix("source:").unwrap_or("").to_string().to_lowercase();
|
||||||
|
} else if self.filter.starts_with("url:") {
|
||||||
|
fltr_by = "url";
|
||||||
|
filter_clean = self.filter.strip_prefix("url:").unwrap_or("").to_string();
|
||||||
|
} else {
|
||||||
|
fltr_by = "";
|
||||||
|
filter_clean = self.filter.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(Color32::from_hex("#4444aa").unwrap(), "Filter: ");
|
||||||
|
ui.text_edit_singleline(&mut self.filter);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
let available_height = ui.available_height();
|
||||||
|
let table = TableBuilder::new(ui)
|
||||||
|
.striped(true)
|
||||||
|
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
||||||
|
.resizable(true)
|
||||||
|
//.column(Column::auto())
|
||||||
|
.column(Column::auto())
|
||||||
|
//.column(
|
||||||
|
// Column::remainder()
|
||||||
|
// .at_least(40.0)
|
||||||
|
// .clip(true)
|
||||||
|
// .resizable(true),
|
||||||
|
//)
|
||||||
|
.column(Column::auto())
|
||||||
|
.column(Column::remainder())
|
||||||
|
//.column(Column::remainder())
|
||||||
|
.min_scrolled_height(0.0)
|
||||||
|
.max_scroll_height(available_height)
|
||||||
|
.sense(egui::Sense::click());
|
||||||
|
|
||||||
|
let playlists = self.manifest.get_playlists().clone();
|
||||||
|
|
||||||
|
let songs = {
|
||||||
|
let mut songs = Vec::new();
|
||||||
|
for (pname, p) in playlists {
|
||||||
|
for (sname, s) in p {
|
||||||
|
songs.push((pname.clone(), sname, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
songs
|
||||||
|
};
|
||||||
|
|
||||||
|
table.header(20.0, |mut header| {
|
||||||
|
// header.col(|_|{});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.strong("Playlist");
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.strong("Source");
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.strong("Name");
|
||||||
|
});
|
||||||
|
}).body(|mut body| {
|
||||||
|
for (pname, sname, s) in songs {
|
||||||
|
if fltr_by == "playlist" && !filter_clean.is_empty() {
|
||||||
|
if !pname.to_lowercase().contains(&filter_clean) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if fltr_by == "type" && !filter_clean.is_empty(){
|
||||||
|
if !s.get_type().to_string().to_lowercase().contains(&filter_clean) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if fltr_by == "url" && !filter_clean.is_empty(){
|
||||||
|
if !s.get_url_str().contains(&filter_clean) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if !filter_clean.is_empty() {
|
||||||
|
if !sname.to_lowercase().contains(&filter_clean) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.row(18.0, |mut row| {
|
||||||
|
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.label(pname.clone())
|
||||||
|
.context_menu(|ui| context_menu(self, ui, &pname, &sname, &s));
|
||||||
|
});
|
||||||
|
row.col(|ui| {
|
||||||
|
let color =
|
||||||
|
match s.get_type() {
|
||||||
|
SongType::Youtube => Color32::from_hex("#FF0000").unwrap(),
|
||||||
|
SongType::Spotify => Color32::from_hex("#1db954").unwrap(),
|
||||||
|
SongType::Soundcloud => Color32::from_hex("#F26F23").unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.colored_label(color, s.get_type().to_string())
|
||||||
|
.context_menu(|ui| context_menu(self, ui, &pname, &sname, &s));
|
||||||
|
});
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.hyperlink_to(sname.clone(), s.get_url_str())
|
||||||
|
.context_menu(|ui| context_menu(self, ui, &pname, &sname, &s));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.response()
|
||||||
|
.context_menu(|ui| context_menu(self, ui, &pname, &sname, &s));
|
||||||
|
|
||||||
|
fn context_menu(this: &mut Gui, ui: &mut egui::Ui, pname: &String, sname: &String, song: &Song) {
|
||||||
|
if ui.button("Edit").clicked() {
|
||||||
|
this.song_edit_w.song = (
|
||||||
|
pname.clone(),
|
||||||
|
sname.clone(),
|
||||||
|
);
|
||||||
|
this.song_edit_w.is_open = true;
|
||||||
|
this.song_edit_w.ed_name = sname.clone();
|
||||||
|
this.song_edit_w.ed_url = song.get_url_str().clone();
|
||||||
|
ui.close_menu()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("Download").clicked() {
|
||||||
|
if let Err(e) = this.downloader.download_song_nb(&this.cfg, pname, sname, song, this.manifest.get_format()) {
|
||||||
|
log::error!("{e}");
|
||||||
|
this.throw_error(format!("Failed to download song {sname}: {e}"));
|
||||||
|
}
|
||||||
|
ui.close_menu()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("Open Source").clicked() {
|
||||||
|
if let Err(e) = open::that(song.get_url_str()) {
|
||||||
|
log::error!("{e}");
|
||||||
|
this.throw_error(format!("Failed to open song source: {e}"));
|
||||||
|
}
|
||||||
|
ui.close_menu()
|
||||||
|
}
|
||||||
|
if ui.button("Play").clicked() {
|
||||||
|
let p = crate::util::get_song_path(pname, sname, this.manifest.get_format());
|
||||||
|
|
||||||
|
if !p.exists() {
|
||||||
|
this.throw_error(format!("Song does not exist on disk"));
|
||||||
|
} else if let Err(e) = open::that(p) {
|
||||||
|
log::error!("{e}");
|
||||||
|
this.throw_error(format!("Failed to play song: {e}"));
|
||||||
|
}
|
||||||
|
ui.close_menu()
|
||||||
|
}
|
||||||
|
if ui.button("Delete from disk").clicked() {
|
||||||
|
let p = crate::util::get_song_path(pname, sname, this.manifest.get_format());
|
||||||
|
if p.exists() {
|
||||||
|
if let Err(e) = std::fs::remove_file(p) {
|
||||||
|
this.throw_error(format!("Failed to delete file: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button(RichText::new("Delete").color(Color32::RED)).clicked() {
|
||||||
|
this.throw_error("TODO");
|
||||||
|
ui.close_menu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
|
||||||
|
egui::warn_if_debug_build(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/commands/gui/nav_bar.rs
Normal file
65
src/commands/gui/nav_bar.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use egui::Hyperlink;
|
||||||
|
|
||||||
|
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("Source").clicked() {
|
||||||
|
ctx.open_url(egui::OpenUrl::new_tab("https://git.mcorangehq.xyz/XOR64/music"));
|
||||||
|
}
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
if let Err(e) = self.manifest.save(None) {
|
||||||
|
log::error!("Failed to save manifest: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("Quit").clicked() {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Song", |ui| {
|
||||||
|
if ui.button("Add New").clicked() {
|
||||||
|
self.new_song_w.is_open = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Playlist", |ui| {
|
||||||
|
if ui.button("Import").clicked() {
|
||||||
|
self.import_playlist_w.is_open = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Downloader", |ui| {
|
||||||
|
if ui.button("Download All").clicked() {
|
||||||
|
if let Err(e) = self.downloader.download_all_nb(&self.manifest, &self.cfg) {
|
||||||
|
log::error!("Err: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.with_layout(egui::Layout::bottom_up(egui::Align::RIGHT), |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if self.downloader.get_songs_left_nb() > 0 {
|
||||||
|
self.downloading = true;
|
||||||
|
ui.label(format!("Downloading: {}/{}", self.downloader.get_songs_left_nb(), self.downloader.get_initial_song_count_nb()));
|
||||||
|
} else if self.downloading {
|
||||||
|
let _ = notify_rust::Notification::new()
|
||||||
|
.summary("Done downloading")
|
||||||
|
.body("Your music has been downloaded")
|
||||||
|
.show();
|
||||||
|
self.downloading = false;
|
||||||
|
}
|
||||||
|
let _ = self.downloader.download_all_nb_poll(&self.cfg);
|
||||||
|
egui::widgets::global_dark_light_mode_buttons(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/commands/gui/song_edit_window.rs
Normal file
226
src/commands/gui/song_edit_window.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use egui::{text::{LayoutJob, TextWrapping}, Color32, Label, RichText, Style, TextBuffer, TextFormat, TextStyle};
|
||||||
|
|
||||||
|
|
||||||
|
use crate::manifest::{playlist::Playlist, song::{Song, SongType}};
|
||||||
|
|
||||||
|
use super::Gui;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct GuiSongEditor {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub song: (String, String),
|
||||||
|
pub ed_url: String,
|
||||||
|
pub ed_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct GuiNewSong {
|
||||||
|
pub is_open: bool,
|
||||||
|
ed_type: SongType,
|
||||||
|
ed_name: String,
|
||||||
|
ed_playlist: Option<String>,
|
||||||
|
ed_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct GuiImportPlaylist {
|
||||||
|
pub is_open: bool,
|
||||||
|
ed_name: String,
|
||||||
|
ed_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct GuiError {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gui {
|
||||||
|
pub fn draw_song_edit_window(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||||
|
let mut save = false;
|
||||||
|
|
||||||
|
let (playlist, song_name) = self.song_edit_w.song.clone();
|
||||||
|
|
||||||
|
let Some(song) = self.manifest.get_song(&playlist, &song_name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let song = song.clone();
|
||||||
|
|
||||||
|
egui::Window::new("Song editor")
|
||||||
|
.open(&mut self.song_edit_w.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, &playlist);
|
||||||
|
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_edit_w.ed_name);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Url: ");
|
||||||
|
ui.text_edit_singleline(&mut self.song_edit_w.ed_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if save {
|
||||||
|
{
|
||||||
|
let Some(song) = self.manifest.get_song_mut(&playlist, &song_name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
*song.get_url_str_mut() = self.song_edit_w.ed_url.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(playlist) = self.manifest.get_playlist_mut(&playlist) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
playlist.remove_song(&song_name);
|
||||||
|
playlist.add_song(self.song_edit_w.ed_name.clone(), song);
|
||||||
|
self.song_edit_w.is_open = false;
|
||||||
|
let _ = self.manifest.save(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_new_song_window(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||||
|
let mut save = false;
|
||||||
|
egui::Window::new("New song")
|
||||||
|
.open(&mut self.new_song_w.is_open)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Type: ");
|
||||||
|
egui::ComboBox::from_id_source("new_song_window_type")
|
||||||
|
.selected_text(format!("{:?}", self.new_song_w.ed_type))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.new_song_w.ed_type, SongType::Youtube, "Youtube");
|
||||||
|
ui.selectable_value(&mut self.new_song_w.ed_type, SongType::Spotify, "Spotify");
|
||||||
|
ui.selectable_value(&mut self.new_song_w.ed_type, SongType::Soundcloud, "Soundcloud");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name: ");
|
||||||
|
ui.text_edit_singleline(&mut self.new_song_w.ed_name);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Playlist: ");
|
||||||
|
egui::ComboBox::from_id_source("new_song_window_playlist")
|
||||||
|
.selected_text(format!("{}", self.new_song_w.ed_playlist.clone().unwrap_or("".to_string())))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for p in self.manifest.get_playlists().keys() {
|
||||||
|
ui.selectable_value(&mut self.new_song_w.ed_playlist, Option::Some(p.clone()), p.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Url: ");
|
||||||
|
ui.text_edit_singleline(&mut self.new_song_w.ed_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if save {
|
||||||
|
let Some(playlist) = self.manifest.get_playlist_mut(&self.new_song_w.ed_playlist.clone().unwrap()) else {
|
||||||
|
panic!("couldnt find playlist from a preset playlist list????????????");
|
||||||
|
};
|
||||||
|
|
||||||
|
playlist.add_song(
|
||||||
|
self.new_song_w.ed_name.clone(),
|
||||||
|
Song::from_url_str(self.new_song_w.ed_url.clone()).unwrap().set_type(self.new_song_w.ed_type.clone()).clone()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
let _ = self.manifest.save(None);
|
||||||
|
self.new_song_w.is_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_import_playlist_window(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||||
|
let mut save = false;
|
||||||
|
egui::Window::new("Import Playlist")
|
||||||
|
.open(&mut self.import_playlist_w.is_open)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Type: Youtube");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name: ");
|
||||||
|
ui.text_edit_singleline(&mut self.import_playlist_w.ed_name);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Url: ");
|
||||||
|
ui.text_edit_singleline(&mut self.import_playlist_w.ed_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui.button("Import").clicked() {
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if save {
|
||||||
|
let name = self.import_playlist_w.ed_name.clone();
|
||||||
|
let url = self.import_playlist_w.ed_url.clone();
|
||||||
|
|
||||||
|
if self.manifest.get_playlist(&name).is_some() {
|
||||||
|
log::error!("Playlist {name} already exists");
|
||||||
|
self.throw_error(format!("Playlist {name} already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let songs = self.downloader.download_playlist_nb(&self.cfg, &url, &name, &self.manifest.get_format()).unwrap();
|
||||||
|
self.manifest.add_playlist(name.clone());
|
||||||
|
|
||||||
|
let playlist = self.manifest.get_playlist_mut(&name).expect("Unreachable");
|
||||||
|
|
||||||
|
for (sname, song) in songs {
|
||||||
|
log::info!("Added: {sname}");
|
||||||
|
playlist.add_song(sname, song);
|
||||||
|
}
|
||||||
|
let _ = self.manifest.save(None);
|
||||||
|
self.import_playlist_w.is_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_error_window(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||||
|
egui::Window::new("ERROR!!!! D:")
|
||||||
|
.open(&mut self.error_w.is_open)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(RichText::new("Error:").size(30.0).color(Color32::RED));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(Label::new(self.error_w.text.clone()).wrap(true));
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
47
src/commands/mod.rs
Normal file
47
src/commands/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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::AddPlaylist { url, name } => {
|
||||||
|
if let Err(e) = add::add_playlist(cfg, manifest, &mut downloader, url, name).await {
|
||||||
|
log::error!("Failed to run 'add-playlist' commmand: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::Add { url, name, playlist } => {
|
||||||
|
if let Err(e) = add::add(cfg, manifest, &mut downloader, url, name, playlist).await {
|
||||||
|
log::error!("Failed to run 'add' command: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::Gui => {
|
||||||
|
gui::Gui::start(manifest.clone(), downloader, cfg.clone())?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, false) => {
|
||||||
|
gui::Gui::start(manifest.clone(), downloader, cfg.clone())?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,38 +1,46 @@
|
|||||||
use camino::Utf8PathBuf;
|
use camino::Utf8PathBuf;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use crate::util::isatty;
|
|
||||||
|
#[derive(Debug, Parser, Default, Clone)]
|
||||||
#[derive(Debug, Parser, Default)]
|
pub struct CliArgs {
|
||||||
pub struct CliArgs {
|
/// Show more info
|
||||||
/// Show more info
|
#[arg(long, short)]
|
||||||
#[arg(long, short)]
|
pub debug: bool,
|
||||||
pub debug: bool,
|
|
||||||
|
/// Path to manifest
|
||||||
/// Path to manifest
|
#[arg(long, short, default_value_t=Utf8PathBuf::from("./manifest.json"))]
|
||||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./manifest.json"))]
|
pub manifest: Utf8PathBuf,
|
||||||
pub manifest: Utf8PathBuf,
|
|
||||||
|
/// Output directory
|
||||||
/// Output directory
|
#[arg(long, short, default_value_t=Utf8PathBuf::from("./out"))]
|
||||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./out"))]
|
pub output: Utf8PathBuf,
|
||||||
pub output: Utf8PathBuf,
|
|
||||||
|
/// Config path
|
||||||
/// Config path
|
#[arg(long, short, default_value_t=Utf8PathBuf::from("./config.json"))]
|
||||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./config.json"))]
|
pub config: Utf8PathBuf,
|
||||||
pub config: Utf8PathBuf,
|
|
||||||
|
#[command(subcommand)]
|
||||||
#[command(subcommand)]
|
pub command: Option<CliCommand>,
|
||||||
pub command: Option<CliCommand>,
|
|
||||||
|
}
|
||||||
}
|
|
||||||
|
#[derive(Debug, Subcommand, Clone)]
|
||||||
#[derive(Debug, Subcommand, Default)]
|
pub enum CliCommand {
|
||||||
pub enum CliCommand {
|
Download,
|
||||||
#[default]
|
Add {
|
||||||
Download,
|
#[arg(long, short)]
|
||||||
Add {
|
url: String,
|
||||||
url: Option<String>,
|
#[arg(long, short)]
|
||||||
name: Option<String>,
|
name: String,
|
||||||
genre: Option<String>
|
#[arg(long, short)]
|
||||||
}
|
playlist: String
|
||||||
}
|
},
|
||||||
|
AddPlaylist {
|
||||||
|
#[arg(long, short)]
|
||||||
|
url: String,
|
||||||
|
#[arg(long, short)]
|
||||||
|
name: String
|
||||||
|
},
|
||||||
|
Gui
|
||||||
|
}
|
||||||
37
src/config/downloader/links.rs
Normal file
37
src/config/downloader/links.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
|
||||||
|
#[cfg(all(target_os="windows", target_arch="x86_64"))]
|
||||||
|
pub const YTDLP: &str = "https://github.com/yt-dlp/yt-dlp/releases/download/2024.08.06/yt-dlp_min.exe";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="x86_64"))]
|
||||||
|
pub const YTDLP: &str = "https://github.com/yt-dlp/yt-dlp/releases/download/2024.08.06/yt-dlp_linux";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="aarch64"))]
|
||||||
|
pub const YTDLP: &str = "https://github.com/yt-dlp/yt-dlp/releases/download/2024.08.06/yt-dlp_linux_aarch64";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="windows", target_arch="x86_64"))]
|
||||||
|
pub const SPOTDL: &str = "https://github.com/spotDL/spotify-downloader/releases/download/v4.2.8/spotdl-4.2.8-win32.exe";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="x86_64"))]
|
||||||
|
pub const SPOTDL: &str = "https://github.com/spotDL/spotify-downloader/releases/download/v4.2.8/spotdl-4.2.8-linux";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="aarch64"))]
|
||||||
|
pub const SPOTDL: &str = "";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="windows", target_arch="x86_64"))]
|
||||||
|
pub const FFMPEG: &str = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="x86_64"))]
|
||||||
|
pub const FFMPEG: &str = "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz";
|
||||||
|
|
||||||
|
#[cfg(all(target_os="linux", target_arch="aarch64"))]
|
||||||
|
pub const FFMPEG: &str = "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(not(any(
|
||||||
|
all(target_os="windows", target_arch="x86_64"),
|
||||||
|
all(target_os="linux", target_arch="x86_64"),
|
||||||
|
all(target_os="linux", target_arch="aarch64"),
|
||||||
|
)))]
|
||||||
|
compile_error!("Target and or Architecture is not supported");
|
||||||
111
src/config/downloader/mod.rs
Normal file
111
src/config/downloader/mod.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::constants::EXEC_EXT;
|
||||||
|
|
||||||
|
mod links;
|
||||||
|
|
||||||
|
pub struct DepDownloader {
|
||||||
|
ytdlp: PathBuf,
|
||||||
|
spotdl: PathBuf,
|
||||||
|
ffmpeg: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl DepDownloader {
|
||||||
|
pub fn new() -> anyhow::Result<Self> {
|
||||||
|
if !Path::new(".dep").exists() {
|
||||||
|
std::fs::create_dir(".dep")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ytdlp = Self::download_ytdlp()?;
|
||||||
|
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_ytdlp() -> anyhow::Result<PathBuf> {
|
||||||
|
if let Some(p) = crate::util::is_program_in_path("yt-dlp") {
|
||||||
|
return Ok(p); // Already exists on pc
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fp = PathBuf::new();
|
||||||
|
fp.push(".dep");
|
||||||
|
fp.push("yt-dlp");
|
||||||
|
fp.set_extension(EXEC_EXT);
|
||||||
|
|
||||||
|
if fp.exists() {
|
||||||
|
return Ok(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&fp)?;
|
||||||
|
|
||||||
|
let mut req = reqwest::blocking::get(links::YTDLP)?;
|
||||||
|
req.copy_to(&mut f)?;
|
||||||
|
|
||||||
|
Ok(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_spotdl() -> anyhow::Result<PathBuf> {
|
||||||
|
if let Some(p) = crate::util::is_program_in_path("spotdl") {
|
||||||
|
return Ok(p); // already exists on pc
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fp = PathBuf::new();
|
||||||
|
fp.push(".dep");
|
||||||
|
fp.push("spotdl");
|
||||||
|
fp.set_extension(EXEC_EXT);
|
||||||
|
|
||||||
|
if fp.exists() {
|
||||||
|
return Ok(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if links::SPOTDL.is_empty() {
|
||||||
|
log::error!("spotdl doesnt support this architecture (probably aarch64)");
|
||||||
|
log::error!("please install it manually, if you have installed it, make sure its accessible in path");
|
||||||
|
anyhow::bail!("");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&fp)?;
|
||||||
|
|
||||||
|
let mut req = reqwest::blocking::get(links::SPOTDL)?;
|
||||||
|
req.copy_to(&mut f)?;
|
||||||
|
|
||||||
|
Ok(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_ffmpeg() -> anyhow::Result<PathBuf> {
|
||||||
|
if let Some(p) = crate::util::is_program_in_path("ffmpeg") {
|
||||||
|
return Ok(p); // already exists on pc
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fp = PathBuf::new();
|
||||||
|
fp.push(".dep");
|
||||||
|
fp.push("ffmpeg");
|
||||||
|
fp.set_extension(EXEC_EXT);
|
||||||
|
|
||||||
|
if fp.exists() {
|
||||||
|
return Ok(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if links::FFMPEG.is_empty() {
|
||||||
|
log::error!("spotdl doesnt support this architecture (probably aarch64)");
|
||||||
|
log::error!("please install it manually, if you have installed it, make sure its accessible in path");
|
||||||
|
anyhow::bail!("");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&fp)?;
|
||||||
|
|
||||||
|
let mut req = reqwest::blocking::get(links::FFMPEG)?;
|
||||||
|
req.copy_to(&mut f)?;
|
||||||
|
|
||||||
|
Ok(fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/config/mod.rs
Normal file
131
src/config/mod.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
pub mod cli;
|
||||||
|
pub mod downloader;
|
||||||
|
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, Clone)]
|
||||||
|
pub struct ConfigWrapper {
|
||||||
|
pub cfg: Config,
|
||||||
|
pub cli: cli::CliArgs,
|
||||||
|
pub isatty: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub ytdlp: ConfigYtdlp,
|
||||||
|
pub spotdl: ConfigSpotdl,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
pub struct ConfigYtdlp {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
|
|
||||||
#[cfg(target_family="windows")]
|
#[cfg(target_family="windows")]
|
||||||
mod constants {
|
mod constants {
|
||||||
pub const PATH_VAR_SEP: &'static str = ";";
|
pub const PATH_VAR_SEP: &'static str = ";";
|
||||||
pub const EXEC_EXT: &'static str = "exe";
|
pub const EXEC_EXT: &'static str = "exe";
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_family="unix")]
|
#[cfg(target_family="unix")]
|
||||||
mod constants {
|
mod constants {
|
||||||
pub const PATH_VAR_SEP: &'static str = ":";
|
pub const PATH_VAR_SEP: &'static str = ":";
|
||||||
pub const EXEC_EXT: &'static str = "";
|
pub const EXEC_EXT: &'static str = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
184
src/downloader.rs
Normal file
184
src/downloader.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
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()));
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Downloader {
|
||||||
|
count: usize,
|
||||||
|
nb_initial_song_count: usize,
|
||||||
|
nb_cache: Vec<(String, String, Song, Format)>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_initial_song_count_nb(&self) -> usize {
|
||||||
|
self.nb_initial_song_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_songs_left_nb(&self) -> usize {
|
||||||
|
self.nb_cache.len() + crate::process_manager::proc_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_song_nb(&mut self, cfg: &ConfigWrapper, pname: &String, sname: &String, song: &Song, format: &Format) -> anyhow::Result<()> {
|
||||||
|
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), format.clone()));
|
||||||
|
self.nb_initial_song_count += 1;
|
||||||
|
self.download_all_nb_poll(cfg)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_all_nb(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<Option<usize>> {
|
||||||
|
for (pname, playlist) in manifest.get_playlists() {
|
||||||
|
for (sname, song) in playlist.get_songs() {
|
||||||
|
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), manifest.get_format().clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.nb_initial_song_count = self.nb_cache.len();
|
||||||
|
|
||||||
|
self.download_all_nb_poll(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_all_nb_poll(&mut self, cfg: &ConfigWrapper) -> anyhow::Result<Option<usize>> {
|
||||||
|
if !crate::process_manager::is_proc_queue_full(10) {
|
||||||
|
if let Some((pname, sname, song, format)) = self.nb_cache.pop() {
|
||||||
|
self.download_song(cfg, &sname, &song, &pname, &format)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.get_songs_left_nb() == 0 {
|
||||||
|
self.nb_initial_song_count = 0;
|
||||||
|
}
|
||||||
|
if crate::process_manager::proc_count() == 0 && self.nb_cache.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(crate::process_manager::purge_done_procs()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn download_all(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<usize> {
|
||||||
|
let format = manifest.get_format();
|
||||||
|
|
||||||
|
for (name, playlist) in manifest.get_playlists() {
|
||||||
|
for (song_name, song) in playlist.get_songs() {
|
||||||
|
self.download_song(cfg, song_name, song, &name, format)?;
|
||||||
|
self.count += crate::process_manager::wait_for_procs_untill(10)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.count += crate::process_manager::wait_for_procs_untill(0)?;
|
||||||
|
Ok(self.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn download_playlist(&mut self, cfg: &ConfigWrapper, url: &String, pname: &String, format: &Format) -> anyhow::Result<usize> {
|
||||||
|
self.download_playlist_nb(cfg, url, pname, format)?;
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(c) = self.download_all_nb_poll(cfg)? {
|
||||||
|
count += c;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_playlist_nb(&mut self, cfg: &ConfigWrapper, url: &String, pname: &String, format: &Format) -> anyhow::Result<HashMap<String, Song>> {
|
||||||
|
log::warn!("This automatically assumes its a youtube link as it is currently the only supported playlist source");
|
||||||
|
let mut cmd = tokio::process::Command::new(&cfg.cfg.ytdlp.path);
|
||||||
|
cmd.args([
|
||||||
|
"--flat-playlist",
|
||||||
|
"--simulate",
|
||||||
|
"-O", "%(url)s|%(title)s",
|
||||||
|
url.as_str()
|
||||||
|
]);
|
||||||
|
cmd
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.stdout(Stdio::piped());
|
||||||
|
|
||||||
|
let ftr = cmd.output();
|
||||||
|
|
||||||
|
let mut ret = HashMap::new();
|
||||||
|
|
||||||
|
let out = futures::executor::block_on(ftr)?.stdout;
|
||||||
|
let out = String::from_utf8(out)?;
|
||||||
|
for line in out.lines() {
|
||||||
|
let mut split_text = line.split("|").collect::<Vec<&str>>();
|
||||||
|
let url = split_text.swap_remove(0).to_string();
|
||||||
|
let sname = split_text.join("|");
|
||||||
|
let song = Song::from_url_str(url)?.set_type(SongType::Youtube).clone();
|
||||||
|
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), format.clone()));
|
||||||
|
ret.insert(sname, song.clone());
|
||||||
|
}
|
||||||
|
self.nb_initial_song_count += out.lines().count();
|
||||||
|
self.download_all_nb_poll(cfg)?;
|
||||||
|
Ok(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_song(&mut self, cfg: &ConfigWrapper, name: &String, song: &Song, playlist: &String, format: &Format) -> anyhow::Result<()> {
|
||||||
|
let dl_dir = format!("{}/{playlist}", cfg.cli.output);
|
||||||
|
let dl_file = format!("{dl_dir}/{}.{}", name, &format);
|
||||||
|
log::debug!("Checking: {dl_file}");
|
||||||
|
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}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
|
|
||||||
|
|
||||||
pub fn init_logger(debug: bool) {
|
pub fn init_logger(debug: bool) {
|
||||||
let level = if debug {
|
let level = if debug {
|
||||||
LevelFilter::Debug
|
LevelFilter::Debug
|
||||||
} else {
|
} else {
|
||||||
LevelFilter::Info
|
LevelFilter::Info
|
||||||
};
|
};
|
||||||
env_logger::builder()
|
env_logger::builder()
|
||||||
.format_timestamp(None)
|
.format_timestamp(None)
|
||||||
.filter_level(level)
|
.filter_level(level)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
use config::ConfigWrapper;
|
use config::ConfigWrapper;
|
||||||
|
|
||||||
|
|
||||||
// TODO: Possibly use https://docs.rs/ytextract/latest/ytextract/ instead of ytdlp
|
// TODO: Possibly use https://docs.rs/ytextract/latest/ytextract/ instead of ytdlp
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod logger;
|
mod logger;
|
||||||
mod downloader;
|
mod downloader;
|
||||||
mod util;
|
mod util;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod process_manager;
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
#[tokio::main]
|
||||||
let Ok(cfg) = ConfigWrapper::parse().await else {
|
async fn main() {
|
||||||
return;
|
let Ok(cfg) = ConfigWrapper::parse().await else {
|
||||||
};
|
return;
|
||||||
|
};
|
||||||
let mut manifest = match manifest::Manifest::from_path(&cfg.cli.manifest.as_std_path()) {
|
|
||||||
Ok(m) => m,
|
let mut manifest = match manifest::Manifest::load_new(&cfg.cli.manifest.clone().into_std_path_buf()) {
|
||||||
Err(e) => {
|
Ok(m) => m,
|
||||||
log::error!("Failed to parse manifest file {}: {e}", cfg.cli.manifest);
|
Err(e) => {
|
||||||
return;
|
log::error!("Failed to parse manifest file {}: {e}", cfg.cli.manifest);
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _ = commands::command_run(&cfg, &mut manifest).await;
|
|
||||||
}
|
let _ = commands::command_run(&cfg, &mut manifest).await;
|
||||||
|
}
|
||||||
139
src/manifest/mod.rs
Normal file
139
src/manifest/mod.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// pub mod v1;
|
||||||
|
|
||||||
|
pub mod song;
|
||||||
|
pub mod playlist;
|
||||||
|
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 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<String, playlist::Playlist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Manifest {
|
||||||
|
pub fn get_format(&self) -> &Format {
|
||||||
|
&self.format
|
||||||
|
}
|
||||||
|
pub fn add_song(&mut self, playlist_name: &String, name: SongName, song: Song) -> Option<Song> {
|
||||||
|
self.get_playlist_mut(playlist_name)?.add_song(name, song)
|
||||||
|
}
|
||||||
|
pub fn get_song(&self, playlist_name: &String, name: &String) -> Option<&Song> {
|
||||||
|
self.get_playlist(playlist_name)?.get_song(name)
|
||||||
|
}
|
||||||
|
pub fn get_song_mut(&mut self, playlist_name: &String, name: &String) -> Option<&mut Song> {
|
||||||
|
self.get_playlist_mut(playlist_name)?.get_song_mut(name)
|
||||||
|
}
|
||||||
|
pub fn add_playlist(&mut self, playlist_name: String) {
|
||||||
|
self.playlists.insert(playlist_name, Default::default());
|
||||||
|
}
|
||||||
|
pub fn get_playlist(&self, playlist_name: &String) -> Option<&playlist::Playlist> {
|
||||||
|
self.playlists.get(playlist_name)
|
||||||
|
}
|
||||||
|
pub fn get_playlist_mut(&mut self, playlist_name: &String) -> Option<&mut playlist::Playlist> {
|
||||||
|
self.playlists.get_mut(playlist_name)
|
||||||
|
}
|
||||||
|
pub fn get_playlists(&self) -> &HashMap<String, playlist::Playlist> {
|
||||||
|
&self.playlists
|
||||||
|
}
|
||||||
|
pub fn get_playlists_mut(&mut self) -> &mut HashMap<String, playlist::Playlist> {
|
||||||
|
&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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
src/manifest/playlist.rs
Normal file
56
src/manifest/playlist.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use egui::ahash::HashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::song::Song;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
pub struct Playlist {
|
||||||
|
songs: HashMap<String, Song>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
impl Playlist {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_song(&mut self, name: String, song: Song) -> Option<Song> {
|
||||||
|
self.songs.insert(name, song)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_song(&mut self, name: &String) -> Option<Song> {
|
||||||
|
self.songs.remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_song(&self, name: &String) -> Option<&Song> {
|
||||||
|
self.songs.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_songs(&self) -> &HashMap<String, Song> {
|
||||||
|
&self.songs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_songs_mut(&mut self) -> &mut HashMap<String, Song> {
|
||||||
|
&mut self.songs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_song_mut(&mut self, name: &String) -> Option<&mut Song> {
|
||||||
|
self.songs.get_mut(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.songs.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoIterator for Playlist {
|
||||||
|
type Item = (String, Song);
|
||||||
|
type IntoIter = std::collections::hash_map::IntoIter<String, Song>;
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.songs.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
src/manifest/song.rs
Normal file
83
src/manifest/song.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum SongType {
|
||||||
|
#[default]
|
||||||
|
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 set_type(&mut self, typ: SongType) -> &mut Self {
|
||||||
|
self.typ = typ;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/process_manager.rs
Normal file
79
src/process_manager.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use std::{collections::HashMap, sync::{atomic::{AtomicUsize, Ordering}, Mutex, RwLock}};
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[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 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().unwrap().write().unwrap().get_mut(&id).unwrap().finished = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
PROCESSES.lock().unwrap().write().unwrap().insert(id, Proc {
|
||||||
|
finished: false,
|
||||||
|
msg,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proc_count() -> usize {
|
||||||
|
PROCESSES.lock().unwrap().read().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_proc_queue_full(max: usize) -> bool {
|
||||||
|
let proc_cnt = PROCESSES.lock().unwrap().read().unwrap().len();
|
||||||
|
proc_cnt >= max
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn purge_done_procs() -> usize {
|
||||||
|
let mut finish_count = 0;
|
||||||
|
let procs = {
|
||||||
|
PROCESSES.lock().unwrap().read().unwrap().clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (idx, proc) in procs {
|
||||||
|
if proc.finished {
|
||||||
|
{
|
||||||
|
PROCESSES.lock().unwrap().write().unwrap().remove(&idx);
|
||||||
|
}
|
||||||
|
log::info!("{}", proc.msg);
|
||||||
|
finish_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for processes to finish untill the proc count is lower or equal to `max`
|
||||||
|
pub 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 !is_proc_queue_full(max) {
|
||||||
|
return Ok(finish_count);
|
||||||
|
}
|
||||||
|
finish_count += purge_done_procs();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +1,149 @@
|
|||||||
use std::{collections::HashMap, io::Write};
|
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} > ",
|
print!("{c}prompt{r}: {p} > ",
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
r=anstyle::Reset.render()
|
r=anstyle::Reset.render()
|
||||||
);
|
);
|
||||||
|
|
||||||
// I dont care if it fails
|
// I dont care if it fails
|
||||||
let _ = std::io::stdout().flush();
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
let _ = std::io::stdin().read_line(&mut buf);
|
let _ = std::io::stdin().read_line(&mut buf);
|
||||||
|
|
||||||
buf.trim().to_string()
|
buf.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prompt_with_list(p: &str, options: &[&str]) -> usize {
|
#[allow(dead_code)]
|
||||||
println!("{c}prompt{r}: {p}",
|
pub(crate) fn prompt_with_list(p: &str, options: &[&str]) -> usize {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p}",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
|
);
|
||||||
for (i, op) in options.iter().enumerate() {
|
|
||||||
println!(" - {}: {}", i, op);
|
for (i, op) in options.iter().enumerate() {
|
||||||
}
|
println!(" - {}: {}", i, op);
|
||||||
|
}
|
||||||
print!("> ");
|
|
||||||
// I dont care if it fails
|
print!("> ");
|
||||||
let _ = std::io::stdout().flush();
|
// I dont care if it fails
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::stdin().read_line(&mut buf);
|
let mut buf = String::new();
|
||||||
|
let _ = std::io::stdin().read_line(&mut buf);
|
||||||
if let Ok(num) = buf.parse::<usize>() {
|
|
||||||
if num <= options.len() {
|
if let Ok(num) = buf.parse::<usize>() {
|
||||||
return num;
|
if num <= options.len() {
|
||||||
} else {
|
return num;
|
||||||
return prompt_with_list(p, options);
|
} else {
|
||||||
}
|
return prompt_with_list(p, options);
|
||||||
} else {
|
}
|
||||||
return prompt_with_list(p, options);
|
} else {
|
||||||
}
|
return prompt_with_list(p, options);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pub fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
|
|
||||||
println!("{c}prompt{r}: {p} (select with number or input text)",
|
pub(crate) fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p} (select with number or input text)",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
|
);
|
||||||
for (i, op) in options.iter().enumerate() {
|
|
||||||
println!(" - {}: {}", i, op);
|
for (i, op) in options.iter().enumerate() {
|
||||||
}
|
println!(" - {}: {}", i, op);
|
||||||
|
}
|
||||||
print!("> ");
|
|
||||||
// I dont care if it fails
|
print!("> ");
|
||||||
let _ = std::io::stdout().flush();
|
// I dont care if it fails
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::stdin().read_line(&mut buf);
|
let mut buf = String::new();
|
||||||
|
let _ = std::io::stdin().read_line(&mut buf);
|
||||||
if let Ok(num) = buf.trim().parse::<usize>() {
|
|
||||||
if let Some(g) = options.get(num) {
|
if let Ok(num) = buf.trim().parse::<usize>() {
|
||||||
return g.clone();
|
if let Some(g) = options.get(num) {
|
||||||
} else {
|
return g.clone();
|
||||||
return prompt_with_list_or_str(p, options);
|
} else {
|
||||||
}
|
return prompt_with_list_or_str(p, options);
|
||||||
} else {
|
}
|
||||||
return buf.trim().to_string();
|
} else {
|
||||||
}
|
return buf.trim().to_string();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn prompt_with_map(p: &str, options: HashMap<&str, &str>) -> String {
|
#[allow(dead_code)]
|
||||||
println!("{c}prompt{r}: {p}",
|
pub(crate) fn prompt_with_map(p: &str, options: HashMap<&str, &str>) -> String {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p}",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
|
);
|
||||||
let mut keys = Vec::new();
|
|
||||||
|
let mut keys = Vec::new();
|
||||||
for (k, v) in &options {
|
|
||||||
println!(" - {}: {}", k, v);
|
for (k, v) in &options {
|
||||||
keys.push(k.trim().to_lowercase())
|
println!(" - {}: {}", k, v);
|
||||||
}
|
keys.push(k.trim().to_lowercase())
|
||||||
|
}
|
||||||
print!("> ");
|
|
||||||
|
print!("> ");
|
||||||
// I dont care if it fails
|
|
||||||
let _ = std::io::stdout().flush();
|
// I dont care if it fails
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::stdin().read_line(&mut buf);
|
let mut buf = String::new();
|
||||||
if !keys.contains(&buf.trim().to_lowercase()) {
|
let _ = std::io::stdin().read_line(&mut buf);
|
||||||
return prompt_with_map(p, options);
|
if !keys.contains(&buf.trim().to_lowercase()) {
|
||||||
}
|
return prompt_with_map(p, options);
|
||||||
buf.trim().to_string()
|
}
|
||||||
}
|
buf.trim().to_string()
|
||||||
|
}
|
||||||
pub fn prompt_bool(p: &str, default: Option<bool>) -> bool {
|
|
||||||
if default == Some(true) {
|
pub fn prompt_bool(p: &str, default: Option<bool>) -> bool {
|
||||||
println!("{c}prompt{r}: {p} (Y/n)",
|
if default == Some(true) {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p} (Y/n)",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
} else if default == Some(false) {
|
);
|
||||||
println!("{c}prompt{r}: {p} (y/N)",
|
} else if default == Some(false) {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p} (y/N)",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
} else {
|
);
|
||||||
println!("{c}prompt{r}: {p} (y/n)",
|
} else {
|
||||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
println!("{c}prompt{r}: {p} (y/n)",
|
||||||
r=anstyle::Reset.render()
|
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||||
);
|
r=anstyle::Reset.render()
|
||||||
}
|
);
|
||||||
print!("> ");
|
}
|
||||||
|
print!("> ");
|
||||||
// I dont care if it fails
|
|
||||||
let _ = std::io::stdout().flush();
|
// I dont care if it fails
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
let mut buf = String::new();
|
|
||||||
let _ = std::io::stdin().read_line(&mut buf);
|
let mut buf = String::new();
|
||||||
|
let _ = std::io::stdin().read_line(&mut buf);
|
||||||
if buf.trim().is_empty() {
|
|
||||||
match default {
|
if buf.trim().is_empty() {
|
||||||
Some(true) => return true,
|
match default {
|
||||||
Some(false) => return false,
|
Some(true) => return true,
|
||||||
None => {
|
Some(false) => return false,
|
||||||
return prompt_bool(p, default);
|
None => {
|
||||||
}
|
return prompt_bool(p, default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
match buf.to_lowercase().trim() {
|
|
||||||
"y" => true,
|
match buf.to_lowercase().trim() {
|
||||||
"n" => false,
|
"y" => true,
|
||||||
c => {
|
"n" => false,
|
||||||
log::error!("'{c}' is invalid, type y (yes) or n (no)");
|
c => {
|
||||||
return prompt_bool(p, default);
|
log::error!("'{c}' is invalid, type y (yes) or n (no)");
|
||||||
}
|
return prompt_bool(p, default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
87
src/util.rs
Normal file
87
src/util.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use std::{any::Any, path::PathBuf};
|
||||||
|
|
||||||
|
use crate::{constants, manifest::Format};
|
||||||
|
|
||||||
|
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(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);
|
||||||
|
if std::fs::metadata(&exec_path).is_ok() {
|
||||||
|
return Some(exec_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_family="unix")]
|
||||||
|
pub(crate) fn isatty() -> bool {
|
||||||
|
use std::{ffi::c_int, os::fd::AsRawFd};
|
||||||
|
unsafe {
|
||||||
|
let fd = std::io::stdin().as_raw_fd();
|
||||||
|
libc::isatty(fd as c_int) == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_family="windows")]
|
||||||
|
pub(crate) fn isatty() -> bool {
|
||||||
|
unsafe {
|
||||||
|
use windows::Win32::System::Console;
|
||||||
|
use Console::{CONSOLE_MODE, STD_OUTPUT_HANDLE};
|
||||||
|
let Ok(handle) = Console::GetStdHandle(STD_OUTPUT_HANDLE) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = CONSOLE_MODE(0);
|
||||||
|
|
||||||
|
let ret = Console::GetConsoleMode(handle, &mut out);
|
||||||
|
|
||||||
|
ret.is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn as_any_mut<T: Any>(val: &mut T) -> &mut dyn Any {
|
||||||
|
val as &mut dyn Any
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 fn get_song_path/*<P: TryInto<PathBuf>>*/(/*basepath: Option<P>,*/ pname: &String, sname: &String, format: &Format) -> PathBuf {
|
||||||
|
// let mut path: PathBuf;
|
||||||
|
/*if let Some(bp) = basepath {
|
||||||
|
if let Ok(bp) = bp.try_into() {
|
||||||
|
path = bp;
|
||||||
|
} else {
|
||||||
|
path = std::env::current_dir().unwrap_or(PathBuf::new());
|
||||||
|
}
|
||||||
|
} else {*/
|
||||||
|
let mut path = std::env::current_dir().unwrap_or(PathBuf::new());
|
||||||
|
//}
|
||||||
|
// TODO: Get this from cfg
|
||||||
|
path.push("out");
|
||||||
|
path.push(pname);
|
||||||
|
path.push(sname);
|
||||||
|
path.set_extension(format.to_string());
|
||||||
|
path
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user