use std::{
io::Write,
process::{Command, Stdio},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{run_command_with_timeout, ytdlp};
use crate::{
providers::{
ArtistAlbumCard, ArtistHeader, CardData, ProviderId, RelatedArtistCard, SearchScope,
SearchTab,
},
types::Track,
};
#[derive(Debug, Clone, serde::Deserialize)]
pub struct YouTubeVideo {
pub id: String,
pub title: String,
pub url: String,
#[serde(default, deserialize_with = "deserialize_flexible_duration")]
pub duration: u32,
#[serde(default)]
pub channel: String,
#[serde(default)]
pub thumbnail: String,
#[serde(default)]
pub album: Option<crate::types::TrackAlbum>,
#[serde(default)]
pub artist_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_view_count")]
pub views: u64,
}
impl From<YouTubeVideo> for Track {
fn from(v: YouTubeVideo) -> Self {
let mut track = Track::from_provider(
ProviderId::YouTube,
v.id,
v.url,
v.title,
v.channel,
v.duration,
v.thumbnail,
v.album,
v.artist_id,
);
if let Some(pt) = track.providers.get_mut(&ProviderId::YouTube) {
pt.play_count = v.views;
}
track
}
}
#[derive(Deserialize)]
struct YTDLPSearchResult {
id: String,
title: String,
#[serde(default, deserialize_with = "deserialize_flexible_duration")]
duration: u32,
channel: String,
#[serde(default)]
webpage_url: String,
#[serde(default)]
view_count: Option<u64>,
}
const MAX_TRACK_SECS: f64 = 7.0 * 24.0 * 3600.0;
fn deserialize_view_count<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde_json::Value;
Ok(match Value::deserialize(deserializer)? {
Value::Number(n) => n.as_u64().unwrap_or(0),
Value::String(s) => parse_abbreviated_count(&s),
_ => 0,
})
}
fn deserialize_flexible_duration<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde_json::Value;
let secs = match Value::deserialize(deserializer)? {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(s) => s
.split(':')
.try_fold(0.0_f64, |acc, part| {
part.trim().parse::<f64>().map(|p| acc * 60.0 + p)
})
.unwrap_or(0.0),
_ => 0.0,
};
let secs = if secs.is_finite() && (0.0..MAX_TRACK_SECS).contains(&secs) {
secs
} else {
0.0
};
Ok(secs as u32)
}
fn parse_abbreviated_count(s: &str) -> u64 {
let s = s
.trim()
.trim_end_matches("plays")
.trim_end_matches("views")
.trim()
.replace(['\u{a0}', ','], "");
let (num, mult) = match s.chars().last() {
Some('K' | 'k') => (&s[..s.len() - 1], 1_000u64),
Some('M' | 'm') => (&s[..s.len() - 1], 1_000_000),
Some('B' | 'b') => (&s[..s.len() - 1], 1_000_000_000),
_ => (s.as_str(), 1),
};
num.parse::<f64>().map_or(0, |n| (n * mult as f64) as u64)
}
const SEARCH_TIMEOUT: Duration = Duration::from_mins(1);
const PYTHON_TIMEOUT: Duration = Duration::from_secs(30);
fn run_python(mode: &str, args: &[&str]) -> Result<String> {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let script_path = std::env::temp_dir().join(format!(
"goosemusic_search_{}_{}.py",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&script_path, include_str!("../youtube_search.py"))
.context("Failed to write ytmusicapi script")?;
let result = (|| {
let py = crate::deps::python_exe().ok_or_else(|| {
anyhow::anyhow!("Python 3 not found; install it to search YouTube Music.")
})?;
let output = run_python_with(&py, &script_path, mode, args)?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if is_import_error(&stderr) {
if let Some(sys_py) = crate::deps::system_python_exe() {
if sys_py != py {
let sys_output = run_python_with(&sys_py, &script_path, mode, args)?;
if sys_output.status.success() {
return Ok(String::from_utf8_lossy(&sys_output.stdout).into_owned());
}
let sys_stderr = String::from_utf8_lossy(&sys_output.stderr);
anyhow::bail!("ytmusicapi {mode} failed: {sys_stderr}");
}
}
}
anyhow::bail!("ytmusicapi {mode} failed: {stderr}");
})();
let _ = std::fs::remove_file(&script_path);
result
}
fn run_python_with(
py: &std::path::Path,
script: &std::path::Path,
mode: &str,
args: &[&str],
) -> Result<std::process::Output> {
let mut cmd = Command::new(py);
cmd.arg(script).arg(mode).args(args);
run_command_with_timeout(&mut cmd, PYTHON_TIMEOUT)
.context("Failed to run Python. Is it installed?")
}
fn is_import_error(stderr: &str) -> bool {
stderr.contains("ModuleNotFoundError")
|| stderr.contains("ImportError")
|| stderr.contains("No module named")
}
pub fn search(query: &str, scope: SearchScope, offset: usize) -> Result<(Vec<Track>, SearchTab)> {
if offset == 0 {
if let Ok(parts) = search_ytmusic(query, scope) {
return Ok(parts);
}
}
let videos = search_ytdlp(query, offset, crate::theme::SEARCH_PAGE_SIZE)?;
let tracks: Vec<Track> = videos.into_iter().map(Track::from).collect();
Ok((tracks, SearchTab::Songs))
}
#[derive(Deserialize)]
#[serde(untagged)]
enum YtBrowseOutput {
Album {
#[serde(default)]
meta: crate::providers::AlbumMeta,
#[serde(default)]
tracks: Vec<YouTubeVideo>,
},
Tracks(Vec<YouTubeVideo>),
}
pub fn browse(id: &str, kind: &str) -> Result<(Vec<Track>, Option<crate::providers::AlbumMeta>)> {
let stdout = run_python("browse", &[id, "50", kind])?;
match serde_json::from_str::<YtBrowseOutput>(&stdout)
.context("Failed to parse ytmusicapi browse output")?
{
YtBrowseOutput::Album { meta, tracks } => {
let has_meta = !meta.badge.is_empty() || !meta.date.is_empty();
let out = if has_meta { Some(meta) } else { None };
Ok((tracks.into_iter().map(Track::from).collect(), out))
}
YtBrowseOutput::Tracks(items) => Ok((items.into_iter().map(Track::from).collect(), None)),
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct YtArtistPageRaw {
header: ArtistHeader,
popular: Vec<YouTubeVideo>,
albums: Vec<ArtistAlbumCard>,
playlists: Vec<CardData>,
related: Vec<RelatedArtistCard>,
}
pub fn fetch_artist_page(
id: &str,
kinds: &[crate::providers::ArtistDataKind],
) -> Result<crate::providers::ArtistPage> {
use crate::providers::{ArtistDataKind as K, ArtistPage};
let stdout = run_python("artist_page", &[id])?;
let mut raw: YtArtistPageRaw =
serde_json::from_str(&stdout).context("Failed to parse ytmusicapi artist_page output")?;
if !K::Popular.wanted(kinds) {
raw.popular.clear();
}
if !K::Header.wanted(kinds) {
raw.header.stats.clear();
raw.header.description.clear();
raw.header.image.clear();
}
if !K::Albums.wanted(kinds) {
raw.albums.clear();
}
if !K::Playlists.wanted(kinds) {
raw.playlists.clear();
}
if !K::Related.wanted(kinds) {
raw.related.clear();
}
Ok(ArtistPage {
header: Some(raw.header),
popular: raw.popular.into_iter().map(Track::from).collect(),
albums: raw.albums,
playlists: raw.playlists,
related: raw.related,
})
}
pub fn resolve_artist_id(name: &str) -> Result<Option<String>> {
let (_, tab) = search_ytmusic(name, SearchScope::Artists)?;
Ok(match tab {
SearchTab::Artists(cards) => cards.into_iter().next().map(|c| c.id),
_ => None,
})
}
fn search_ytmusic(query: &str, scope: SearchScope) -> Result<(Vec<Track>, SearchTab)> {
let limit = 20;
let limit_str = limit.to_string();
let scope_arg = scope.youtube_filter();
let stdout = run_python("search", &[query, scope_arg, &limit_str])?;
let raw: Vec<serde_json::Value> =
serde_json::from_str(&stdout).context("Failed to parse ytmusicapi output")?;
let mut tracks: Vec<Track> = Vec::new();
let mut cards: Vec<CardData> = Vec::new();
let mut albums: Vec<CardData> = Vec::new();
let mut playlists: Vec<CardData> = Vec::new();
for v in raw {
let kind = v.get("kind").and_then(|k| k.as_str()).unwrap_or("track");
let id = v["id"].as_str().unwrap_or_default().to_string();
let title = v["title"].as_str().unwrap_or_default().to_string();
let subtitle = v["subtitle"].as_str().unwrap_or_default().to_string();
let thumbnail = v["thumbnail"].as_str().unwrap_or_default().to_string();
match kind {
"artist" => cards.push(CardData {
id,
title,
subtitle: String::new(),
thumbnail,
}),
"album" => albums.push(CardData {
id,
title,
subtitle,
thumbnail,
}),
"playlist" => playlists.push(CardData {
id,
title,
subtitle,
thumbnail,
}),
_ => {
if let Ok(video) = serde_json::from_value::<YouTubeVideo>(v) {
tracks.push(video.into());
}
}
}
}
let tab = match scope {
SearchScope::Songs => SearchTab::Songs,
SearchScope::Videos => SearchTab::Videos,
SearchScope::Artists => SearchTab::Artists(cards),
SearchScope::Albums => SearchTab::Albums(albums),
SearchScope::Playlists => SearchTab::Playlists(playlists),
};
Ok((tracks, tab))
}
fn search_ytdlp(query: &str, offset: usize, page_size: usize) -> Result<Vec<YouTubeVideo>> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
let (videos, _) = flat_search(query, offset + 1, offset + page_size)?;
Ok(videos)
}
pub fn search_more(query: &str, offset: usize) -> Result<Vec<Track>> {
let videos = search_ytdlp(query, offset, crate::theme::SEARCH_PAGE_SIZE)?;
Ok(videos.into_iter().map(Track::from).collect())
}
fn flat_search(query: &str, start: usize, end: usize) -> Result<(Vec<YouTubeVideo>, Vec<String>)> {
let start_str = start.to_string();
let end_str = end.to_string();
let search_spec = format!("ytsearch{end}:{query}");
let args: Vec<&str> = vec![
"--dump-json",
"--no-warnings",
"--skip-download",
"--playlist-start",
&start_str,
"--playlist-end",
&end_str,
&search_spec,
];
let mut cmd =
crate::deps::yt_dlp_command().context("Failed to run yt-dlp. Is it installed?")?;
cmd.args(&args);
let flat_output = run_command_with_timeout(&mut cmd, SEARCH_TIMEOUT)
.context("Failed to run yt-dlp. Is it installed?")?;
if !flat_output.status.success() {
let stderr = String::from_utf8_lossy(&flat_output.stderr);
anyhow::bail!("yt-dlp search failed: {stderr}");
}
let mut videos: Vec<YouTubeVideo> = Vec::new();
let mut valid_ids: Vec<String> = Vec::new();
for line in String::from_utf8_lossy(&flat_output.stdout).lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(item) = serde_json::from_str::<YTDLPSearchResult>(line) {
let id = item.id;
if !is_video_id(&id) {
continue;
}
valid_ids.push(id.clone());
videos.push(YouTubeVideo {
id: id.clone(),
title: item.title,
url: if item.webpage_url.is_empty() {
format!("https://youtube.com/watch?v={id}")
} else {
item.webpage_url
},
duration: item.duration,
channel: item.channel,
thumbnail: format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg"),
album: None,
views: item.view_count.unwrap_or(0),
artist_id: None,
});
}
}
Ok((videos, valid_ids))
}
pub fn enrich_track_metadata(tracks: &mut [crate::types::Track]) {
let ids: Vec<String> = tracks
.iter()
.filter_map(|t| t.provider_id(ProviderId::YouTube).map(str::to_string))
.collect();
if ids.is_empty() {
return;
}
let metadata = fetch_batch_metadata(&ids);
for track in tracks.iter_mut() {
let Some(id) = track.provider_id(ProviderId::YouTube).map(str::to_string) else {
continue;
};
if let (Some(item), Some(pt)) = (
metadata.get(&id),
track.providers.get_mut(&ProviderId::YouTube),
) {
if pt.duration == 0 {
pt.duration = item.duration;
}
if pt.play_count == 0 {
pt.play_count = item.view_count.unwrap_or(0);
}
}
}
}
fn fetch_batch_metadata(
valid_ids: &[String],
) -> std::collections::HashMap<String, YTDLPSearchResult> {
use std::collections::HashMap;
let mut results: HashMap<String, YTDLPSearchResult> = HashMap::new();
let Some(path) = crate::deps::resolve_yt_dlp() else {
return results;
};
let Ok(mut child) = Command::new(path)
.args([
"--batch-file",
"-",
"--dump-json",
"--skip-download",
"--no-warnings",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
else {
return results;
};
if let Some(ref mut stdin) = child.stdin {
for id in valid_ids {
let _ = writeln!(stdin, "https://youtube.com/watch?v={id}");
}
}
drop(child.stdin.take());
if let Ok(output) = child.wait_with_output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(item) = serde_json::from_str::<YTDLPSearchResult>(line) {
results.insert(item.id.clone(), item);
}
}
}
}
results
}
pub fn watch_playlist(video_id: Option<&str>, playlist_id: Option<&str>) -> Result<Vec<Track>> {
let video_arg = video_id.unwrap_or("");
let playlist_arg = playlist_id.unwrap_or("");
let stdout = run_python("watch", &[video_arg, playlist_arg, "50"])?;
let items: Vec<YouTubeVideo> =
serde_json::from_str(&stdout).context("Failed to parse ytmusicapi watch output")?;
Ok(items.into_iter().map(Track::from).collect())
}
pub fn radio_song(video_id: &str) -> Result<Vec<Track>> {
watch_playlist(Some(video_id), None)
}
pub fn radio_artist(browse_id: &str) -> Result<Vec<Track>> {
watch_playlist(None, Some(browse_id))
}
pub fn resolve_id(track: &Track) -> Result<Option<Track>> {
let query = track.search_query();
let (videos, _) = flat_search(&query, 1, 1)?;
Ok(videos.into_iter().next().map(|mut v| {
if v.url.is_empty() {
v.url = format!("https://www.youtube.com/watch?v={}", v.id);
}
Track::from(v)
}))
}
pub fn download(video_url: &str, download_dir: &str) -> Result<String> {
let id = video_url
.split("v=")
.nth(1)
.and_then(|s| s.split('&').next())
.unwrap_or("download");
let dir = std::path::Path::new(download_dir);
let _ = std::fs::create_dir_all(dir);
let output_path = dir.join(format!("{id}.mp3"));
download_audio(video_url, output_path.to_string_lossy().as_ref())
}
pub fn download_audio(video_url: &str, output_path: &str) -> Result<String> {
#[cfg(target_os = "linux")]
let extra_args = &["--extractor-args", "youtube:player_client=web_embedded"];
#[cfg(not(target_os = "linux"))]
let extra_args: &[&str] = &[];
ytdlp::download_audio(video_url, output_path, extra_args)
}
fn is_video_id(id: &str) -> bool {
id.len() == 11 && !id.starts_with("MPRE") && !id.starts_with("UC")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_view_count_shapes() {
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "views": "841M plays"
}))
.unwrap();
assert_eq!(v.views, 841_000_000);
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "views": null
}))
.unwrap();
assert_eq!(v.views, 0);
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "views": 841_234
}))
.unwrap();
assert_eq!(v.views, 841_234);
for fractional in [-12.5f64, 841_234.7, u64::MAX as f64 * 2.0] {
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "views": fractional
}))
.unwrap();
assert_eq!(v.views, 0, "non-u64 number {fractional} degrades to 0");
}
}
#[test]
fn deserializes_mixed_duration_shapes() {
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u",
"duration": "4:36"
}))
.unwrap();
assert_eq!(v.duration, 276);
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "duration": 321.0
}))
.unwrap();
assert_eq!(v.duration, 321);
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "duration": null
}))
.unwrap();
assert_eq!(v.duration, 0);
for malformed in ["-1:30", "1e30", "4:xx"] {
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "duration": malformed
}))
.unwrap();
assert_eq!(v.duration, 0, "malformed duration {malformed} -> 0");
}
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "duration": 1e30
}))
.unwrap();
assert_eq!(v.duration, 0);
let v: YouTubeVideo = serde_json::from_value(serde_json::json!({
"id": "a", "title": "t", "url": "u", "duration": "2:00:05"
}))
.unwrap();
assert_eq!(v.duration, 7205);
}
use super::parse_abbreviated_count as p;
#[test]
fn parses_abbreviated_counts() {
assert_eq!(p("847"), 847);
assert_eq!(p("1.2K"), 1_200);
assert_eq!(p("966K"), 966_000);
assert_eq!(p("4.9M"), 4_900_000);
assert_eq!(p("841M plays"), 841_000_000);
assert_eq!(p("614M\u{a0}plays"), 614_000_000);
assert_eq!(p("3.4B views"), 3_400_000_000);
assert_eq!(p(""), 0);
assert_eq!(p("garbage"), 0);
assert_eq!(p(" \u{a0}1.2K "), 1_200);
assert_eq!(p("-3K"), 0);
}
}