Fixes for ytmusic search not working without ytmusicapi
This commit is contained in:
105
scripts/ytmusic_search.py
Normal file
105
scripts/ytmusic_search.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""YouTube Music search helper for shanty-dl.
|
||||||
|
|
||||||
|
Requires: pip install ytmusicapi
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ytmusic_search.py search <query> # general search
|
||||||
|
ytmusic_search.py artist <artist> # bulk search by artist (up to 100 songs)
|
||||||
|
ytmusic_search.py track <artist> <title> # 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()
|
||||||
183
src/ytdlp.rs
183
src/ytdlp.rs
@@ -15,22 +15,6 @@ pub enum SearchSource {
|
|||||||
YouTube,
|
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 {
|
impl std::str::FromStr for SearchSource {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
|
|
||||||
@@ -111,13 +95,53 @@ impl YtDlpBackend {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search using a specific source prefix.
|
/// Search via the ytmusicapi Python helper script.
|
||||||
async fn search_with_source(
|
async fn search_ytmusic(&self, query: &str) -> DlResult<Vec<SearchResult>> {
|
||||||
&self,
|
let script = self.find_ytmusic_script()?;
|
||||||
query: &str,
|
|
||||||
source: SearchSource,
|
tracing::debug!(query = query, "searching YouTube Music via ytmusicapi");
|
||||||
) -> DlResult<Vec<SearchResult>> {
|
|
||||||
let search_query = format!("{}:{}", source.prefix(), query);
|
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
|
let output = self
|
||||||
.run_ytdlp(&[
|
.run_ytdlp(&[
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
@@ -139,7 +163,7 @@ impl YtDlpBackend {
|
|||||||
title: entry.title.unwrap_or_else(|| "Unknown".into()),
|
title: entry.title.unwrap_or_else(|| "Unknown".into()),
|
||||||
artist: entry.artist.or(entry.uploader).or(entry.channel),
|
artist: entry.artist.or(entry.uploader).or(entry.channel),
|
||||||
duration: entry.duration,
|
duration: entry.duration,
|
||||||
source: source.source_name().into(),
|
source: "youtube".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -150,6 +174,39 @@ impl YtDlpBackend {
|
|||||||
|
|
||||||
Ok(results)
|
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 {
|
impl DownloadBackend for YtDlpBackend {
|
||||||
@@ -161,7 +218,10 @@ impl DownloadBackend for YtDlpBackend {
|
|||||||
|
|
||||||
async fn search(&self, query: &str) -> DlResult<Vec<SearchResult>> {
|
async fn search(&self, query: &str) -> DlResult<Vec<SearchResult>> {
|
||||||
// Try primary search source
|
// 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 {
|
match results {
|
||||||
Ok(ref r) if !r.is_empty() => return results,
|
Ok(ref r) if !r.is_empty() => return results,
|
||||||
@@ -173,19 +233,26 @@ impl DownloadBackend for YtDlpBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to the other source
|
// Fallback: if ytmusic failed/empty, try ytsearch; and vice versa
|
||||||
if self.fallback_search && self.search_source == SearchSource::YouTubeMusic {
|
if self.fallback_search {
|
||||||
tracing::info!("falling back to YouTube search");
|
let fallback_results = match self.search_source {
|
||||||
let fallback = self
|
SearchSource::YouTubeMusic => {
|
||||||
.search_with_source(query, SearchSource::YouTube)
|
tracing::info!("falling back to YouTube search");
|
||||||
.await?;
|
self.search_ytsearch(query).await
|
||||||
if !fallback.is_empty() {
|
}
|
||||||
return Ok(fallback);
|
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
|
// Return the original result (Ok([]) or Err)
|
||||||
// If primary returned Err, propagate it
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,15 +322,17 @@ impl DownloadBackend for YtDlpBackend {
|
|||||||
DlError::BackendError(format!("failed to parse yt-dlp output: {e}"))
|
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 {
|
let file_path = if let Some(ref requested) = info.requested_downloads {
|
||||||
if let Some(first) = requested.first() {
|
if let Some(first) = requested.first() {
|
||||||
PathBuf::from(&first.filepath)
|
PathBuf::from(&first.filepath)
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(&info.filename)
|
fix_extension(&info.filename, config.format)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(&info.filename)
|
fix_extension(&info.filename, config.format)
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(
|
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 ---
|
// --- yt-dlp JSON output types ---
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -339,9 +436,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_search_source_prefix() {
|
fn test_parse_ytmusic_entry() {
|
||||||
assert_eq!(SearchSource::YouTubeMusic.prefix(), "ytmusicsearch5");
|
let json = r#"[{"videoId": "abc123", "title": "Time", "artist": "Pink Floyd", "album": "DSOTM", "duration_seconds": 413.0}]"#;
|
||||||
assert_eq!(SearchSource::YouTube.prefix(), "ytsearch5");
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user