From fed3a070fc17342fb496ff9d67f857123802f94b Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 17 Mar 2026 18:23:04 -0400 Subject: [PATCH] Fixes for ytmusic search not working without ytmusicapi --- scripts/ytmusic_search.py | 105 ++++++++++++++++++++++ src/ytdlp.rs | 183 +++++++++++++++++++++++++++++--------- 2 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 scripts/ytmusic_search.py diff --git a/scripts/ytmusic_search.py b/scripts/ytmusic_search.py new file mode 100644 index 0000000..391f536 --- /dev/null +++ b/scripts/ytmusic_search.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""YouTube Music search helper for shanty-dl. + +Requires: pip install ytmusicapi + +Usage: + ytmusic_search.py search # general search + ytmusic_search.py artist # bulk search by artist (up to 100 songs) + ytmusic_search.py track # search for a specific track + +Output: JSON array of results to stdout, each with: + - videoId: YouTube video ID + - title: track title + - artist: primary artist name + - album: album name (if available) + - duration_seconds: duration in seconds (if available) +""" + +import json +import sys + +from ytmusicapi import YTMusic + + +def search_general(yt: YTMusic, query: str) -> list[dict]: + """Search YouTube Music for songs matching a general query.""" + try: + results = yt.search(query, filter="songs", limit=5) + except Exception as e: + print(f"Search error: {e}", file=sys.stderr) + return [] + return _format_results(results) + + +def search_artist_bulk(yt: YTMusic, artist: str) -> list[dict]: + """Fetch up to 100 songs by an artist.""" + try: + results = yt.search(artist, filter="songs", limit=100) + except Exception as e: + print(f"Search error: {e}", file=sys.stderr) + return [] + return _format_results(results) + + +def search_track(yt: YTMusic, artist: str, title: str) -> list[dict]: + """Search for a specific track by artist and title.""" + try: + results = yt.search(f"{artist} {title}", filter="songs", limit=5) + except Exception as e: + print(f"Search error: {e}", file=sys.stderr) + return [] + return _format_results(results) + + +def _format_results(results: list[dict]) -> list[dict]: + """Convert ytmusicapi results to a simple JSON-friendly format.""" + formatted = [] + for r in results: + if not r.get("videoId"): + continue + artists = r.get("artists", []) + artist_name = artists[0]["name"] if artists else None + album = r.get("album") + album_name = album["name"] if isinstance(album, dict) else None + formatted.append({ + "videoId": r["videoId"], + "title": r.get("title", ""), + "artist": artist_name, + "album": album_name, + "duration_seconds": r.get("duration_seconds"), + }) + return formatted + + +def main(): + if len(sys.argv) < 3: + print("Usage: ytmusic_search.py <search|artist|track> <args...>", file=sys.stderr) + sys.exit(1) + + yt = YTMusic() + command = sys.argv[1] + + if command == "search": + query = " ".join(sys.argv[2:]) + results = search_general(yt, query) + elif command == "artist": + artist = " ".join(sys.argv[2:]) + results = search_artist_bulk(yt, artist) + elif command == "track": + if len(sys.argv) < 4: + print("Usage: ytmusic_search.py track <artist> <title>", file=sys.stderr) + sys.exit(1) + # Artist is argv[2], title is the rest + artist = sys.argv[2] + title = " ".join(sys.argv[3:]) + results = search_track(yt, artist, title) + else: + print(f"Unknown command: {command}", file=sys.stderr) + sys.exit(1) + + json.dump(results, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/src/ytdlp.rs b/src/ytdlp.rs index bcbd839..7b689a6 100644 --- a/src/ytdlp.rs +++ b/src/ytdlp.rs @@ -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]