use std::sync::LazyLock;
use async_trait::async_trait;
use serde_json::{Value, json};
use super::{LyricsProvider, utils};
use crate::protocol::{
models::{LyricsData, LyricsLine},
tracks::TrackInfo,
};
static VIDEO_ID_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#""videoId":"([^"]+)""#).expect("invalid VIDEO_ID_RE regex pattern")
});
const YTM_DOMAIN: &str = "https://music.youtube.com";
const YTM_BASE_API: &str = "https://music.youtube.com/youtubei/v1/";
const YTM_PARAMS: &str = "?alt=json";
const YTM_PARAMS_KEY: &str = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
#[derive(Default)]
pub struct YoutubeMusicLyricsProvider {
client: reqwest::Client,
}
impl YoutubeMusicLyricsProvider {
pub fn new() -> Self {
Self::default()
}
async fn send_request(&self, endpoint: &str, body: Value, is_mobile: bool) -> Option<Value> {
let client_name = if is_mobile {
"ANDROID_MUSIC"
} else {
"WEB_REMIX"
};
let client_version = if is_mobile {
"7.21.50"
} else {
"1.20240101.01.00"
};
let context = json!({
"context": {
"client": {
"clientName": client_name,
"clientVersion": client_version,
"hl": "en",
"gl": "US",
},
"user": {},
}
});
let mut final_body = body.as_object()?.clone();
for (k, v) in context.as_object()? {
final_body.insert(k.clone(), v.clone());
}
let url = format!(
"{}{}{}{}",
YTM_BASE_API, endpoint, YTM_PARAMS, YTM_PARAMS_KEY
);
let resp = self.client.post(&url)
.header("Content-Type", "application/json")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Origin", YTM_DOMAIN)
.header("Referer", YTM_DOMAIN)
.header("Cookie", "SOCS=CAI")
.json(&final_body)
.send().await.ok()?;
let json_resp: Value = resp.json().await.ok()?;
Some(json_resp)
}
}
#[async_trait]
impl LyricsProvider for YoutubeMusicLyricsProvider {
fn name(&self) -> &'static str {
"youtubemusic"
}
async fn load_lyrics(&self, track: &TrackInfo) -> Option<LyricsData> {
let mut video_id = None;
if track.source_name == "youtube" || track.source_name == "youtubemusic" {
video_id = track.uri.as_deref().or(Some(&track.identifier)).map(|s| {
if s.contains("v=") {
s.split("v=")
.nth(1)
.and_then(|v| v.split('&').next())
.unwrap_or(s)
.to_string()
} else if s.contains("youtu.be/") {
s.split("youtu.be/")
.nth(1)
.and_then(|v| v.split('?').next())
.unwrap_or(s)
.to_string()
} else {
s.to_string()
}
});
}
if video_id.is_none() {
let title = utils::clean_text(&track.title);
let author = utils::clean_text(&track.author);
let query = format!("{} {}", title, author);
if let Some(search_results) = self
.send_request(
"search",
json!({
"query": query,
"params": "EgWKAQIIAWoMEA4QChADEAQQCRAF"
}),
false,
)
.await
{
if let Some(contents) = search_results
.pointer("/contents/sectionListRenderer/contents")
.and_then(|v| v.as_array())
{
for section in contents {
if let Some(music_shelf) = section.get("musicShelfRenderer") {
if let Some(music_contents) = music_shelf.get("contents").and_then(|v| v.as_array()) {
if !music_contents.is_empty() {
let first_item = &music_contents[0];
if let Some(vid) = first_item
.pointer("/musicResponsiveListItemRenderer/playlistItemData/videoId")
.and_then(|v| v.as_str())
{
video_id = Some(vid.to_string());
break;
}
}
}
}
}
}
if video_id.is_none() {
if let Some(vid) = search_results.pointer("/contents/sectionListRenderer/contents/0/musicCardShelfRenderer/onTap/watchEndpoint/videoId").and_then(|v| v.as_str()) {
video_id = Some(vid.to_string());
}
}
if video_id.is_none() {
let search_str = search_results.to_string();
if let Some(caps) = VIDEO_ID_RE.captures(&search_str) {
video_id = Some(caps[1].to_string());
}
}
}
}
let video_id = video_id?;
let next_response = self
.send_request("next", json!({ "videoId": video_id }), false)
.await?;
let tabs = next_response.pointer("/contents/singleColumnMusicWatchNextResultsRenderer/tabbedRenderer/watchNextTabbedResultsRenderer/tabs").and_then(|v| v.as_array())?;
if tabs.len() < 2 {
return None;
}
let browse_id = tabs[1]
.pointer("/tabRenderer/endpoint/browseEndpoint/browseId")
.and_then(|v| v.as_str())?;
if let Some(mobile_response) = self
.send_request("browse", json!({ "browseId": browse_id }), true)
.await
{
if let Some(lyrics_data) = mobile_response.pointer("/contents/elementRenderer/newElement/type/componentType/model/timedLyricsModel/lyricsData/timedLyricsData").and_then(|v| v.as_array()) {
let mut lines = Vec::new();
for line in lyrics_data {
let text = line["lyricLine"].as_str().unwrap_or("").to_string();
let start_time: u64 = line.pointer("/cueRange/startTimeMilliseconds").and_then(|v| v.as_str()).unwrap_or("0").parse().unwrap_or(0);
let end_time: u64 = line.pointer("/cueRange/endTimeMilliseconds").and_then(|v| v.as_str()).unwrap_or("0").parse().unwrap_or(0);
lines.push(LyricsLine {
text,
timestamp: start_time,
duration: end_time.saturating_sub(start_time),
});
}
if !lines.is_empty() {
return Some(LyricsData {
name: track.title.clone(),
author: track.author.clone(),
provider: "youtubemusic".to_string(),
text: lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join("\n"),
lines: Some(lines),
});
}
}
}
if let Some(browse_response) = self
.send_request("browse", json!({ "browseId": browse_id }), false)
.await
{
if let Some(contents) = browse_response
.pointer("/contents/sectionListRenderer/contents")
.and_then(|v| v.as_array())
{
if let Some(desc_shelf) = contents
.iter()
.find_map(|c| c.get("musicDescriptionShelfRenderer"))
{
if let Some(lyrics_text) = desc_shelf
.pointer("/description/runs/0/text")
.and_then(|v| v.as_str())
{
let mut lines = Vec::new();
for text_line in lyrics_text.split('\n') {
let trimmed = text_line.trim();
if !trimmed.is_empty() {
lines.push(LyricsLine {
text: trimmed.to_string(),
timestamp: 0,
duration: 0,
});
}
}
if !lines.is_empty() {
return Some(LyricsData {
name: track.title.clone(),
author: track.author.clone(),
provider: "youtubemusic".to_string(),
text: lyrics_text.to_string(),
lines: Some(lines),
});
}
}
}
}
}
None
}
}