Formatting
This commit is contained in:
@@ -80,7 +80,9 @@ impl std::str::FromStr for AudioFormat {
|
||||
"mp3" => Ok(AudioFormat::Mp3),
|
||||
"flac" => Ok(AudioFormat::Flac),
|
||||
"best" => Ok(AudioFormat::Best),
|
||||
_ => Err(format!("unsupported format: {s} (expected opus, mp3, flac, or best)")),
|
||||
_ => Err(format!(
|
||||
"unsupported format: {s} (expected opus, mp3, flac, or best)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +100,10 @@ pub trait DownloadBackend: Send + Sync {
|
||||
fn check_available(&self) -> impl std::future::Future<Output = DlResult<()>> + Send;
|
||||
|
||||
/// Search for tracks matching a query.
|
||||
fn search(&self, query: &str) -> impl std::future::Future<Output = DlResult<Vec<SearchResult>>> + Send;
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
) -> impl std::future::Future<Output = DlResult<Vec<SearchResult>>> + Send;
|
||||
|
||||
/// Download a target to the configured output directory.
|
||||
fn download(
|
||||
|
||||
@@ -35,9 +35,7 @@ impl DlError {
|
||||
pub fn is_transient(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DlError::RateLimited(_)
|
||||
| DlError::Io(_)
|
||||
| DlError::BackendError(_)
|
||||
DlError::RateLimited(_) | DlError::Io(_) | DlError::BackendError(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ pub mod queue;
|
||||
pub mod rate_limit;
|
||||
pub mod ytdlp;
|
||||
|
||||
pub use backend::{AudioFormat, BackendConfig, DownloadBackend, DownloadResult, DownloadTarget, SearchResult};
|
||||
pub use backend::{
|
||||
AudioFormat, BackendConfig, DownloadBackend, DownloadResult, DownloadTarget, SearchResult,
|
||||
};
|
||||
pub use error::{DlError, DlResult};
|
||||
pub use queue::{DlStats, ProgressFn, SyncStats, download_single, run_queue, run_queue_with_progress, sync_wanted_to_queue};
|
||||
pub use queue::{
|
||||
DlStats, ProgressFn, SyncStats, download_single, run_queue, run_queue_with_progress,
|
||||
sync_wanted_to_queue,
|
||||
};
|
||||
pub use ytdlp::{SearchSource, YtDlpBackend};
|
||||
|
||||
24
src/main.rs
24
src/main.rs
@@ -156,9 +156,7 @@ fn make_backend_config(
|
||||
output: &Option<PathBuf>,
|
||||
cookies: &Option<PathBuf>,
|
||||
) -> anyhow::Result<BackendConfig> {
|
||||
let fmt: AudioFormat = format
|
||||
.parse()
|
||||
.map_err(|e: String| anyhow::anyhow!(e))?;
|
||||
let fmt: AudioFormat = format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
|
||||
Ok(BackendConfig {
|
||||
output_dir: output.clone().unwrap_or_else(default_output_dir),
|
||||
format: fmt,
|
||||
@@ -199,13 +197,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
let config = make_backend_config(&format, &output, &cookies)?;
|
||||
|
||||
// Determine if it's a URL or a search query
|
||||
let target = if query_or_url.starts_with("http://")
|
||||
|| query_or_url.starts_with("https://")
|
||||
{
|
||||
DownloadTarget::Url(query_or_url)
|
||||
} else {
|
||||
DownloadTarget::Query(query_or_url)
|
||||
};
|
||||
let target =
|
||||
if query_or_url.starts_with("http://") || query_or_url.starts_with("https://") {
|
||||
DownloadTarget::Url(query_or_url)
|
||||
} else {
|
||||
DownloadTarget::Query(query_or_url)
|
||||
};
|
||||
|
||||
download_single(&backend, target, &config, dry_run).await?;
|
||||
}
|
||||
@@ -235,7 +232,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("\nQueue processing complete: {stats}");
|
||||
}
|
||||
QueueAction::Add { query } => {
|
||||
let item = queries::downloads::enqueue(db.conn(), &query, None, "ytdlp").await?;
|
||||
let item =
|
||||
queries::downloads::enqueue(db.conn(), &query, None, "ytdlp").await?;
|
||||
println!("Added to queue: id={}, query=\"{}\"", item.id, item.query);
|
||||
}
|
||||
QueueAction::List { status } => {
|
||||
@@ -254,8 +252,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("Queue is empty.");
|
||||
} else {
|
||||
println!(
|
||||
"{:<5} {:<12} {:<6} {:<40} {}",
|
||||
"ID", "STATUS", "RETRY", "QUERY", "ERROR"
|
||||
"{:<5} {:<12} {:<6} {:<40} ERROR",
|
||||
"ID", "STATUS", "RETRY", "QUERY"
|
||||
);
|
||||
for item in &items {
|
||||
println!(
|
||||
|
||||
92
src/queue.rs
92
src/queue.rs
@@ -81,7 +81,11 @@ pub async fn run_queue_with_progress(
|
||||
stats.downloads_attempted += 1;
|
||||
|
||||
if let Some(ref cb) = on_progress {
|
||||
cb(stats.downloads_attempted, total, &format!("Downloading: {}", item.query));
|
||||
cb(
|
||||
stats.downloads_attempted,
|
||||
total,
|
||||
&format!("Downloading: {}", item.query),
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -106,8 +110,7 @@ pub async fn run_queue_with_progress(
|
||||
}
|
||||
|
||||
// Mark as downloading
|
||||
queries::downloads::update_status(conn, item.id, DownloadStatus::Downloading, None)
|
||||
.await?;
|
||||
queries::downloads::update_status(conn, item.id, DownloadStatus::Downloading, None).await?;
|
||||
|
||||
// Determine download target
|
||||
let target = if let Some(ref url) = item.source_url {
|
||||
@@ -126,53 +129,45 @@ pub async fn run_queue_with_progress(
|
||||
"download completed"
|
||||
);
|
||||
|
||||
queries::downloads::update_status(
|
||||
conn,
|
||||
item.id,
|
||||
DownloadStatus::Completed,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
queries::downloads::update_status(conn, item.id, DownloadStatus::Completed, None)
|
||||
.await?;
|
||||
|
||||
// Update wanted item status and create track record with MBID
|
||||
if let Some(wanted_id) = item.wanted_item_id {
|
||||
if let Ok(wanted) = queries::wanted::get_by_id(conn, wanted_id).await {
|
||||
// Create a track record with the MBID so the tagger
|
||||
// can skip searching and go straight to the right recording
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let file_path = result.file_path.to_string_lossy().to_string();
|
||||
let file_size = std::fs::metadata(&result.file_path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
if let Some(wanted_id) = item.wanted_item_id
|
||||
&& let Ok(wanted) = queries::wanted::get_by_id(conn, wanted_id).await
|
||||
{
|
||||
// Create a track record with the MBID so the tagger
|
||||
// can skip searching and go straight to the right recording
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let file_path = result.file_path.to_string_lossy().to_string();
|
||||
let file_size = std::fs::metadata(&result.file_path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let track_active = shanty_db::entities::track::ActiveModel {
|
||||
file_path: Set(file_path),
|
||||
title: Set(Some(result.title.clone())),
|
||||
artist: Set(result.artist.clone()),
|
||||
file_size: Set(file_size),
|
||||
musicbrainz_id: Set(wanted.musicbrainz_id.clone()),
|
||||
artist_id: Set(wanted.artist_id),
|
||||
added_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = queries::tracks::upsert(conn, track_active).await {
|
||||
tracing::warn!(error = %e, "failed to create track record after download");
|
||||
}
|
||||
let track_active = shanty_db::entities::track::ActiveModel {
|
||||
file_path: Set(file_path),
|
||||
title: Set(Some(result.title.clone())),
|
||||
artist: Set(result.artist.clone()),
|
||||
file_size: Set(file_size),
|
||||
musicbrainz_id: Set(wanted.musicbrainz_id.clone()),
|
||||
artist_id: Set(wanted.artist_id),
|
||||
added_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = queries::tracks::upsert(conn, track_active).await {
|
||||
tracing::warn!(error = %e, "failed to create track record after download");
|
||||
}
|
||||
|
||||
if let Err(e) = queries::wanted::update_status(
|
||||
conn,
|
||||
wanted_id,
|
||||
WantedStatus::Downloaded,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted_id = wanted_id,
|
||||
error = %e,
|
||||
"failed to update wanted item status"
|
||||
);
|
||||
}
|
||||
if let Err(e) =
|
||||
queries::wanted::update_status(conn, wanted_id, WantedStatus::Downloaded)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted_id = wanted_id,
|
||||
error = %e,
|
||||
"failed to update wanted item status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,10 +239,7 @@ impl fmt::Display for SyncStats {
|
||||
/// Sync wanted items to the download queue.
|
||||
/// Finds all Track-type Wanted items and enqueues them for download,
|
||||
/// skipping any that already have a queue entry.
|
||||
pub async fn sync_wanted_to_queue(
|
||||
conn: &DatabaseConnection,
|
||||
dry_run: bool,
|
||||
) -> DlResult<SyncStats> {
|
||||
pub async fn sync_wanted_to_queue(conn: &DatabaseConnection, dry_run: bool) -> DlResult<SyncStats> {
|
||||
let wanted = queries::wanted::list(conn, Some(WantedStatus::Wanted)).await?;
|
||||
let mut stats = SyncStats::default();
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ impl RateLimiter {
|
||||
state.remaining -= 1;
|
||||
|
||||
// Warn when approaching the limit
|
||||
let pct_remaining =
|
||||
(state.remaining as f64 / self.max_per_hour as f64) * 100.0;
|
||||
let pct_remaining = (state.remaining as f64 / self.max_per_hour as f64) * 100.0;
|
||||
if pct_remaining < 10.0 && pct_remaining > 0.0 {
|
||||
tracing::warn!(
|
||||
remaining = state.remaining,
|
||||
|
||||
36
src/ytdlp.rs
36
src/ytdlp.rs
@@ -4,7 +4,9 @@ use std::process::Stdio;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::backend::{BackendConfig, DownloadBackend, DownloadResult, DownloadTarget, SearchResult};
|
||||
use crate::backend::{
|
||||
BackendConfig, DownloadBackend, DownloadResult, DownloadTarget, SearchResult,
|
||||
};
|
||||
use crate::error::{DlError, DlResult};
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
@@ -22,7 +24,9 @@ impl std::str::FromStr for SearchSource {
|
||||
match s.to_lowercase().as_str() {
|
||||
"ytmusic" | "youtube_music" | "youtubemusic" => Ok(SearchSource::YouTubeMusic),
|
||||
"youtube" | "yt" => Ok(SearchSource::YouTube),
|
||||
_ => Err(format!("unknown search source: {s} (expected ytmusic or youtube)")),
|
||||
_ => Err(format!(
|
||||
"unknown search source: {s} (expected ytmusic or youtube)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,9 +67,7 @@ impl YtDlpBackend {
|
||||
self.rate_limiter.acquire().await;
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
// Add cookies if configured
|
||||
if let Some(ref cookies) = self.cookies_path {
|
||||
@@ -120,7 +122,9 @@ impl YtDlpBackend {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
tracing::warn!(stderr = %stderr, "ytmusic search failed");
|
||||
return Err(DlError::BackendError(format!("ytmusic search failed: {stderr}")));
|
||||
return Err(DlError::BackendError(format!(
|
||||
"ytmusic search failed: {stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -180,17 +184,17 @@ impl YtDlpBackend {
|
||||
fn find_ytmusic_script(&self) -> DlResult<PathBuf> {
|
||||
// Check next to the current executable
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let beside_exe = exe.parent().unwrap_or(std::path::Path::new(".")).join("ytmusic_search.py");
|
||||
let beside_exe = exe
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("ytmusic_search.py");
|
||||
if beside_exe.exists() {
|
||||
return Ok(beside_exe);
|
||||
}
|
||||
}
|
||||
|
||||
// Check common install locations
|
||||
for dir in &[
|
||||
"/usr/share/shanty",
|
||||
"/usr/local/share/shanty",
|
||||
] {
|
||||
for dir in &["/usr/share/shanty", "/usr/local/share/shanty"] {
|
||||
let path = PathBuf::from(dir).join("ytmusic_search.py");
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
@@ -303,10 +307,7 @@ impl DownloadBackend for YtDlpBackend {
|
||||
|
||||
// Add cookies from backend config or backend's own cookies
|
||||
let cookies_str;
|
||||
let cookies_path = config
|
||||
.cookies_path
|
||||
.as_ref()
|
||||
.or(self.cookies_path.as_ref());
|
||||
let cookies_path = config.cookies_path.as_ref().or(self.cookies_path.as_ref());
|
||||
if let Some(c) = cookies_path {
|
||||
cookies_str = c.to_string_lossy().to_string();
|
||||
args.push("--cookies");
|
||||
@@ -318,9 +319,8 @@ impl DownloadBackend for YtDlpBackend {
|
||||
let output = self.run_ytdlp(&args).await?;
|
||||
|
||||
// Parse the JSON output to get the actual file path
|
||||
let info: YtDlpDownloadInfo = serde_json::from_str(output.trim()).map_err(|e| {
|
||||
DlError::BackendError(format!("failed to parse yt-dlp output: {e}"))
|
||||
})?;
|
||||
let info: YtDlpDownloadInfo = serde_json::from_str(output.trim())
|
||||
.map_err(|e| DlError::BackendError(format!("failed to parse yt-dlp output: {e}")))?;
|
||||
|
||||
// --print-json reports the pre-extraction filename (e.g. .webm),
|
||||
// but --extract-audio produces a file with the target format extension.
|
||||
|
||||
Reference in New Issue
Block a user