diff --git a/Cargo.lock b/Cargo.lock index 37aac15..10e805c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,7 +577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1320,6 +1320,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1442,6 +1448,7 @@ dependencies = [ "toml", "tui-big-text", "twox-hash", + "youtube-api", ] [[package]] @@ -2105,7 +2112,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2494,7 +2501,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3025,7 +3032,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3215,6 +3222,16 @@ dependencies = [ "synstructure", ] +[[package]] +name = "youtube-api" +version = "0.1.1" +source = "git+https://gitea.awain.net/alterwain/youtube_api.git#bfac55ac2e9459b4eb51ed0bfe15fe61e29da4d7" +dependencies = [ + "json", + "reqwest", + "tokio", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index a5c596b..06517f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ futures = "0.3" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7.12", features = ["codec"] } soundcloud = { version = "0.1.9", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" } +youtube-api = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/youtube_api.git" } itunesdb = { version = "0.1.99", git = "https://gitea.awain.net/alterwain/ITunesDB.git" } rand = "0.8.5" tui-big-text = "0.7.1" diff --git a/src/config.rs b/src/config.rs index aee94f8..dd33e91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,31 +32,22 @@ pub fn get_temp_itunesdb() -> PathBuf { p } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Default)] pub struct YouTubeConfiguration { - pub user_id: u64, + pub user_id: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Default)] pub struct SoundCloudConfiguration { pub user_id: u64, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Default)] pub struct LyricaConfiguration { soundcloud: SoundCloudConfiguration, youtube: YouTubeConfiguration, } -impl Default for LyricaConfiguration { - fn default() -> Self { - Self { - soundcloud: SoundCloudConfiguration { user_id: 0 }, - youtube: YouTubeConfiguration { user_id: 0 }, - } - } -} - impl LyricaConfiguration { pub fn get_soundcloud(&self) -> &SoundCloudConfiguration { &self.soundcloud diff --git a/src/dlp.rs b/src/dlp.rs index 59d12ec..7cc4024 100644 --- a/src/dlp.rs +++ b/src/dlp.rs @@ -18,6 +18,64 @@ pub struct DownloadProgress { pub eta: String, } +pub async fn download_track_from_youtube( + track_url: &str, + download_dir: &PathBuf, + sender: Sender, +) -> io::Result<()> { + let _ = sender + .send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen)) + .await; + + if download_dir.exists() { + let _ = std::fs::remove_dir_all(download_dir); + } + let _ = std::fs::create_dir_all(download_dir); + + let args = &[ + "-f", + "bestaudio", + "-x", + "--audio-format", + "mp3", + "--audio-quality", + "0", + "-o", + "%(id)s.%(ext)s", + "--ignore-errors", + "--newline", + "--progress-template", + "{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}", + "--write-thumbnail", + &*["https://youtube.com/watch?v=", track_url].concat() + ]; + + let mut command = Command::new("yt-dlp"); + command.args(args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + command.current_dir(download_dir); + + let mut child = command.spawn()?; + + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + if line.starts_with("{") { + let progress: DownloadProgress = serde_json::from_str(&line).unwrap(); + let _ = sender + .send(AppEvent::OverallProgress((0, 1, Color::Green))) + .await; + let _ = sender.send(AppEvent::CurrentProgress(progress)).await; + } + } + let _ = sender + .send(AppEvent::OverallProgress((1, 1, Color::Green))) + .await; + Ok(()) +} + pub async fn download_track_from_soundcloud( track_url: &str, download_dir: &PathBuf, @@ -71,6 +129,74 @@ pub async fn download_track_from_soundcloud( Ok(()) } +pub async fn download_from_youtube( + playlist_url: &str, + download_dir: &PathBuf, + sender: Sender, +) -> io::Result<()> { + let _ = sender + .send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen)) + .await; + let dl_rx: Regex = Regex::new(r"\[download\] Downloading item \d+ of \d+").unwrap(); + + if download_dir.exists() { + let _ = std::fs::remove_dir_all(download_dir); + } + let _ = std::fs::create_dir_all(download_dir); + + let args = &[ + "-f", + "bestaudio", + "-x", + "--audio-format", + "mp3", + "--audio-quality", + "0", + "-o", + "%(id)s.%(ext)s", + "--ignore-errors", + "--newline", + "--progress-template", + "{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}", + "--write-thumbnail", + &*["https://youtube.com", playlist_url].concat() + ]; + + let mut command = Command::new("yt-dlp"); + command.args(args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + command.current_dir(download_dir); + + let mut child = command.spawn()?; + + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + match dl_rx.find(&line) { + Some(m) => { + let mut s = m.as_str(); + s = s.split("Downloading item ").last().unwrap(); + let s: Vec<&str> = s.split(' ').collect(); + let cur = s.first().unwrap().trim().parse().unwrap(); + let max = s.last().unwrap().trim().parse().unwrap(); + let _ = sender + .send(AppEvent::OverallProgress((cur, max, Color::Green))) + .await; + } + None => { + if line.starts_with("{") { + let progress: DownloadProgress = serde_json::from_str(&line).unwrap(); + let _ = sender.send(AppEvent::CurrentProgress(progress)).await; + } + } + } + } + + Ok(()) +} + pub async fn download_from_soundcloud( playlist_url: &str, download_dir: &PathBuf, diff --git a/src/main.rs b/src/main.rs index c912930..3bdc1cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,6 +121,10 @@ impl App { let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen); screen.set_soundcloud_playlists(playlists); }, + AppEvent::YoutubeGot(playlists) => { + let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen); + screen.set_youtube_playlists(playlists); + }, AppEvent::OverallProgress((c, max, color)) => { self.state = AppState::LoadingScreen; let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen); diff --git a/src/main_screen.rs b/src/main_screen.rs index 9abc567..b80f3fe 100644 --- a/src/main_screen.rs +++ b/src/main_screen.rs @@ -11,7 +11,7 @@ use soundcloud::sobjects::{CloudPlaylist, CloudPlaylists}; use tokio::sync::mpsc::UnboundedSender; use crate::component::table::SmartTable; -use crate::sync::DBPlaylist; +use crate::sync::{DBPlaylist, YTPlaylist}; use crate::{screen::AppScreen, sync::AppEvent, theme::Theme, AppState}; pub struct MainScreen { @@ -20,6 +20,7 @@ pub struct MainScreen { pl_table: SmartTable, song_table: SmartTable, tab_titles: Vec, + youtube: Option>, soundcloud: Option>, playlists: Option>, sender: UnboundedSender, @@ -104,6 +105,7 @@ impl MainScreen { pl_table: SmartTable::default(), song_table: SmartTable::default(), soundcloud: None, + youtube: None, playlists: None, selected_tab: 0, tab_titles: vec![ @@ -229,33 +231,73 @@ impl MainScreen { } fn download_row(&mut self) { - if self.selected_tab == 1 { - // SC - match self.mode { - false => { - let playlist = self - .soundcloud - .as_ref() - .unwrap() - .get(self.pl_table.selected_row()) - .unwrap() - .clone(); - let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist)); - } - true => { - let track = self - .soundcloud - .as_ref() - .unwrap() - .get(self.pl_table.selected_row()) - .unwrap() - .tracks - .get(self.song_table.selected_row()) - .unwrap() - .clone(); - let _ = self.sender.send(AppEvent::DownloadTrack(track)); + match self.selected_tab { + 0 => { + // YT + match self.mode { + false => { + let playlist = self + .youtube + .as_ref() + .unwrap() + .get(self.pl_table.selected_row()) + .unwrap() + .clone(); + + let _ = self.sender.send(AppEvent::DownloadYTPlaylist(playlist)); + } + true => { + let track = self + .youtube + .as_ref() + .unwrap() + .get(self.pl_table.selected_row()) + .unwrap() + .videos + .get(self.song_table.selected_row()) + .unwrap() + .clone(); + + let _ = self.sender.send(AppEvent::DownloadYTTrack(track)); + } } } + 1 => { + // SC + match self.mode { + false => { + let playlist = self + .soundcloud + .as_ref() + .unwrap() + .get(self.pl_table.selected_row()) + .unwrap() + .clone(); + let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist)); + } + true => { + let track = self + .soundcloud + .as_ref() + .unwrap() + .get(self.pl_table.selected_row()) + .unwrap() + .tracks + .get(self.song_table.selected_row()) + .unwrap() + .clone(); + let _ = self.sender.send(AppEvent::DownloadTrack(track)); + } + } + } + _ => {} + } + } + + pub fn set_youtube_playlists(&mut self, pls: Vec) { + self.youtube = Some(pls); + if self.selected_tab == 0 { + self.update_tables(); } } @@ -292,6 +334,23 @@ impl MainScreen { ); let data = match self.selected_tab { + 0 => { + if let Some(yt) = &self.youtube { + yt.iter() + .map(|playlist| { + vec![ + 0.to_string(), + playlist.title.clone(), + [playlist.videos.len().to_string(), " songs".to_string()].concat(), + String::new(), + "NO".to_string(), + ] + }) + .collect::>>() + } else { + Vec::new() + } + } 1 => { if let Some(sc) = &self.soundcloud { sc.iter() @@ -349,6 +408,35 @@ impl MainScreen { .to_vec(); match self.selected_tab { + 0 => { + self.song_table = SmartTable::new( + ["Id", "Title", "Artist", "Duration", ""] + .iter_mut() + .map(|s| s.to_string()) + .collect(), + constraints, + ); + self.set_mode(self.mode); + + if let Some(pls) = &self.youtube { + let y = &pls.get(self.pl_table.selected_row()).unwrap().videos; + let data = y + .iter() + .map(|video| { + vec![ + video.videoId.clone(), + video.title.clone(), + video.publisher.clone(), + video.lengthSeconds.to_string(), + String::new(), + ] + }) + .collect::>>(); + + self.song_table.set_data(data); + } + self.song_table.set_title(" Songs ".to_string()); + } 1 => { self.song_table = SmartTable::new( ["Id", "Title", "Artist", "Duration", "Genre"] diff --git a/src/sync.rs b/src/sync.rs index e3452bb..ada1f24 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,3 +1,11 @@ +use crate::util::IPodImage; +use crate::{ + config::{ + get_config_path, get_configs_dir, get_temp_dl_dir, get_temp_itunesdb, LyricaConfiguration, + }, + dlp::{self, DownloadProgress}, + util, AppState, +}; use audiotags::Tag; use color_eyre::owo_colors::OwoColorize; use image::imageops::FilterType; @@ -6,6 +14,7 @@ use itunesdb::artworkdb::aobjects::ADatabase; use itunesdb::objects::{ListSortOrder, PlaylistItem}; use itunesdb::serializer; use itunesdb::xobjects::{XDatabase, XPlArgument, XPlaylist, XTrackItem}; +use rand::random; use ratatui::style::Color; use soundcloud::sobjects::{CloudPlaylist, CloudPlaylists, CloudTrack}; use std::io::Read; @@ -18,23 +27,18 @@ use tokio::{ sync::mpsc::{Sender, UnboundedReceiver}, }; use tokio_util::sync::CancellationToken; - -use crate::util::IPodImage; -use crate::{ - config::{ - get_config_path, get_configs_dir, get_temp_dl_dir, get_temp_itunesdb, LyricaConfiguration, - }, - dlp::{self, DownloadProgress}, - util, AppState, -}; +use youtube_api::objects::YoutubeVideo; pub enum AppEvent { SearchIPod, IPodNotFound, ITunesParsed(Vec), + YoutubeGot(Vec), SoundcloudGot(CloudPlaylists), DownloadPlaylist(CloudPlaylist), DownloadTrack(CloudTrack), + DownloadYTPlaylist(YTPlaylist), + DownloadYTTrack(YoutubeVideo), CurrentProgress(DownloadProgress), OverallProgress((u32, u32, ratatui::style::Color)), ArtworkProgress((u32, u32)), @@ -54,6 +58,84 @@ pub struct DBPlaylist { pub tracks: Vec, } +#[derive(Clone)] +pub struct YTPlaylist { + pub title: String, + pub url: String, + pub videos: Vec, +} + +async fn track_from_video( + value: &YoutubeVideo, + ipod_path: String, + sender: &Sender, +) -> Option { + let mut track_path = get_temp_dl_dir(); + track_path.push(&value.videoId); + track_path.set_extension("mp3"); + let mut image_path = get_temp_dl_dir(); + image_path.push(&value.videoId); + image_path.set_extension("webp"); + + let audio_file = audio_file_info::from_path(track_path.to_str().unwrap()) + .await + .unwrap(); + let audio_info = &audio_file.audio_file.tracks.track; + let song_dbid = util::hash_from_path(track_path.clone()); + + let mut track = XTrackItem::new( + random(), + File::open(track_path) + .await + .unwrap() + .metadata() + .await + .unwrap() + .size() as u32, + (audio_info.duration * 1000.0) as u32, + 0, + (audio_info.bit_rate / 1000) as u32, + audio_info.sample_rate as u32, + song_dbid, + 0, + ); + + if image_path.exists() { + let _ = sender.send(AppEvent::ArtworkProgress((0, 2))).await; + let mut adb = get_artwork_db(&ipod_path); + + let image_data = std::fs::read(image_path).unwrap(); + + let cover_hash = util::hash(&image_data); + + let if_cover_present = adb.if_cover_present(cover_hash); + + let (small_img_name, large_img_name) = adb.add_images(song_dbid, cover_hash); + + let size = image_data.len(); + + if !if_cover_present { + make_cover_image(&image_data, &ipod_path, &small_img_name, (100, 100)); + let _ = sender.send(AppEvent::ArtworkProgress((1, 2))).await; + make_cover_image(&image_data, &ipod_path, &large_img_name, (200, 200)); + } + + write_artwork_db(adb, &ipod_path); + + track.data.artwork_size = size as u32; + track.data.mhii_link = 0; + track.data.has_artwork = 1; + track.data.artwork_count = 1; + let _ = sender.send(AppEvent::ArtworkProgress((2, 2))).await; + } + + audio_file.modify_xtrack(&mut track); + + track.set_title(value.title.clone()); + track.set_artist(value.publisher.clone()); + Some(track) +} + async fn track_from_soundcloud( value: &CloudTrack, ipod_path: String, @@ -203,6 +285,8 @@ pub fn initialize_async_service( }, AppEvent::DownloadPlaylist(playlist) => { download_playlist(playlist, database.as_mut().unwrap(), &sender, ipod_db.clone().unwrap()).await; }, AppEvent::DownloadTrack(track) => { download_track(track, database.as_mut().unwrap(), &sender, ipod_db.clone().unwrap()).await; }, + AppEvent::DownloadYTTrack(video) => { download_video(video, database.as_mut().unwrap(), &sender, ipod_db.clone().unwrap()).await; }, + AppEvent::DownloadYTPlaylist(ytplaylist) => { download_youtube_playlist(ytplaylist, database.as_mut().unwrap(), &sender, ipod_db.clone().unwrap()).await; }, AppEvent::SwitchScreen(state) => { let _ = sender.send(AppEvent::SwitchScreen(state)).await;}, AppEvent::LoadFromFS(path) => { load_from_fs(path, database.as_mut().unwrap(), &sender, ipod_db.clone().unwrap()).await; @@ -584,6 +668,48 @@ fn make_cover_image(cover: &[u8], ipod_path: &str, file_name: &str, dim: (u32, u img.write(dst); } +async fn download_video( + video: YoutubeVideo, + database: &mut XDatabase, + sender: &Sender, + ipod_path: String, +) { + if let Ok(()) = + dlp::download_track_from_youtube(&video.videoId.clone(), &get_temp_dl_dir(), sender.clone()) + .await + { + let p: PathBuf = Path::new(&ipod_path).into(); + + if let Some(mut t) = track_from_video(&video, ipod_path.clone(), sender).await { + if !database.if_track_in_library(t.data.dbid) { + t.data.unique_id = database.get_unique_id(); + t.set_location(get_track_location(t.data.unique_id, "mp3")); + let dest = get_full_track_location(p.clone(), t.data.unique_id, "mp3"); + + let mut track_path = get_temp_dl_dir(); + track_path.push(&video.videoId); + track_path.set_extension("mp3"); + + let _ = std::fs::copy(track_path.to_str().unwrap(), dest.to_str().unwrap()); + + database.add_track(t); + } + } + } + + let _ = sender + .send(AppEvent::SwitchScreen(AppState::MainScreen)) + .await; + + let _ = sender + .send(AppEvent::ITunesParsed(get_playlists(database))) + .await; + + overwrite_database(database, &ipod_path); + + crate::config::clear_temp_dl_dir(); +} + async fn download_track( track: CloudTrack, database: &mut XDatabase, @@ -629,6 +755,60 @@ async fn download_track( crate::config::clear_temp_dl_dir(); } +async fn download_youtube_playlist( + playlist: YTPlaylist, + database: &mut XDatabase, + sender: &Sender, + ipod_path: String, +) { + if let Ok(()) = + dlp::download_from_youtube(&playlist.url, &get_temp_dl_dir(), sender.clone()).await + { + let videos = playlist.videos; + + let p: PathBuf = Path::new(&ipod_path).into(); + + let mut new_playlist = XPlaylist::new(rand::random(), ListSortOrder::SongTitle); + + new_playlist.set_title(playlist.title); + + for video in videos { + if let Some(mut t) = track_from_video(&video, ipod_path.clone(), sender).await { + if !database.if_track_in_library(t.data.dbid) { + t.data.unique_id = database.get_unique_id(); + new_playlist.add_elem(t.data.unique_id); + t.set_location(get_track_location(t.data.unique_id, "mp3")); + let dest = get_full_track_location(p.clone(), t.data.unique_id, "mp3"); + + let mut track_path = get_temp_dl_dir(); + track_path.push(&video.videoId); + track_path.set_extension("mp3"); + + let _ = std::fs::copy(track_path.to_str().unwrap(), dest.to_str().unwrap()); + + database.add_track(t); + } else if let Some(unique_id) = database.get_unique_id_by_dbid(t.data.dbid) { + new_playlist.add_elem(unique_id); + } + } + } + + database.add_playlist(new_playlist); + } + + let _ = sender + .send(AppEvent::SwitchScreen(AppState::MainScreen)) + .await; + + let _ = sender + .send(AppEvent::ITunesParsed(get_playlists(database))) + .await; + + overwrite_database(database, &ipod_path); + + crate::config::clear_temp_dl_dir(); +} + async fn download_playlist( playlist: CloudPlaylist, database: &mut XDatabase, @@ -732,6 +912,28 @@ async fn parse_itunes(sender: &Sender, path: String) -> XDatabase { file.read_to_string(&mut content).await.unwrap(); let config: LyricaConfiguration = toml::from_str(&content).unwrap(); + let yt_channel_id = config.get_youtube().user_id.clone(); + + let rid = youtube_api::get_channel(yt_channel_id.clone()) + .await + .unwrap(); + let pls = youtube_api::get_playlists(yt_channel_id, rid) + .await + .unwrap(); + + let mut yt_v = Vec::new(); + + for pl in pls { + let videos = youtube_api::get_playlist(pl.browse_id).await.unwrap(); + yt_v.push(YTPlaylist { + title: pl.title, + url: pl.pl_url, + videos, + }); + } + + let _ = sender.send(AppEvent::YoutubeGot(yt_v)).await; + let app_version = soundcloud::get_app().await.unwrap().unwrap(); let client_id = soundcloud::get_client_id().await.unwrap().unwrap(); let playlists = soundcloud::get_playlists(