Fixes for ytmusic search not working without ytmusicapi

This commit is contained in:
Connor Johnstone
2026-03-17 18:23:04 -04:00
parent d5641493b9
commit fed3a070fc
2 changed files with 247 additions and 41 deletions

View File

@@ -15,22 +15,6 @@ pub enum SearchSource {
YouTube,
}
impl SearchSource {
fn prefix(&self) -> &str {
match self {
SearchSource::YouTubeMusic => "ytmusicsearch5",
SearchSource::YouTube => "ytsearch5",
}
}
fn source_name(&self) -> &str {
match self {
SearchSource::YouTubeMusic => "youtube_music",
SearchSource::YouTube => "youtube",
}
}
}
impl std::str::FromStr for SearchSource {
type Err = String;
@@ -111,13 +95,53 @@ impl YtDlpBackend {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Search using a specific source prefix.
async fn search_with_source(
&self,
query: &str,
source: SearchSource,
) -> DlResult<Vec<SearchResult>> {
let search_query = format!("{}:{}", source.prefix(), query);
/// Search via the ytmusicapi Python helper script.
async fn search_ytmusic(&self, query: &str) -> DlResult<Vec<SearchResult>> {
let script = self.find_ytmusic_script()?;
tracing::debug!(query = query, "searching YouTube Music via ytmusicapi");
let output = Command::new("python3")
.args([script.to_str().unwrap(), "search", query])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
DlError::BackendNotFound(
"python3 not found — required for YouTube Music search".into(),
)
} else {
DlError::Io(e)
}
})?;
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}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let entries: Vec<YtMusicSearchEntry> = serde_json::from_str(stdout.trim())
.map_err(|e| DlError::BackendError(format!("failed to parse ytmusic output: {e}")))?;
Ok(entries
.into_iter()
.map(|e| SearchResult {
url: format!("https://music.youtube.com/watch?v={}", e.video_id),
title: e.title,
artist: e.artist,
duration: e.duration_seconds,
source: "youtube_music".into(),
})
.collect())
}
/// Search using yt-dlp's ytsearch prefix.
async fn search_ytsearch(&self, query: &str) -> DlResult<Vec<SearchResult>> {
let search_query = format!("ytsearch5:{query}");
let output = self
.run_ytdlp(&[
"--dump-json",
@@ -139,7 +163,7 @@ impl YtDlpBackend {
title: entry.title.unwrap_or_else(|| "Unknown".into()),
artist: entry.artist.or(entry.uploader).or(entry.channel),
duration: entry.duration,
source: source.source_name().into(),
source: "youtube".into(),
});
}
Err(e) => {
@@ -150,6 +174,39 @@ impl YtDlpBackend {
Ok(results)
}
/// Locate the ytmusic_search.py helper script.
/// Checks: next to the binary, in the crate's scripts/ dir, or on PATH.
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");
if beside_exe.exists() {
return Ok(beside_exe);
}
}
// Check common install locations
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);
}
}
// Check relative to crate root (for development)
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/ytmusic_search.py");
if dev_path.exists() {
return Ok(dev_path);
}
Err(DlError::BackendNotFound(
"ytmusic_search.py not found — install it next to the shanty-dl binary or in /usr/share/shanty/".into(),
))
}
}
impl DownloadBackend for YtDlpBackend {
@@ -161,7 +218,10 @@ impl DownloadBackend for YtDlpBackend {
async fn search(&self, query: &str) -> DlResult<Vec<SearchResult>> {
// Try primary search source
let results = self.search_with_source(query, self.search_source).await;
let results = match self.search_source {
SearchSource::YouTubeMusic => self.search_ytmusic(query).await,
SearchSource::YouTube => self.search_ytsearch(query).await,
};
match results {
Ok(ref r) if !r.is_empty() => return results,
@@ -173,19 +233,26 @@ impl DownloadBackend for YtDlpBackend {
}
}
// Fallback to the other source
if self.fallback_search && self.search_source == SearchSource::YouTubeMusic {
tracing::info!("falling back to YouTube search");
let fallback = self
.search_with_source(query, SearchSource::YouTube)
.await?;
if !fallback.is_empty() {
return Ok(fallback);
// Fallback: if ytmusic failed/empty, try ytsearch; and vice versa
if self.fallback_search {
let fallback_results = match self.search_source {
SearchSource::YouTubeMusic => {
tracing::info!("falling back to YouTube search");
self.search_ytsearch(query).await
}
SearchSource::YouTube => {
tracing::info!("falling back to YouTube Music search");
self.search_ytmusic(query).await
}
};
match fallback_results {
Ok(ref r) if !r.is_empty() => return fallback_results,
Ok(_) => tracing::debug!("fallback search also returned no results"),
Err(ref e) => tracing::debug!(error = %e, "fallback search also failed"),
}
}
// If primary returned Ok([]) and fallback also empty, return that
// If primary returned Err, propagate it
// Return the original result (Ok([]) or Err)
results
}
@@ -255,15 +322,17 @@ impl DownloadBackend for YtDlpBackend {
DlError::BackendError(format!("failed to parse yt-dlp output: {e}"))
})?;
// yt-dlp may change the extension after conversion
// --print-json reports the pre-extraction filename (e.g. .webm),
// but --extract-audio produces a file with the target format extension.
// Use requested_downloads if available, otherwise fix the extension.
let file_path = if let Some(ref requested) = info.requested_downloads {
if let Some(first) = requested.first() {
PathBuf::from(&first.filepath)
} else {
PathBuf::from(&info.filename)
fix_extension(&info.filename, config.format)
}
} else {
PathBuf::from(&info.filename)
fix_extension(&info.filename, config.format)
};
tracing::info!(
@@ -282,6 +351,34 @@ impl DownloadBackend for YtDlpBackend {
}
}
/// Replace the file extension with the target audio format.
/// yt-dlp's --print-json reports the pre-extraction filename (e.g. .webm),
/// but --extract-audio produces a file with the converted extension.
fn fix_extension(filename: &str, format: crate::backend::AudioFormat) -> PathBuf {
let mut path = PathBuf::from(filename);
let new_ext = match format {
crate::backend::AudioFormat::Best => return path, // keep original
crate::backend::AudioFormat::Opus => "opus",
crate::backend::AudioFormat::Mp3 => "mp3",
crate::backend::AudioFormat::Flac => "flac",
};
path.set_extension(new_ext);
path
}
// --- ytmusicapi search result types ---
#[derive(Debug, Deserialize)]
struct YtMusicSearchEntry {
#[serde(rename = "videoId")]
video_id: String,
title: String,
artist: Option<String>,
#[allow(dead_code)]
album: Option<String>,
duration_seconds: Option<f64>,
}
// --- yt-dlp JSON output types ---
#[derive(Debug, Deserialize)]
@@ -339,9 +436,13 @@ mod tests {
}
#[test]
fn test_search_source_prefix() {
assert_eq!(SearchSource::YouTubeMusic.prefix(), "ytmusicsearch5");
assert_eq!(SearchSource::YouTube.prefix(), "ytsearch5");
fn test_parse_ytmusic_entry() {
let json = r#"[{"videoId": "abc123", "title": "Time", "artist": "Pink Floyd", "album": "DSOTM", "duration_seconds": 413.0}]"#;
let entries: Vec<super::YtMusicSearchEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].video_id, "abc123");
assert_eq!(entries[0].title, "Time");
assert_eq!(entries[0].artist.as_deref(), Some("Pink Floyd"));
}
#[test]