From 67c1f34bd046882de1773870455854c446921c66 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 22 Jun 2026 17:03:42 +0800 Subject: [PATCH 1/2] feat: add authorized YouTube audio downloads --- src/app/bootstrap.rs | 6 ++ src/app/components/playback_info_panel.rs | 101 +++++++++++++++++++++- src/app/core.rs | 76 +++++++++++++++- src/app/i18n.rs | 32 +++++++ src/app/state/ui_state.rs | 22 +++++ src/app/style/icons.rs | 2 + src/app/ui.rs | 14 ++- src/lib/services/mod.rs | 2 + src/lib/services/youtube_download.rs | 97 +++++++++++++++++++++ 9 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/lib/services/youtube_download.rs diff --git a/src/app/bootstrap.rs b/src/app/bootstrap.rs index 84bdb54..efc2c37 100644 --- a/src/app/bootstrap.rs +++ b/src/app/bootstrap.rs @@ -8,6 +8,7 @@ use crate::app::runtime; use crate::app::viewport; use crate::app::App; use crate::app::LibraryCommand; +use bird_player::services::YoutubeDownloadEvent; use std::sync::atomic::{AtomicBool, AtomicU32}; use std::sync::mpsc::channel; use std::sync::Arc; @@ -16,6 +17,8 @@ pub struct BirdBootCfg { pub db: Arc, pub lib_cmd_tx: std::sync::mpsc::Sender, pub lib_cmd_rx: std::sync::mpsc::Receiver, + pub youtube_download_tx: std::sync::mpsc::Sender, + pub youtube_download_rx: std::sync::mpsc::Receiver, pub is_processing_ui_change: Arc, } @@ -30,6 +33,7 @@ pub fn start_app() -> Result<()> { tracing::info!("Database initialized successfully"); let (lib_cmd_tx, lib_cmd_rx) = channel(); + let (youtube_download_tx, youtube_download_rx) = channel(); let is_processing_ui_change = Arc::new(AtomicBool::new(false)); let is_processing_ui_change_thread = is_processing_ui_change.clone(); @@ -37,6 +41,8 @@ pub fn start_app() -> Result<()> { db: database, lib_cmd_tx, lib_cmd_rx, + youtube_download_tx, + youtube_download_rx, is_processing_ui_change, }; diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index ab0480a..7c81c30 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -1,7 +1,8 @@ use super::AppComponent; -use crate::app::style::tokens; +use crate::app::style::{icons, player_button, tokens, ButtonExt}; +use crate::app::t; use crate::app::App; -use eframe::egui::{self, Align, Layout, RichText}; +use eframe::egui::{self, Align, Layout, RichText, TextEdit}; const DESCRIPTION_PREVIEW_LENGTH: usize = 30; @@ -11,6 +12,8 @@ impl AppComponent for PlaybackInfoPanel { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { + Self::render_download_entry(ctx, ui); + // Check if we have a playing playlist let playing_playlist_idx = ctx.app_settings.playing_playlist_idx; @@ -25,6 +28,100 @@ impl AppComponent for PlaybackInfoPanel { } impl PlaybackInfoPanel { + fn render_download_entry(ctx: &mut App, ui: &mut egui::Ui) { + let active = + ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress; + let button = ui + .add(player_button(icons::DOWNLOAD, active)) + .on_hover_text(t("download_authorized_audio")); + + if button.clicked() { + ctx.ui_state.show_youtube_download_dialog = true; + } + + Self::render_download_dialog(ctx, ui.ctx().clone()); + } + + fn render_download_dialog(ctx: &mut App, egui_ctx: egui::Context) { + if !ctx.ui_state.show_youtube_download_dialog { + return; + } + + let mut open = ctx.ui_state.show_youtube_download_dialog; + egui::Window::new(t("download_authorized_audio")) + .id(egui::Id::new("youtube_download_dialog")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(&egui_ctx, |ui| { + ui.set_min_width(420.0); + ui.label(RichText::new(t("authorized_audio_notice")).weak()); + ui.add_space(tokens::spacing::XS); + + ui.label(t("youtube_url")); + ui.add_enabled( + !ctx.ui_state.youtube_download_in_progress, + TextEdit::singleline(&mut ctx.ui_state.youtube_download_url) + .hint_text("https://www.youtube.com/watch?v=..."), + ); + + ui.add_space(tokens::spacing::XS); + ui.label(t("save_to")); + ui.horizontal(|ui| { + let mut path_text = ctx.ui_state.youtube_download_dir.display().to_string(); + ui.add_enabled( + false, + TextEdit::singleline(&mut path_text).desired_width(340.0), + ); + + let choose = ui + .add_enabled( + !ctx.ui_state.youtube_download_in_progress, + egui::Button::new(icons::FOLDER).player_style(), + ) + .on_hover_text(t("choose_download_folder")); + if choose.clicked() { + if let Some(folder) = rfd::FileDialog::new() + .set_directory(&ctx.ui_state.youtube_download_dir) + .pick_folder() + { + ctx.ui_state.youtube_download_dir = folder; + } + } + }); + + if let Some(status) = &ctx.ui_state.youtube_download_status { + ui.add_space(tokens::spacing::XS); + ui.label(RichText::new(status).weak()); + } + + ui.add_space(tokens::spacing::SM); + ui.horizontal(|ui| { + let can_download = !ctx.ui_state.youtube_download_in_progress + && !ctx.ui_state.youtube_download_url.trim().is_empty(); + if ui + .add_enabled(can_download, egui::Button::new(t("download"))) + .clicked() + { + ctx.start_youtube_download(); + } + + if ui + .add_enabled( + !ctx.ui_state.youtube_download_in_progress, + egui::Button::new(t("clear")), + ) + .clicked() + { + ctx.ui_state.youtube_download_url.clear(); + ctx.ui_state.youtube_download_status = None; + } + }); + }); + + ctx.ui_state.show_youtube_download_dialog = open; + } + fn render_playlist_info(ctx: &App, ui: &mut egui::Ui, playlist_idx: usize) { if let Some(playlist) = ctx.playlists.get(playlist_idx) { ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { diff --git a/src/app/core.rs b/src/app/core.rs index 39788c8..465335c 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize}; use super::error::AppLoadError; use super::i18n; -use super::lib_services::{LibraryImportService, LyricsManager, PlayerRestoreService}; +use super::lib_services::{ + LibraryImportService, LyricsManager, PlayerRestoreService, YoutubeDownloadEvent, + YoutubeDownloadService, +}; use super::library::{Library, LibraryCommand, LibraryPath}; pub use super::library::{LibraryItem, LibraryPathId}; use super::libstate::lyrics_state::LyricsFetchState; @@ -88,6 +91,22 @@ impl App { .lib_cmd_rx } + pub fn youtube_download_tx(&self) -> &Sender { + &self + .boot_cfg + .as_ref() + .expect("boot_cfg not initialized") + .youtube_download_tx + } + + pub fn youtube_download_rx(&self) -> &Receiver { + &self + .boot_cfg + .as_ref() + .expect("boot_cfg not initialized") + .youtube_download_rx + } + pub fn is_processing_ui_change(&self) -> Arc { Arc::clone( &self @@ -271,6 +290,61 @@ impl App { LibraryImportService::import_library_path(lib_path, lib_cmd_tx, album_art_dir); } + pub fn start_youtube_download(&mut self) { + if self.ui_state.youtube_download_in_progress { + return; + } + + let url = self.ui_state.youtube_download_url.trim().to_string(); + if url.is_empty() { + self.ui_state.youtube_download_status = Some("URL is required".to_string()); + return; + } + + self.ui_state.youtube_download_in_progress = true; + self.ui_state.youtube_download_status = Some("Downloading...".to_string()); + + YoutubeDownloadService::download_authorized_audio( + url, + self.ui_state.youtube_download_dir.clone(), + self.youtube_download_tx().clone(), + ); + } + + pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) { + self.ui_state.youtube_download_in_progress = false; + + match event { + YoutubeDownloadEvent::Finished(Ok(result)) => { + let file_count = result.downloaded_files.len(); + self.ui_state.youtube_download_status = Some(if file_count == 0 { + "Download finished. Re-syncing folder...".to_string() + } else { + format!("Downloaded {} file(s). Re-syncing folder...", file_count) + }); + + let path_exists = !self.library.add_path(result.output_dir.clone()); + let path_to_import = if path_exists { + self.library + .paths() + .iter() + .find(|p| *p.path() == result.output_dir) + .cloned() + } else { + self.library.paths().last().cloned() + }; + + if let Some(path) = path_to_import { + self.library.set_path_to_not_imported(path.id()); + self.import_library_paths(&path); + } + } + YoutubeDownloadEvent::Finished(Err(err)) => { + self.ui_state.youtube_download_status = Some(err); + } + } + } + pub fn update_track_lyrics(&mut self, track_key: String, lyrics: Option<&str>) { let db_conn = self.db().connection(); let player = self.runtime.as_mut().map(|rt| &mut rt.player); diff --git a/src/app/i18n.rs b/src/app/i18n.rs index c2dc484..b02b457 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -110,6 +110,22 @@ pub fn init() { "remove_from_library".to_string(), "Remove from library".to_string(), ); + en.insert( + "download_authorized_audio".to_string(), + "Download authorized audio".to_string(), + ); + en.insert( + "authorized_audio_notice".to_string(), + "Only download audio you are authorized to save locally.".to_string(), + ); + en.insert("youtube_url".to_string(), "YouTube URL".to_string()); + en.insert("save_to".to_string(), "Save to".to_string()); + en.insert( + "choose_download_folder".to_string(), + "Choose download folder".to_string(), + ); + en.insert("download".to_string(), "Download".to_string()); + en.insert("clear".to_string(), "Clear".to_string()); // Playlist tabs component en.insert("rename".to_string(), "Rename".to_string()); @@ -203,6 +219,22 @@ pub fn init() { "全部添加到播放列表".to_string(), ); zh.insert("remove_from_library".to_string(), "从库中移除".to_string()); + zh.insert( + "download_authorized_audio".to_string(), + "下载已授权音频".to_string(), + ); + zh.insert( + "authorized_audio_notice".to_string(), + "请只下载你有权保存到本地的音频。".to_string(), + ); + zh.insert("youtube_url".to_string(), "YouTube URL".to_string()); + zh.insert("save_to".to_string(), "保存到".to_string()); + zh.insert( + "choose_download_folder".to_string(), + "选择下载文件夹".to_string(), + ); + zh.insert("download".to_string(), "下载".to_string()); + zh.insert("clear".to_string(), "清空".to_string()); // Playlist tabs component zh.insert("rename".to_string(), "重命名".to_string()); diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index a7b6f1c..b295a0b 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -1,5 +1,7 @@ +use crate::app::lib_services::YoutubeDownloadService; use crate::app::libstate::lyrics_state::LyricsFetchState; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::time::Instant; /// UI-specific state that doesn't need to be persisted @@ -41,6 +43,21 @@ pub struct UiState { /// Whether library import is currently running pub is_importing: bool, + /// Whether the authorized audio download window is open. + pub show_youtube_download_dialog: bool, + + /// URL entered in the authorized audio download form. + pub youtube_download_url: String, + + /// Destination folder for authorized audio downloads. + pub youtube_download_dir: PathBuf, + + /// Whether a yt-dlp download task is currently running. + pub youtube_download_in_progress: bool, + + /// Last user-facing download status or error message. + pub youtube_download_status: Option, + /// Volume value to restore when the user un-mutes via the speaker icon. /// `None` while not muted. Not serialized: a fresh launch always starts /// with whatever volume the player itself remembers. @@ -75,6 +92,11 @@ impl Default for UiState { lyrics_fetch_state: LyricsFetchState::Idle, last_window_title: None, is_importing: false, + show_youtube_download_dialog: false, + youtube_download_url: String::new(), + youtube_download_dir: YoutubeDownloadService::default_download_dir(), + youtube_download_in_progress: false, + youtube_download_status: None, volume_before_mute: None, last_persistence_save: Instant::now(), desktop_lyrics_font_size: 48.0, diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index 45db180..1e1cf19 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -26,6 +26,8 @@ pub const VOLUME_MUTE: &str = p::SPEAKER_X; pub const LYRICS_TOGGLE: &str = p::MICROPHONE_STAGE; pub const SEARCH: &str = p::MAGNIFYING_GLASS; pub const CLOSE: &str = p::X; +pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE; +pub const FOLDER: &str = p::FOLDER_OPEN; /// Generic "add / new" affordance — used by library/playlist "+" buttons. pub const PLUS: &str = p::PLUS; /// Indicator shown in the playlist's lyrics column when a track has lyrics. diff --git a/src/app/ui.rs b/src/app/ui.rs index baa28cf..cf1087c 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -158,6 +158,17 @@ impl App { self.process_library_command(lib_cmd); } } + + fn refresh_youtube_download_processor(&mut self) { + let mut events = Vec::new(); + while let Ok(event) = self.youtube_download_rx().try_recv() { + events.push(event); + } + + for event in events { + self.handle_youtube_download_event(event); + } + } } impl eframe::App for App { @@ -176,9 +187,10 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } self.refresh_library_command_processor(); + self.refresh_youtube_download_processor(); self.pump_audio_events(); - if self.ui_state.is_importing { + if self.ui_state.is_importing || self.ui_state.youtube_download_in_progress { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } self.refresh_lyrics_display(); diff --git a/src/lib/services/mod.rs b/src/lib/services/mod.rs index 61961ce..33cbbb8 100644 --- a/src/lib/services/mod.rs +++ b/src/lib/services/mod.rs @@ -6,9 +6,11 @@ pub mod library_import; pub mod lyrics_manager; pub mod metadata_editor; pub mod player_restore; +pub mod youtube_download; // Re-export commonly used types pub use library_import::LibraryImportService; pub use lyrics_manager::LyricsManager; pub use metadata_editor::MetadataEditor; pub use player_restore::PlayerRestoreService; +pub use youtube_download::{YoutubeDownloadEvent, YoutubeDownloadResult, YoutubeDownloadService}; diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs new file mode 100644 index 0000000..d7bf3c4 --- /dev/null +++ b/src/lib/services/youtube_download.rs @@ -0,0 +1,97 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::Sender; + +#[derive(Debug, Clone)] +pub enum YoutubeDownloadEvent { + Finished(Result), +} + +#[derive(Debug, Clone)] +pub struct YoutubeDownloadResult { + pub output_dir: PathBuf, + pub downloaded_files: Vec, +} + +pub struct YoutubeDownloadService; + +impl YoutubeDownloadService { + pub fn default_download_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join("Music") + .join("Bird Player Downloads") + } + + pub fn download_authorized_audio( + url: String, + output_dir: PathBuf, + event_tx: Sender, + ) { + std::thread::spawn(move || { + let result = Self::run_download(&url, &output_dir); + let _ = event_tx.send(YoutubeDownloadEvent::Finished(result)); + }); + } + + fn run_download(url: &str, output_dir: &Path) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("URL is required".to_string()); + } + + if let Err(err) = std::fs::create_dir_all(output_dir) { + return Err(format!("Failed to create output folder: {}", err)); + } + + if Command::new("yt-dlp").arg("--version").output().is_err() { + return Err( + "yt-dlp was not found. Install yt-dlp and make sure it is available in PATH." + .to_string(), + ); + } + + let output_template = output_dir + .join("%(artist,creator,uploader|Unknown Artist)s - %(title)s [%(id)s].%(ext)s"); + + let output = Command::new("yt-dlp") + .arg("--extract-audio") + .arg("--audio-format") + .arg("mp3") + .arg("--audio-quality") + .arg("0") + .arg("--embed-metadata") + .arg("--embed-thumbnail") + .arg("--restrict-filenames") + .arg("--print") + .arg("after_move:filepath") + .arg("--output") + .arg(output_template) + .arg(url) + .output() + .map_err(|err| format!("Failed to start yt-dlp: {}", err))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("yt-dlp failed with status {}", output.status) + } else { + stderr + }); + } + + let downloaded_files = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .filter(|path| path.exists()) + .collect::>(); + + Ok(YoutubeDownloadResult { + output_dir: output_dir.to_path_buf(), + downloaded_files, + }) + } +} From bb2eb866ca74ce05a1504592ef65395331bafb8c Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 22 Jun 2026 17:10:36 +0800 Subject: [PATCH 2/2] fix: address download review comments --- src/app/core.rs | 25 +++++++++++-------------- src/app/i18n.rs | 20 ++++++++++++++++++++ src/lib/services/youtube_download.rs | 2 ++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/app/core.rs b/src/app/core.rs index 465335c..8285afe 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -297,12 +297,12 @@ impl App { let url = self.ui_state.youtube_download_url.trim().to_string(); if url.is_empty() { - self.ui_state.youtube_download_status = Some("URL is required".to_string()); + self.ui_state.youtube_download_status = Some(i18n::t("url_required")); return; } self.ui_state.youtube_download_in_progress = true; - self.ui_state.youtube_download_status = Some("Downloading...".to_string()); + self.ui_state.youtube_download_status = Some(i18n::t("downloading")); YoutubeDownloadService::download_authorized_audio( url, @@ -318,21 +318,18 @@ impl App { YoutubeDownloadEvent::Finished(Ok(result)) => { let file_count = result.downloaded_files.len(); self.ui_state.youtube_download_status = Some(if file_count == 0 { - "Download finished. Re-syncing folder...".to_string() + i18n::t("download_finished_resync") } else { - format!("Downloaded {} file(s). Re-syncing folder...", file_count) + i18n::tf("downloaded_files_resync", &[&file_count.to_string()]) }); - let path_exists = !self.library.add_path(result.output_dir.clone()); - let path_to_import = if path_exists { - self.library - .paths() - .iter() - .find(|p| *p.path() == result.output_dir) - .cloned() - } else { - self.library.paths().last().cloned() - }; + self.library.add_path(result.output_dir.clone()); + let path_to_import = self + .library + .paths() + .iter() + .find(|p| *p.path() == result.output_dir) + .cloned(); if let Some(path) = path_to_import { self.library.set_path_to_not_imported(path.id()); diff --git a/src/app/i18n.rs b/src/app/i18n.rs index b02b457..193b529 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -126,6 +126,16 @@ pub fn init() { ); en.insert("download".to_string(), "Download".to_string()); en.insert("clear".to_string(), "Clear".to_string()); + en.insert("url_required".to_string(), "URL is required".to_string()); + en.insert("downloading".to_string(), "Downloading...".to_string()); + en.insert( + "download_finished_resync".to_string(), + "Download finished. Re-syncing folder...".to_string(), + ); + en.insert( + "downloaded_files_resync".to_string(), + "Downloaded {} file(s). Re-syncing folder...".to_string(), + ); // Playlist tabs component en.insert("rename".to_string(), "Rename".to_string()); @@ -235,6 +245,16 @@ pub fn init() { ); zh.insert("download".to_string(), "下载".to_string()); zh.insert("clear".to_string(), "清空".to_string()); + zh.insert("url_required".to_string(), "请输入 URL".to_string()); + zh.insert("downloading".to_string(), "正在下载...".to_string()); + zh.insert( + "download_finished_resync".to_string(), + "下载完成,正在重新同步文件夹...".to_string(), + ); + zh.insert( + "downloaded_files_resync".to_string(), + "已下载 {} 个文件,正在重新同步文件夹...".to_string(), + ); // Playlist tabs component zh.insert("rename".to_string(), "重命名".to_string()); diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index d7bf3c4..a134a48 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -18,6 +18,7 @@ pub struct YoutubeDownloadService; impl YoutubeDownloadService { pub fn default_download_dir() -> PathBuf { std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join("Music") @@ -68,6 +69,7 @@ impl YoutubeDownloadService { .arg("after_move:filepath") .arg("--output") .arg(output_template) + .arg("--") .arg(url) .output() .map_err(|err| format!("Failed to start yt-dlp: {}", err))?;