mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
feat(mpris, similar search, playlist)
- added feature to search for similar songs using YouTube API ; can go to similar songs by pressing (gs) - added mpris support (added support for playerctl) ; this means you can show the current playing song on waybar, shells, etc - added playlist search - added go to album
This commit is contained in:
parent
71c2ef6410
commit
da5b76ab06
11 changed files with 2101 additions and 100 deletions
318
src/ytmusic.rs
318
src/ytmusic.rs
|
|
@ -4,15 +4,26 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::model::{is_valid_id, parse_clock, Track};
|
||||
use crate::model::{is_valid_id, parse_clock, Collection, Track};
|
||||
|
||||
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
||||
const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false";
|
||||
const NEXT_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/next?prettyPrint=false";
|
||||
const ALBUMS_FILTER: &str = "EgWKAQIYAWoMEA4QChADEAQQCRAF";
|
||||
const PLAYLISTS_FILTER: &str = "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D";
|
||||
const RADIO_TARGET: usize = 50;
|
||||
const RADIO_PAGES: usize = 4;
|
||||
const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
|
||||
const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF";
|
||||
const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
||||
const FALLBACK_COUNT: usize = 25;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Kind {
|
||||
Album,
|
||||
Playlist,
|
||||
}
|
||||
|
||||
pub struct Page {
|
||||
pub tracks: Vec<Track>,
|
||||
pub next: Option<String>,
|
||||
|
|
@ -49,6 +60,91 @@ pub fn search_more(token: &str) -> Result<Page, String> {
|
|||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
pub fn search_collections(query: &str, kind: Kind) -> Result<Vec<Collection>, String> {
|
||||
let params = match kind {
|
||||
Kind::Album => ALBUMS_FILTER,
|
||||
Kind::Playlist => PLAYLISTS_FILTER,
|
||||
};
|
||||
post(ENDPOINT, json!({ "query": query, "params": params }))
|
||||
.map(|v| parse_collections(&v, kind))
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
/* browse_id: MPREb_... for an album, VL... for a playlist */
|
||||
pub fn browse_tracks(browse_id: &str, album: &str, artist: &str) -> Result<Vec<Track>, String> {
|
||||
let playlist = browse_id.starts_with("VL");
|
||||
let v = post(BROWSE_ENDPOINT, json!({ "browseId": browse_id })).map_err(|e| format!("{e:#}"))?;
|
||||
let mut tracks = parse_browse(&v, playlist);
|
||||
let mut next = if playlist { find_continuation(&v) } else { None };
|
||||
let mut pages = 0;
|
||||
while let Some(tok) = next.take() {
|
||||
if pages >= 5 || tracks.len() >= 300 {
|
||||
break;
|
||||
}
|
||||
pages += 1;
|
||||
let url = format!("{BROWSE_ENDPOINT}&ctoken={tok}&continuation={tok}");
|
||||
let Ok(v) = post(&url, json!({})) else { break };
|
||||
let more = parse_browse(&v, true);
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
let known: std::collections::HashSet<String> =
|
||||
tracks.iter().map(|t| t.id.clone()).collect();
|
||||
tracks.extend(more.into_iter().filter(|t| !known.contains(&t.id)));
|
||||
next = find_continuation(&v);
|
||||
}
|
||||
if tracks.is_empty() {
|
||||
return Err("nothing playable found".into());
|
||||
}
|
||||
if !playlist {
|
||||
for t in &mut tracks {
|
||||
if !album.is_empty() && t.album.is_none() {
|
||||
t.album = Some(album.to_string());
|
||||
}
|
||||
if !artist.is_empty() && t.artist == "Unknown artist" {
|
||||
t.artist = artist.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
pub fn radio(seed: &str) -> Result<Vec<Track>, String> {
|
||||
fetch_radio(seed).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn fetch_radio(seed: &str) -> Result<Vec<Track>> {
|
||||
let body = json!({
|
||||
"videoId": seed,
|
||||
"playlistId": format!("RDAMVM{seed}"),
|
||||
"params": "wAEB",
|
||||
"isAudioOnly": true,
|
||||
"enablePersistentPlaylistPanel": true,
|
||||
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
|
||||
});
|
||||
let mut url = NEXT_ENDPOINT.to_string();
|
||||
let mut tracks: Vec<Track> = Vec::new();
|
||||
for _ in 0..RADIO_PAGES {
|
||||
let v = post(&url, body.clone())?;
|
||||
let before = tracks.len();
|
||||
for t in parse_radio_response(&v) {
|
||||
if !tracks.iter().any(|x| x.id == t.id) {
|
||||
tracks.push(t);
|
||||
}
|
||||
}
|
||||
match find_continuation(&v) {
|
||||
Some(tok) if tracks.len() < RADIO_TARGET && tracks.len() > before => {
|
||||
url = format!("{NEXT_ENDPOINT}&ctoken={tok}&continuation={tok}");
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if tracks.is_empty() {
|
||||
bail!("no similar songs found");
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
pub fn import(url: &str) -> Result<Vec<Track>, String> {
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err("import expects a full http(s) URL".into());
|
||||
|
|
@ -119,6 +215,7 @@ fn find_continuation(v: &Value) -> Option<String> {
|
|||
Value::Object(map) => {
|
||||
let token = map
|
||||
.get("nextContinuationData")
|
||||
.or_else(|| map.get("nextRadioContinuationData"))
|
||||
.and_then(|c| c.get("continuation"))
|
||||
.or_else(|| {
|
||||
map.get("continuationCommand")
|
||||
|
|
@ -220,11 +317,15 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
|||
}
|
||||
|
||||
fn column_segments(item: &Value, col: usize) -> Vec<String> {
|
||||
let runs = item
|
||||
.get("flexColumns")
|
||||
.and_then(|c| c.get(col))
|
||||
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
|
||||
.and_then(Value::as_array);
|
||||
run_segments(
|
||||
item.get("flexColumns")
|
||||
.and_then(|c| c.get(col))
|
||||
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
|
||||
.and_then(Value::as_array),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
|
||||
let mut segments = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for run in runs.into_iter().flatten() {
|
||||
|
|
@ -249,6 +350,13 @@ fn looks_like_duration(s: &str) -> bool {
|
|||
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
|
||||
}
|
||||
|
||||
fn fixed_duration(item: &Value) -> Option<u32> {
|
||||
let s = item
|
||||
.pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")?
|
||||
.as_str()?;
|
||||
parse_clock(s).map(|s| s as u32)
|
||||
}
|
||||
|
||||
fn parse_item(item: &Value) -> Option<Track> {
|
||||
let id = find_str(item, "videoId")?;
|
||||
if !is_valid_id(id) {
|
||||
|
|
@ -271,6 +379,7 @@ fn parse_item(item: &Value) -> Option<Track> {
|
|||
}
|
||||
_ => None,
|
||||
};
|
||||
let duration = duration.or_else(|| fixed_duration(item));
|
||||
let artist = meta
|
||||
.first()
|
||||
.cloned()
|
||||
|
|
@ -289,6 +398,63 @@ fn parse_item(item: &Value) -> Option<Track> {
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_browse(v: &Value, playlist: bool) -> Vec<Track> {
|
||||
let mut items = Vec::new();
|
||||
collect_items(v, &mut items);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let mut t = parse_item(item)?;
|
||||
if playlist && t.album.is_none() {
|
||||
t.album = column_segments(item, 2).into_iter().next();
|
||||
}
|
||||
Some(t)
|
||||
})
|
||||
.filter(|t| seen.insert(t.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_collections(v: &Value, kind: Kind) -> Vec<Collection> {
|
||||
let mut items = Vec::new();
|
||||
collect_items(v, &mut items);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let id = item
|
||||
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
|
||||
.as_str()?;
|
||||
let ok = match kind {
|
||||
Kind::Album => id.starts_with("MPRE"),
|
||||
Kind::Playlist => id.starts_with("VL"),
|
||||
};
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let title = column_segments(item, 0).into_iter().next()?;
|
||||
let mut meta = column_segments(item, 1);
|
||||
if matches!(
|
||||
meta.first().map(String::as_str),
|
||||
Some("Album" | "EP" | "Single" | "Playlist")
|
||||
) {
|
||||
meta.remove(0);
|
||||
}
|
||||
let artist = match kind {
|
||||
Kind::Album => meta.first().cloned().unwrap_or_default(),
|
||||
Kind::Playlist => String::new(),
|
||||
};
|
||||
Some(Collection {
|
||||
browse_id: id.to_string(),
|
||||
title,
|
||||
subtitle: meta.join(" · "),
|
||||
artist,
|
||||
})
|
||||
})
|
||||
.filter(|c| seen.insert(c.browse_id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_search_response(v: &Value) -> Vec<Track> {
|
||||
let mut items = Vec::new();
|
||||
collect_items(v, &mut items);
|
||||
|
|
@ -300,6 +466,76 @@ pub fn parse_search_response(v: &Value) -> Vec<Track> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn collect_panel<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
if let Some(w) = map.get("playlistPanelVideoWrapperRenderer") {
|
||||
out.extend(w.pointer("/primaryRenderer/playlistPanelVideoRenderer"));
|
||||
} else if let Some(item) = map.get("playlistPanelVideoRenderer") {
|
||||
out.push(item);
|
||||
} else {
|
||||
map.values().for_each(|c| collect_panel(c, out));
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => arr.iter().for_each(|c| collect_panel(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_panel_item(item: &Value) -> Option<Track> {
|
||||
let id = item.get("videoId")?.as_str()?;
|
||||
if !is_valid_id(id) {
|
||||
return None;
|
||||
}
|
||||
let title: String = item
|
||||
.pointer("/title/runs")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|r| r.get("text")?.as_str())
|
||||
.collect();
|
||||
let title = title.trim().to_string();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array));
|
||||
let artist = meta
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown artist".into());
|
||||
let album = meta
|
||||
.get(1)
|
||||
.filter(|a| {
|
||||
!a.contains(" views")
|
||||
&& !a.contains(" plays")
|
||||
&& !(a.len() == 4 && a.bytes().all(|b| b.is_ascii_digit()))
|
||||
})
|
||||
.cloned();
|
||||
let duration = item
|
||||
.pointer("/lengthText/runs/0/text")
|
||||
.or_else(|| item.pointer("/lengthText/simpleText"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_clock)
|
||||
.map(|s| s as u32);
|
||||
Some(Track {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_radio_response(v: &Value) -> Vec<Track> {
|
||||
let mut items = Vec::new();
|
||||
collect_panel(v, &mut items);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(parse_panel_item)
|
||||
.filter(|t| seen.insert(t.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
|
||||
ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"))
|
||||
}
|
||||
|
|
@ -463,6 +699,76 @@ not json
|
|||
assert_eq!(find_continuation(&json!({"a": [1, 2]})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_radio_panel() {
|
||||
let item = |id: &str, title: &str, by: Value, len: &str| {
|
||||
json!({"playlistPanelVideoRenderer": {
|
||||
"videoId": id,
|
||||
"title": {"runs": [{"text": title}]},
|
||||
"longBylineText": {"runs": by},
|
||||
"lengthText": {"runs": [{"text": len}]},
|
||||
}})
|
||||
};
|
||||
let by = json!([{"text": "A"}, {"text": " & "}, {"text": "B"},
|
||||
{"text": " • "}, {"text": "Album"}, {"text": " • "}, {"text": "2019"}]);
|
||||
let resp = json!({"contents": [
|
||||
item("abcdefghijk", "One", by, "3:21"),
|
||||
{"playlistPanelVideoWrapperRenderer": {
|
||||
"primaryRenderer": item("bcdefghijkl", "Two", json!([{"text": "C"}]), "1:05"),
|
||||
"counterpart": [item("zzzzzzzzzzz", "Video", json!([{"text": "C"}]), "1:05")],
|
||||
}},
|
||||
item("abcdefghijk", "dup", json!([{"text": "A"}]), "3:21"),
|
||||
]});
|
||||
let t = parse_radio_response(&resp);
|
||||
assert_eq!(t.len(), 2);
|
||||
assert_eq!(t[0].artist, "A & B");
|
||||
assert_eq!(t[0].album.as_deref(), Some("Album"));
|
||||
assert_eq!(t[0].duration, Some(201));
|
||||
assert_eq!(t[1].id, "bcdefghijkl");
|
||||
assert_eq!(t[1].album, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_album_and_playlist_results() {
|
||||
let row = |id: &str, title: &str, runs: Value| {
|
||||
json!({"musicResponsiveListItemRenderer": {
|
||||
"navigationEndpoint": {"browseEndpoint": {"browseId": id}},
|
||||
"flexColumns": [
|
||||
{"musicResponsiveListItemFlexColumnRenderer": run(title)},
|
||||
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": runs}}},
|
||||
]
|
||||
}})
|
||||
};
|
||||
let resp = json!([
|
||||
row("MPREb_abc", "Discovery", json!([{"text": "Album"}, {"text": " • "},
|
||||
{"text": "Daft Punk"}, {"text": " • "}, {"text": "2001"}])),
|
||||
row("VLPL123", "Mix", json!([{"text": "Playlist"}, {"text": " • "}, {"text": "Someone"}])),
|
||||
row("UCartist", "Artist", json!([{"text": "Artist"}])),
|
||||
]);
|
||||
let a = parse_collections(&resp, Kind::Album);
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].title, "Discovery");
|
||||
assert_eq!(a[0].artist, "Daft Punk");
|
||||
assert_eq!(a[0].subtitle, "Daft Punk · 2001");
|
||||
let p = parse_collections(&resp, Kind::Playlist);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0].browse_id, "VLPL123");
|
||||
assert_eq!(p[0].artist, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_browse_tracks() {
|
||||
let resp = json!({"contents": [{"musicResponsiveListItemRenderer": {
|
||||
"playlistItemData": {"videoId": "abcdefghijk"},
|
||||
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("One More Time")}],
|
||||
"fixedColumns": [{"musicResponsiveListItemFixedColumnRenderer": run("5:20")}],
|
||||
}}]});
|
||||
let t = parse_browse(&resp, false);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].duration, Some(320));
|
||||
assert_eq!(t[0].artist, "Unknown artist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_math() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
|
|
|
|||
Loading…
Reference in a new issue