use reader::models::{Track, TrackId};
use serde_json::Value;
pub mod botguard;
pub mod clients;
pub mod cookies;
pub mod decipher;
pub mod discover;
pub mod innertube;
pub mod isolated_profile;
pub mod mix;
pub mod mutations;
pub mod player;
pub mod playlists;
pub mod search;
pub mod verify_session_keepalive;
pub use player::YtStreamInfo;
pub const SOURCE_PREFIX: &str = "ytmusic";
pub(crate) fn yt_id(video_id: impl Into<String>) -> TrackId {
TrackId::Server {
service: config::MusicService::YtMusic,
item_id: video_id.into(),
}
}
pub const ANON_AUTH_REQUIRED: &str = "YouTube Music not signed in";
pub fn derive_user_id(cookies: &str) -> Option<String> {
use sha1::{Digest, Sha1};
let sapisid = cookies.split(';').find_map(|p| {
let (k, v) = p.trim().split_once('=')?;
(k == "SAPISID" || k == "__Secure-3PAPISID").then(|| v.to_string())
})?;
let mut h = Sha1::new();
h.update(sapisid.as_bytes());
Some(format!("yt-{}", hex::encode(&h.finalize()[..6])))
}
pub async fn probe_stream(video_id: &str, cookies: Option<&str>) -> Result<YtStreamInfo, String> {
player::resolve(video_id, cookies).await
}
pub(crate) struct YouTubeMusicClient {
cookies: Option<String>,
}
impl YouTubeMusicClient {
pub fn new() -> Self {
Self { cookies: None }
}
pub fn with_cookies(cookies: String) -> Self {
Self {
cookies: (!cookies.is_empty()).then_some(cookies),
}
}
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, String> {
search::music_search_tracks(query, self.cookies.as_deref()).await
}
pub async fn resolve_artist_channel_id(&self, query: &str) -> Result<Option<String>, String> {
search::resolve_artist_channel_id(query, self.cookies.as_deref()).await
}
pub async fn resolve_artist_image(&self, name: &str) -> Result<Option<String>, String> {
search::resolve_artist_image(name, self.cookies.as_deref()).await
}
pub async fn resolve_album_browse_id(
&self,
album: &str,
artist: &str,
) -> Result<Option<String>, String> {
search::resolve_album_browse_id(album, artist, self.cookies.as_deref()).await
}
pub async fn list_playlists(&self) -> Result<Vec<playlists::YtPlaylistSummary>, String> {
let Some(cookies) = self.cookies.as_deref() else {
return Ok(Vec::new());
};
playlists::list_playlists(cookies).await
}
pub async fn get_playlist_entries(&self, playlist_id: &str) -> Result<Vec<Track>, String> {
playlists::get_playlist_entries(playlist_id, self.cookies.as_deref().unwrap_or("")).await
}
pub async fn playlist_page(
&self,
playlist_id: &str,
continuation: Option<&str>,
) -> Result<(Vec<Track>, Option<String>), String> {
playlists::playlist_page(
playlist_id,
self.cookies.as_deref().unwrap_or(""),
continuation,
)
.await
}
pub async fn like_video(&self, video_id: &str) -> Result<(), String> {
let cookies = self.cookies.as_deref().ok_or(ANON_AUTH_REQUIRED)?;
mutations::like_video(video_id, cookies).await
}
pub async fn unlike_video(&self, video_id: &str) -> Result<(), String> {
let cookies = self.cookies.as_deref().ok_or(ANON_AUTH_REQUIRED)?;
mutations::unlike_video(video_id, cookies).await
}
pub async fn add_to_playlist(&self, playlist_id: &str, video_id: &str) -> Result<(), String> {
let cookies = self.cookies.as_deref().ok_or(ANON_AUTH_REQUIRED)?;
mutations::add_to_playlist(playlist_id, video_id, cookies).await
}
pub async fn remove_from_playlist(
&self,
playlist_id: &str,
video_id: &str,
) -> Result<(), String> {
let cookies = self.cookies.as_deref().ok_or(ANON_AUTH_REQUIRED)?;
mutations::remove_from_playlist(playlist_id, video_id, cookies).await
}
pub async fn create_playlist(&self, title: &str, video_ids: &[&str]) -> Result<String, String> {
let cookies = self.cookies.as_deref().ok_or(ANON_AUTH_REQUIRED)?;
mutations::create_playlist(title, video_ids, cookies).await
}
#[tracing::instrument(name = "yt.liked_stream", skip_all)]
pub async fn stream_liked_songs<F>(&self, mut on_page: F) -> Result<(), String>
where
F: FnMut(Vec<Track>),
{
let Some(cookies) = self.cookies.as_deref() else {
let _ = &mut on_page;
return Ok(());
};
let resp: Value = innertube::browse("VLLM", cookies).await?;
if !has_playlist_shelf(&resp) {
return Err("Sign-in prompt returned — cookies expired".to_string());
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let dedup =
|page: Vec<Track>, seen: &mut std::collections::HashSet<String>| -> Vec<Track> {
page.into_iter()
.filter(|t| {
let id = t.id.key().to_string();
!id.is_empty() && seen.insert(id)
})
.collect()
};
let (page1, mut next) = search::walk_playlist_shelf(&resp);
let page1 = dedup(page1, &mut seen);
if !page1.is_empty() {
on_page(page1);
}
while let Some(token) = next.take() {
let page = innertube::browse_continuation(&token, cookies).await?;
let (more, next_token) = search::walk_playlist_continuation(&page);
let more = dedup(more, &mut seen);
if more.is_empty() {
break;
}
on_page(more);
next = next_token;
}
Ok(())
}
pub async fn liked_songs_page(
&self,
continuation: Option<&str>,
) -> Result<(Vec<Track>, Option<String>), String> {
let Some(cookies) = self.cookies.as_deref() else {
return Ok((Vec::new(), None));
};
match continuation {
None => {
let resp: Value = innertube::browse("VLLM", cookies).await?;
if !has_playlist_shelf(&resp) {
return Err("Sign-in prompt returned — cookies expired".to_string());
}
Ok(search::walk_playlist_shelf(&resp))
}
Some(token) => {
let resp = innertube::browse_continuation(token, cookies).await?;
Ok(search::walk_playlist_continuation(&resp))
}
}
}
pub async fn get_stream(&self, video_id: &str) -> Result<YtStreamInfo, String> {
player::resolve(video_id, self.cookies.as_deref()).await
}
pub async fn start_mix(&self, seed_video_id: &str) -> Result<Vec<Track>, String> {
mix::start_mix(seed_video_id, self.cookies.as_deref().unwrap_or("")).await
}
pub async fn discover_home(&self) -> Result<discover::DiscoverHome, String> {
discover::fetch_home(self.cookies.as_deref().unwrap_or("")).await
}
pub async fn discover_continuation(
&self,
token: &str,
) -> Result<discover::DiscoverHome, String> {
discover::fetch_continuation(token, self.cookies.as_deref().unwrap_or("")).await
}
pub async fn fetch_album_tracks(&self, browse_id: &str) -> Result<Vec<Track>, String> {
discover::fetch_album_tracks(browse_id, self.cookies.as_deref().unwrap_or("")).await
}
pub async fn fetch_album(&self, browse_id: &str) -> Result<discover::YtAlbum, String> {
discover::fetch_album(browse_id, self.cookies.as_deref().unwrap_or("")).await
}
pub async fn fetch_artist(&self, channel_id: &str) -> Result<discover::YtArtist, String> {
discover::fetch_artist(channel_id, self.cookies.as_deref().unwrap_or("")).await
}
#[tracing::instrument(name = "yt.validate", skip_all)]
pub async fn validate_cookies(&self) -> Result<(), String> {
let Some(cookies) = self.cookies.as_deref() else {
return Ok(());
};
let json: Value = innertube::browse("VLLM", cookies).await?;
if has_playlist_shelf(&json) {
Ok(())
} else {
Err("YouTube returned no playlist shelf — cookies expired or browser signed out".into())
}
}
}
fn has_playlist_shelf(json: &Value) -> bool {
json.pointer("/contents/twoColumnBrowseResultsRenderer/secondaryContents/sectionListRenderer/contents")
.or_else(|| {
json.pointer(
"/contents/singleColumnBrowseResultsRenderer/tabs/0/tabRenderer/content/sectionListRenderer/contents",
)
})
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.any(|shelf| shelf.get("musicPlaylistShelfRenderer").is_some())
})
.unwrap_or(false)
}
impl Default for YouTubeMusicClient {
fn default() -> Self {
Self::new()
}
}