use reader::models::Track;
use serde_json::Value;
use super::innertube;
use super::search::walk_playlist_shelf;
#[derive(Debug, Clone)]
pub struct YtPlaylistSummary {
pub id: String,
pub title: String,
pub thumbnail_url: Option<String>,
}
#[tracing::instrument(name = "yt.list_playlists", skip(cookies))]
pub async fn list_playlists(cookies: &str) -> Result<Vec<YtPlaylistSummary>, String> {
let resp: Value = innertube::browse("FEmusic_liked_playlists", cookies).await?;
if has_sign_in_endpoint(&resp) {
return Err("Sign-in prompt returned — cookies expired".to_string());
}
let items = resp
.pointer(
"/contents/singleColumnBrowseResultsRenderer/tabs/0/tabRenderer/content/sectionListRenderer/contents/0/gridRenderer/items",
)
.or_else(|| {
resp.pointer(
"/contents/twoColumnBrowseResultsRenderer/secondaryContents/sectionListRenderer/contents/0/gridRenderer/items",
)
})
.and_then(|v| v.as_array());
let Some(items) = items else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for item in items {
let row = match item.get("musicTwoRowItemRenderer") {
Some(r) => r,
None => continue,
};
let raw = row
.pointer("/navigationEndpoint/browseEndpoint/browseId")
.and_then(|v| v.as_str());
let Some(raw) = raw else { continue };
let id = raw.strip_prefix("VL").unwrap_or(raw).to_string();
if id.is_empty() || raw == "FEmusic_offline_storage" {
continue;
}
let title = row
.pointer("/title/runs/0/text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let thumbnail_url = row
.pointer("/thumbnailRenderer/musicThumbnailRenderer/thumbnail/thumbnails")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter()
.max_by_key(|t| t.get("width").and_then(|v| v.as_u64()).unwrap_or(0))
})
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.map(|s| s.to_string());
out.push(YtPlaylistSummary {
id,
title,
thumbnail_url,
});
}
Ok(out)
}
pub async fn get_playlist_entries(playlist_id: &str, cookies: &str) -> Result<Vec<Track>, String> {
let mut out = Vec::new();
stream_playlist_entries(playlist_id, cookies, |batch| out.extend(batch)).await?;
Ok(out)
}
#[tracing::instrument(name = "yt.playlist_entries", skip(cookies, on_batch), fields(playlist_id = %playlist_id))]
pub async fn stream_playlist_entries<F>(
playlist_id: &str,
cookies: &str,
mut on_batch: F,
) -> Result<(), String>
where
F: FnMut(Vec<Track>),
{
let browse_id = if playlist_id.starts_with("VL") {
playlist_id.to_string()
} else {
format!("VL{playlist_id}")
};
let auth = if cookies.is_empty() {
None
} else {
Some(cookies)
};
let resp: Value = innertube::browse_maybe_auth(&browse_id, auth).await?;
let (raw_first, mut next) = walk_playlist_shelf(&resp);
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let first: Vec<Track> = raw_first
.into_iter()
.filter(|t| keep_unique(t, &mut seen))
.collect();
if !first.is_empty() {
on_batch(first);
}
let mut page = 1u32;
while let Some(token) = next.take() {
let resp = innertube::browse_continuation_maybe_auth(&token, auth).await?;
let (more, next_token) = super::search::walk_playlist_continuation(&resp);
let unique: Vec<Track> = more
.into_iter()
.filter(|t| keep_unique(t, &mut seen))
.collect();
if unique.is_empty() {
break;
}
page += 1;
tracing::debug!(
page,
new_tracks = unique.len(),
total = seen.len(),
"playlist continuation page"
);
on_batch(unique);
next = next_token;
}
tracing::debug!(
pages = page,
total = seen.len(),
"playlist pagination complete"
);
Ok(())
}
pub async fn playlist_page(
playlist_id: &str,
cookies: &str,
continuation: Option<&str>,
) -> Result<(Vec<Track>, Option<String>), String> {
let auth = if cookies.is_empty() {
None
} else {
Some(cookies)
};
let page = match continuation {
None => {
let browse_id = if playlist_id.starts_with("VL") {
playlist_id.to_string()
} else {
format!("VL{playlist_id}")
};
let resp: Value = innertube::browse_maybe_auth(&browse_id, auth).await?;
walk_playlist_shelf(&resp)
}
Some(token) => {
let resp = innertube::browse_continuation_maybe_auth(token, auth).await?;
super::search::walk_playlist_continuation(&resp)
}
};
Ok(page)
}
fn has_sign_in_endpoint(v: &Value) -> bool {
match v {
Value::Object(map) => {
map.contains_key("signInEndpoint") || map.values().any(has_sign_in_endpoint)
}
Value::Array(items) => items.iter().any(has_sign_in_endpoint),
_ => false,
}
}
fn keep_unique(t: &Track, seen: &mut std::collections::HashSet<String>) -> bool {
let id = t.id.key().to_string();
!id.is_empty() && seen.insert(id)
}