pub mod api;
pub mod auth;
pub mod innertube;
pub mod model;
use std::io::{self, Read};
use std::os::fd::AsRawFd;
use auth::{Account, Credentials, Session, Tokens};
pub struct Service {
creds: Option<Credentials>,
session: Option<Session>, #[cfg(feature = "youtube-tv")]
tv_session: Option<Session>, account: Option<String>, }
impl Service {
pub fn load() -> Service {
Service {
creds: Credentials::load(),
session: auth::load_session(Account::DataApi),
#[cfg(feature = "youtube-tv")]
tv_session: auth::load_session(Account::Tv),
account: None,
}
}
pub fn signed_in(&self) -> bool {
self.session.is_some()
}
#[cfg(feature = "youtube-tv")]
pub fn tv_signed_in(&self) -> bool {
self.tv_session.is_some()
}
pub fn account(&self) -> Option<&str> {
self.account.as_deref()
}
pub fn refresh_account(&mut self) {
if let Ok(client) = self.client() {
if let Ok(mine) = client.mine() {
self.account = Some(mine.title);
}
}
}
pub fn data_api_available(&self) -> bool {
self.signed_in() || self.creds.as_ref().and_then(|c| c.api_key.as_ref()).is_some()
}
pub fn sign_in_credentials(&self, account: Account) -> Option<Credentials> {
match account {
Account::DataApi => self.creds.clone().filter(Credentials::can_sign_in),
#[cfg(feature = "youtube-tv")]
Account::Tv => Some(auth::tv_credentials()),
}
}
pub fn client(&mut self) -> Result<api::Client, String> {
let key = self.creds.as_ref().and_then(|c| c.api_key.clone());
let token = match (&self.creds, &mut self.session) {
(Some(creds), Some(session)) => Some(fresh_token(Account::DataApi, creds, session)?),
_ => None,
};
if token.is_none() && key.is_none() {
return Err("not signed in and no API key configured".into());
}
Ok(api::Client::new(token, key))
}
#[cfg(feature = "youtube-tv")]
pub fn tv_access_token(&mut self) -> Option<String> {
let session = self.tv_session.as_mut()?;
match fresh_token(Account::Tv, &auth::tv_credentials(), session) {
Ok(token) => Some(token),
Err(error) => {
eprintln!("youtube tv: {error}");
None
}
}
}
pub fn complete_sign_in(&mut self, account: Account, tokens: Tokens) {
let session = auth::session_from(tokens);
if let Err(error) = auth::save_session(account, &session) {
eprintln!("save youtube session: {error}");
}
match account {
Account::DataApi => {
self.session = Some(session);
self.refresh_account();
}
#[cfg(feature = "youtube-tv")]
Account::Tv => self.tv_session = Some(session),
}
}
pub fn sign_out(&mut self, account: Account) {
auth::clear_session(account);
match account {
Account::DataApi => {
self.session = None;
self.account = None;
}
#[cfg(feature = "youtube-tv")]
Account::Tv => self.tv_session = None,
}
}
}
fn fresh_token(account: Account, creds: &Credentials, session: &mut Session) -> Result<String, String> {
if session.expired() {
if session.refresh_token.is_empty() {
return Err("session expired; sign in again".into());
}
let tokens = auth::refresh(creds, &session.refresh_token)?;
*session = auth::session_from(tokens);
let _ = auth::save_session(account, session);
}
Ok(session.access_token.clone())
}
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Output, Stdio};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub label: String,
pub query: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Video {
pub title: String,
pub url: String,
}
pub fn parse_config(text: &str) -> Vec<Source> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| {
let (label, query) = l.split_once('|')?;
let (label, query) = (label.trim(), query.trim());
if label.is_empty() || query.is_empty() {
return None;
}
Some(Source {
label: label.into(),
query: query.into(),
})
})
.collect()
}
fn config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join("medias").join("youtube.txt")
}
pub fn sources() -> Vec<Source> {
match std::fs::read_to_string(config_path()) {
Ok(text) => {
let s = parse_config(&text);
if s.is_empty() { defaults() } else { s }
}
Err(_) => defaults(),
}
}
fn defaults() -> Vec<Source> {
vec![
Source {
label: "Popular music".into(),
query: "ytsearch25:popular music".into(),
},
Source {
label: "News".into(),
query: "ytsearch25:news".into(),
},
]
}
pub fn parse_entries(stdout: &str) -> Vec<Video> {
stdout
.lines()
.filter_map(|line| {
let (title, id) = line.split_once('\t')?;
let (title, id) = (title.trim(), id.trim());
if id.is_empty() {
return None;
}
let url = if id.starts_with("http") {
id.to_string()
} else {
format!("https://www.youtube.com/watch?v={id}")
};
Some(Video {
title: if title.is_empty() {
id.into()
} else {
title.into()
},
url,
})
})
.collect()
}
struct ChildGroup {
child: Child,
complete: bool,
}
impl Drop for ChildGroup {
fn drop(&mut self) {
if !self.complete {
unsafe { libc::kill(-(self.child.id() as libc::pid_t), libc::SIGKILL) };
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn nonblocking(pipe: &impl AsRawFd) -> io::Result<()> {
let fd = pipe.as_raw_fd();
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn drain(pipe: &mut impl Read, output: &mut Vec<u8>) -> io::Result<bool> {
let mut bytes = [0; 8192];
for _ in 0..8 {
match pipe.read(&mut bytes) {
Ok(0) => return Ok(true),
Ok(count) => output.extend_from_slice(&bytes[..count]),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
Ok(false)
}
fn cancellable_output(
command: &mut Command,
mut cancelled: impl FnMut() -> bool,
) -> io::Result<Output> {
if cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "resolution cancelled"));
}
let child = command
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut group = ChildGroup {
child,
complete: false,
};
let mut stdout = group.child.stdout.take().unwrap();
let mut stderr = group.child.stderr.take().unwrap();
nonblocking(&stdout)?;
nonblocking(&stderr)?;
let (mut out, mut err) = (Vec::new(), Vec::new());
let (mut out_done, mut err_done) = (false, false);
loop {
if cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "resolution cancelled"));
}
if !out_done {
out_done = drain(&mut stdout, &mut out)?;
}
if !err_done {
err_done = drain(&mut stderr, &mut err)?;
}
if out_done && err_done && let Some(status) = group.child.try_wait()? {
group.complete = true;
return Ok(Output {
status,
stdout: out,
stderr: err,
});
}
let mut polls = [
libc::pollfd {
fd: if out_done { -1 } else { stdout.as_raw_fd() },
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: if err_done { -1 } else { stderr.as_raw_fd() },
events: libc::POLLIN,
revents: 0,
},
];
let result = unsafe { libc::poll(polls.as_mut_ptr(), polls.len() as libc::nfds_t, 20) };
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
}
}
pub fn interleave(lists: Vec<Vec<model::Video>>, skip: &std::collections::HashSet<String>) -> Vec<model::Video> {
let mut queues: Vec<std::collections::VecDeque<model::Video>> = lists.into_iter().map(Into::into).collect();
let mut seen = skip.clone();
let mut out = Vec::new();
while queues.iter().any(|q| !q.is_empty()) {
for queue in &mut queues {
if let Some(video) = queue.pop_front()
&& seen.insert(video.id.clone())
{
out.push(video);
}
}
}
out
}
pub fn thumb_url(id: &str) -> String {
format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg")
}
pub fn video_id(url: &str) -> Option<String> {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
let host = host.strip_prefix("www.").or_else(|| host.strip_prefix("m.")).unwrap_or(host);
let id = if host == "youtu.be" {
Some(path)
} else if host == "youtube.com" || host == "music.youtube.com" {
let (route, query) = path.split_once('?').unwrap_or((path, ""));
query
.split('&')
.find_map(|kv| kv.strip_prefix("v="))
.or_else(|| ["shorts/", "live/", "embed/"].iter().find_map(|p| route.strip_prefix(p)))
} else {
None
}?;
let id = id.split(['?', '&', '#', '/']).next()?;
let valid = id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
valid.then(|| id.to_string())
}
pub fn ytdl_path() -> String {
if let Ok(p) = std::env::var("MEDIAS_YTDL_PATH")
&& !p.is_empty()
{
return p;
}
for candidate in ["/usr/bin/yt-dlp", "/usr/local/bin/yt-dlp", "/usr/sbin/yt-dlp", "/opt/homebrew/bin/yt-dlp"] {
if std::path::Path::new(candidate).exists() {
return candidate.to_string();
}
}
"yt-dlp".to_string()
}
pub fn resolve(query: &str) -> io::Result<Vec<Video>> {
let mut command = Command::new(ytdl_path());
command.args([
"--flat-playlist",
"--no-warnings",
"--ignore-errors",
"--print",
"%(title)s\t%(id)s",
query,
]);
let out = cancellable_output(&mut command, crate::console::stopping)?;
if !out.status.success() && out.stdout.is_empty() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(io::Error::other(format!(
"yt-dlp: {}",
err.lines().next().unwrap_or("failed")
)));
}
Ok(parse_entries(&String::from_utf8_lossy(&out.stdout)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subprocess_output_drains_both_pipes() {
let mut command = Command::new("/bin/sh");
command.args(["-c", "dd if=/dev/zero bs=65536 count=4 2>/dev/null; (dd if=/dev/zero bs=65536 count=4 2>/dev/null) >&2"]);
let output = cancellable_output(&mut command, || false).unwrap();
assert!(output.status.success());
assert_eq!(output.stdout.len(), 262144);
assert_eq!(output.stderr.len(), 262144);
assert!(
output
.stdout
.iter()
.chain(&output.stderr)
.all(|&byte| byte == 0)
);
}
#[test]
fn subprocess_cancellation_closes_descendant_pipes() {
for script in ["sleep 30 &", "sleep 30 & wait"] {
let mut command = Command::new("/bin/sh");
command.args(["-c", script]);
let start = std::time::Instant::now();
let mut checks = 0;
let error = cancellable_output(&mut command, || {
checks += 1;
checks >= 5
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
}
#[test]
fn config_parsing() {
let text = "\
# saved searches
Lofi | ytsearch20:lofi hip hop
My Playlist | https://youtube.com/playlist?list=ABC
bad line without pipe
empty |
| noquerylabel
";
assert_eq!(
parse_config(text),
vec![
Source {
label: "Lofi".into(),
query: "ytsearch20:lofi hip hop".into()
},
Source {
label: "My Playlist".into(),
query: "https://youtube.com/playlist?list=ABC".into()
},
]
);
}
#[test]
fn entries_build_watch_urls() {
let out =
"Never Gonna Give You Up\tdQw4w9WgXcQ\nSome Title\tabc123\njunk no tab\n\tEMPTYTITLE\n";
let v = parse_entries(out);
assert_eq!(v.len(), 3);
assert_eq!(v[0].title, "Never Gonna Give You Up");
assert_eq!(v[0].url, "https://www.youtube.com/watch?v=dQw4w9WgXcQ");
assert_eq!(v[2].title, "EMPTYTITLE"); }
#[test]
fn interleave_round_robins_and_skips_seen_ids() {
let video = |id: &str| model::Video { id: id.into(), title: String::new(), channel: String::new(), duration: 0, thumbs: Default::default() };
let lists = vec![
vec![video("a1"), video("a2"), video("a3")],
vec![video("b1"), video("a2"), video("w")],
vec![video("c1")],
];
let skip = ["w".to_string()].into_iter().collect();
let ids: Vec<String> = interleave(lists, &skip).into_iter().map(|v| v.id).collect();
assert_eq!(ids, ["a1", "b1", "c1", "a2", "a3"]);
}
#[test]
fn video_ids_from_url_forms() {
for url in [
"https://www.youtube.com/watch?v=WXELsIk4o0g&t=4246s",
"https://m.youtube.com/watch?feature=share&v=WXELsIk4o0g",
"https://music.youtube.com/watch?v=WXELsIk4o0g#x",
"https://youtu.be/WXELsIk4o0g?t=10",
"https://www.youtube.com/shorts/WXELsIk4o0g",
"https://www.youtube.com/live/WXELsIk4o0g?si=abc",
] {
assert_eq!(video_id(url).as_deref(), Some("WXELsIk4o0g"), "{url}");
}
assert_eq!(video_id("https://www.youtube.com/watch?v=short"), None);
assert_eq!(video_id("https://vimeo.com/123456789"), None);
assert_eq!(video_id("/media/movies/a.mkv"), None);
}
#[test]
fn entries_passthrough_full_url() {
let v = parse_entries("Clip\thttps://www.youtube.com/watch?v=xyz\n");
assert_eq!(v[0].url, "https://www.youtube.com/watch?v=xyz");
}
}