use std::path::PathBuf;
use std::time::Duration;
use crate::youtube::model::{self, Channel, Mine, Playlist, Video};
const BASE: &str = "https://www.googleapis.com/youtube/v3";
pub const PAGE_SIZE: u32 = 25;
pub struct Client {
agent: ureq::Agent,
token: Option<String>,
key: Option<String>,
}
impl Client {
pub fn new(token: Option<String>, key: Option<String>) -> Client {
let agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into();
Client { agent, token, key }
}
pub fn usable(&self) -> bool {
self.token.is_some() || self.key.is_some()
}
pub fn search(&self, query: &str) -> Result<Vec<Video>, String> {
let n = PAGE_SIZE.to_string();
let body = self.get_cached(
"search",
&[("part", "snippet"), ("type", "video"), ("maxResults", &n), ("q", query)],
Duration::from_secs(600),
)?;
Ok(model::videos(&body)?.items)
}
pub fn trending(&self) -> Result<Vec<Video>, String> {
let n = PAGE_SIZE.to_string();
let body = self.get_cached(
"videos",
&[
("part", "snippet,contentDetails"),
("chart", "mostPopular"),
("maxResults", &n),
],
Duration::from_secs(1800),
)?;
Ok(model::videos(&body)?.items)
}
pub fn channel_uploads(&self, channel_id: &str) -> Result<Vec<Video>, String> {
self.playlist_videos(&uploads_playlist_id(channel_id))
}
pub fn playlist_videos(&self, playlist_id: &str) -> Result<Vec<Video>, String> {
let n = PAGE_SIZE.to_string();
let body = self.get_cached(
"playlistItems",
&[
("part", "snippet,contentDetails"),
("playlistId", playlist_id),
("maxResults", &n),
],
Duration::from_secs(900),
)?;
Ok(model::videos(&body)?.items)
}
pub fn subscriptions(&self) -> Result<Vec<Channel>, String> {
let n = PAGE_SIZE.to_string();
let body = self.get_cached(
"subscriptions",
&[
("part", "snippet"),
("mine", "true"),
("order", "alphabetical"),
("maxResults", &n),
],
Duration::from_secs(1800),
)?;
Ok(model::subscriptions(&body)?.items)
}
pub fn playlists(&self) -> Result<Vec<Playlist>, String> {
let n = PAGE_SIZE.to_string();
let body = self.get_cached(
"playlists",
&[("part", "snippet,contentDetails"), ("mine", "true"), ("maxResults", &n)],
Duration::from_secs(1800),
)?;
Ok(model::playlists(&body)?.items)
}
pub fn mine(&self) -> Result<Mine, String> {
let body = self.get_cached(
"channels",
&[("part", "snippet,contentDetails"), ("mine", "true")],
Duration::from_secs(3600),
)?;
model::mine(&body)
}
fn get_cached(&self, endpoint: &str, params: &[(&str, &str)], ttl: Duration) -> Result<String, String> {
let cache = cache_path(endpoint, params);
if let Some(body) = cache.as_ref().and_then(|p| read_fresh(p, ttl)) {
return Ok(body);
}
let body = self.get(endpoint, params)?;
if let Some(path) = &cache {
write_cache(path, &body);
}
Ok(body)
}
fn get(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
let url = self.url(endpoint, params);
let mut req = self.agent.get(&url);
if let Some(token) = &self.token {
req = req.header("Authorization", &format!("Bearer {token}"));
}
let mut resp = req.call().map_err(|e| format!("request: {e}"))?;
resp.body_mut().read_to_string().map_err(|e| format!("read: {e}"))
}
fn url(&self, endpoint: &str, params: &[(&str, &str)]) -> String {
let key = self.key.as_deref().map(|k| ("key", k));
build_url(endpoint, params, key)
}
}
pub fn uploads_playlist_id(channel_id: &str) -> String {
match channel_id.strip_prefix("UC") {
Some(rest) => format!("UU{rest}"),
None => channel_id.to_string(),
}
}
pub const LIKED_PLAYLIST: &str = "LL";
fn build_url(endpoint: &str, params: &[(&str, &str)], extra: Option<(&str, &str)>) -> String {
let mut url = format!("{BASE}/{endpoint}?");
let mut first = true;
let push = |url: &mut String, first: &mut bool, k: &str, v: &str| {
if !*first {
url.push('&');
}
*first = false;
url.push_str(k);
url.push('=');
url.push_str(&encode(v));
};
for (k, v) in params {
push(&mut url, &mut first, k, v);
}
if let Some((k, v)) = extra {
push(&mut url, &mut first, k, v);
}
url
}
pub(crate) fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn cache_dir() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join("medias").join(".cache").join("api"))
}
fn cache_path(endpoint: &str, params: &[(&str, &str)]) -> Option<PathBuf> {
let mut hash = 0xcbf29ce484222325u64; for bytes in std::iter::once(endpoint.as_bytes())
.chain(params.iter().flat_map(|(k, v)| [k.as_bytes(), v.as_bytes()]))
{
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
}
Some(cache_dir()?.join(format!("{endpoint}-{hash:016x}.json")))
}
fn read_fresh(path: &PathBuf, ttl: Duration) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
let age = meta.modified().ok()?.elapsed().ok()?;
(age < ttl).then(|| std::fs::read_to_string(path).ok()).flatten()
}
fn write_cache(path: &PathBuf, body: &str) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, body);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uploads_playlist_swaps_uc_prefix() {
assert_eq!(uploads_playlist_id("UC1a2b3c"), "UU1a2b3c");
assert_eq!(uploads_playlist_id("PLsomething"), "PLsomething");
}
#[test]
fn query_values_are_percent_encoded() {
assert_eq!(encode("lofi hip hop"), "lofi%20hip%20hop");
assert_eq!(encode("a&b=c"), "a%26b%3Dc");
assert_eq!(encode("plain-TEXT_1.0~"), "plain-TEXT_1.0~");
}
#[test]
fn url_includes_params_and_optional_key() {
let url = build_url("search", &[("q", "cats"), ("type", "video")], Some(("key", "K1")));
assert_eq!(
url,
"https://www.googleapis.com/youtube/v3/search?q=cats&type=video&key=K1"
);
let url = build_url("videos", &[("chart", "mostPopular")], None);
assert_eq!(url, "https://www.googleapis.com/youtube/v3/videos?chart=mostPopular");
}
}