use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::youtube::api::encode;
const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code";
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const SCOPE: &str = "https://www.googleapis.com/auth/youtube.readonly";
#[cfg(feature = "youtube-tv")]
const TV_SCOPE: &str = "https://www.googleapis.com/auth/youtube";
const GRANT_DEVICE: &str = "urn:ietf:params:oauth:grant-type:device_code";
#[cfg(feature = "youtube-tv")]
const TV_CLIENT_ID: &str = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com";
#[cfg(feature = "youtube-tv")]
const TV_CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Account {
DataApi,
#[cfg(feature = "youtube-tv")]
Tv,
}
impl Account {
pub fn scope(self) -> &'static str {
match self {
Account::DataApi => SCOPE,
#[cfg(feature = "youtube-tv")]
Account::Tv => TV_SCOPE,
}
}
fn file(self) -> &'static str {
match self {
Account::DataApi => ".youtube_auth",
#[cfg(feature = "youtube-tv")]
Account::Tv => ".youtube_tv_auth",
}
}
}
#[cfg(feature = "youtube-tv")]
pub fn tv_credentials() -> Credentials {
Credentials { client_id: TV_CLIENT_ID.into(), client_secret: TV_CLIENT_SECRET.into(), api_key: None }
}
#[derive(Debug, Clone)]
pub struct Credentials {
pub client_id: String,
pub client_secret: String,
pub api_key: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawCredentials {
#[serde(default)]
client_id: String,
#[serde(default)]
client_secret: String,
#[serde(default)]
api_key: Option<String>,
}
impl Credentials {
pub fn load() -> Option<Credentials> {
let mut raw = creds_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str::<RawCredentials>(&t).ok())
.unwrap_or_default();
if raw.client_id.is_empty() {
raw.client_id = std::env::var("MEDIAS_YT_CLIENT_ID").unwrap_or_default();
}
if raw.client_secret.is_empty() {
raw.client_secret = std::env::var("MEDIAS_YT_CLIENT_SECRET").unwrap_or_default();
}
if raw.api_key.is_none() {
raw.api_key = std::env::var("MEDIAS_YT_API_KEY").ok();
}
if raw.client_id.is_empty() || raw.client_secret.is_empty() {
if let Some(key) = raw.api_key.filter(|k| !k.is_empty()) {
return Some(Credentials { client_id: String::new(), client_secret: String::new(), api_key: Some(key) });
}
return None;
}
Some(Credentials { client_id: raw.client_id, client_secret: raw.client_secret, api_key: raw.api_key })
}
pub fn can_sign_in(&self) -> bool {
!self.client_id.is_empty() && !self.client_secret.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_url: String,
pub verification_url_complete: String,
pub interval: u64,
pub expires_in: u64,
}
impl DeviceCode {
pub fn scan_url(&self) -> String {
if !self.verification_url_complete.is_empty() {
return self.verification_url_complete.clone();
}
let sep = if self.verification_url.contains('?') { '&' } else { '?' };
format!("{}{sep}user_code={}", self.verification_url, encode(&self.user_code))
}
}
#[derive(Deserialize)]
struct RawDeviceCode {
device_code: String,
user_code: String,
#[serde(alias = "verification_url", alias = "verification_uri")]
verification_url: String,
#[serde(default, alias = "verification_url_complete", alias = "verification_uri_complete")]
verification_url_complete: String,
#[serde(default = "default_interval")]
interval: u64,
#[serde(default)]
expires_in: u64,
}
fn default_interval() -> u64 {
5
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Poll {
Pending,
SlowDown,
Denied,
Expired,
Granted(Tokens),
Error(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}
#[derive(Deserialize)]
struct RawToken {
#[serde(default)]
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default)]
error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub refresh_token: String,
#[serde(default)]
pub access_token: String,
#[serde(default)]
pub expiry: u64, }
impl Session {
pub fn expired(&self) -> bool {
self.access_token.is_empty() || now() >= self.expiry
}
}
pub fn qr_matrix(data: &str) -> Option<(usize, Vec<bool>)> {
let code = qrcode::QrCode::new(data.as_bytes()).ok()?;
let width = code.width();
let modules = code.to_colors().into_iter().map(|c| c == qrcode::Color::Dark).collect();
Some((width, modules))
}
pub fn parse_device_code(body: &str) -> Result<DeviceCode, String> {
let raw: RawDeviceCode = serde_json::from_str(body).map_err(|e| format!("device code json: {e}"))?;
Ok(DeviceCode {
device_code: raw.device_code,
user_code: raw.user_code,
verification_url: raw.verification_url,
verification_url_complete: raw.verification_url_complete,
interval: raw.interval.max(1),
expires_in: raw.expires_in,
})
}
pub fn parse_poll(body: &str) -> Poll {
let raw: RawToken = match serde_json::from_str(body) {
Ok(raw) => raw,
Err(e) => return Poll::Error(format!("token json: {e}")),
};
if let Some(error) = raw.error.filter(|e| !e.is_empty()) {
return match error.as_str() {
"authorization_pending" => Poll::Pending,
"slow_down" => Poll::SlowDown,
"access_denied" => Poll::Denied,
"expired_token" => Poll::Expired,
other => Poll::Error(other.to_string()),
};
}
if raw.access_token.is_empty() {
return Poll::Error("token response had no access_token".into());
}
Poll::Granted(Tokens {
access_token: raw.access_token,
refresh_token: raw.refresh_token,
expires_in: raw.expires_in,
})
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into()
}
fn post_form(agent: &ureq::Agent, url: &str, fields: &[(&str, &str)]) -> Result<String, String> {
let body = fields
.iter()
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
.collect::<Vec<_>>()
.join("&");
let mut resp = agent
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.send(body.as_bytes())
.map_err(|e| format!("request: {e}"))?;
resp.body_mut().read_to_string().map_err(|e| format!("read: {e}"))
}
pub fn request_device_code(creds: &Credentials, scope: &str) -> Result<DeviceCode, String> {
let body = post_form(&agent(), DEVICE_CODE_URL, &[("client_id", &creds.client_id), ("scope", scope)])?;
parse_device_code(&body)
}
pub fn poll_once(creds: &Credentials, device_code: &str) -> Poll {
match post_form(
&agent(),
TOKEN_URL,
&[
("client_id", &creds.client_id),
("client_secret", &creds.client_secret),
("device_code", device_code),
("grant_type", GRANT_DEVICE),
],
) {
Ok(body) => parse_poll(&body),
Err(e) => Poll::Error(e),
}
}
pub fn refresh(creds: &Credentials, refresh_token: &str) -> Result<Tokens, String> {
let body = post_form(
&agent(),
TOKEN_URL,
&[
("client_id", &creds.client_id),
("client_secret", &creds.client_secret),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
],
)?;
match parse_poll(&body) {
Poll::Granted(mut tokens) => {
tokens.refresh_token.get_or_insert_with(|| refresh_token.to_string());
Ok(tokens)
}
Poll::Error(e) => Err(e),
other => Err(format!("refresh rejected: {other:?}")),
}
}
pub fn load_session(account: Account) -> Option<Session> {
let path = session_path(account)?;
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn save_session(account: Account, session: &Session) -> std::io::Result<()> {
let Some(path) = session_path(account) else {
return Err(std::io::Error::other("no HOME for token store"));
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let body = serde_json::to_string(session).map_err(std::io::Error::other)?;
let temporary = path.with_extension(format!("tmp.{}", std::process::id()));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
let result = file.write_all(body.as_bytes()).and_then(|_| std::fs::rename(&temporary, &path));
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
pub fn clear_session(account: Account) {
if let Some(path) = session_path(account) {
let _ = std::fs::remove_file(path);
}
}
pub fn session_from(tokens: Tokens) -> Session {
Session {
refresh_token: tokens.refresh_token.unwrap_or_default(),
access_token: tokens.access_token,
expiry: now() + tokens.expires_in.saturating_sub(60),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn creds_path() -> Option<PathBuf> {
Some(home()?.join("medias").join("youtube_oauth.json"))
}
fn session_path(account: Account) -> Option<PathBuf> {
Some(home()?.join("medias").join(account.file()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_code_parses_with_interval_default() {
let body = r#"{"device_code":"DC","user_code":"ABCD-EFGH",
"verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"#;
let dc = parse_device_code(body).unwrap();
assert_eq!(dc.user_code, "ABCD-EFGH");
assert_eq!(dc.verification_url, "https://www.google.com/device");
assert_eq!((dc.interval, dc.expires_in), (5, 1800));
let body = r#"{"device_code":"DC","user_code":"X","verification_uri":"https://g.co/d"}"#;
let dc = parse_device_code(body).unwrap();
assert_eq!(dc.verification_url, "https://g.co/d");
assert_eq!(dc.interval, 5);
}
#[test]
fn scan_url_prefers_complete_then_synthesizes_from_user_code() {
let mut dc = parse_device_code(
r#"{"device_code":"DC","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device"}"#,
)
.unwrap();
assert_eq!(dc.scan_url(), "https://www.google.com/device?user_code=ABCD-EFGH");
dc.verification_url_complete = "https://www.google.com/device?user_code=ABCD-EFGH".into();
assert_eq!(dc.scan_url(), dc.verification_url_complete);
}
#[test]
fn qr_matrix_is_square_and_dense() {
let (n, modules) = qr_matrix("https://www.google.com/device?user_code=ABCD-EFGH").unwrap();
assert!(n >= 21 && modules.len() == n * n);
assert!(modules.iter().any(|&m| m) && modules.iter().any(|&m| !m));
}
#[test]
fn poll_classifies_each_state() {
assert_eq!(parse_poll(r#"{"error":"authorization_pending"}"#), Poll::Pending);
assert_eq!(parse_poll(r#"{"error":"slow_down"}"#), Poll::SlowDown);
assert_eq!(parse_poll(r#"{"error":"access_denied"}"#), Poll::Denied);
assert_eq!(parse_poll(r#"{"error":"expired_token"}"#), Poll::Expired);
match parse_poll(r#"{"error":"invalid_client"}"#) {
Poll::Error(e) => assert_eq!(e, "invalid_client"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn poll_returns_granted_tokens() {
let body = r#"{"access_token":"AT","refresh_token":"RT","expires_in":3599,"token_type":"Bearer"}"#;
match parse_poll(body) {
Poll::Granted(t) => {
assert_eq!(t.access_token, "AT");
assert_eq!(t.refresh_token.as_deref(), Some("RT"));
assert_eq!(t.expires_in, 3599);
}
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn session_from_tokens_sets_expiry_in_the_future() {
let s = session_from(Tokens { access_token: "AT".into(), refresh_token: Some("RT".into()), expires_in: 3600 });
assert_eq!(s.refresh_token, "RT");
assert!(!s.expired());
let past = Session { refresh_token: "RT".into(), access_token: "AT".into(), expiry: 0 };
assert!(past.expired());
}
#[test]
fn session_round_trips_through_json() {
let s = Session { refresh_token: "RT".into(), access_token: "AT".into(), expiry: 123 };
let text = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Session>(&text).unwrap(), s);
}
}