@@ 1,19 1,22 @@
use anyhow::{anyhow, Context, Result};
use async_std::task;
-use rss::{ChannelBuilder, ItemBuilder};
+use rss::{ChannelBuilder, ImageBuilder, ItemBuilder};
use tide::Request;
use tracing::{debug, error, info, warn};
use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::twitch;
#[derive(Clone)]
struct State {
- api_key: String,
- account_cache: HashMap<String, String>,
+ twitch_auth: Arc<twitch::TwitchAuth>,
+ account_cache: HashMap<String, String>, // TODO use a TTL cache here
}
-pub fn run(listen_endpoint: &str, api_key: String) -> Result<()> {
+pub fn run(listen_endpoint: String, twitch_auth: twitch::TwitchAuth) -> Result<()> {
let state = State {
- api_key,
+ twitch_auth: Arc::new(twitch_auth),
account_cache: HashMap::new()
};
@@ 42,45 45,45 @@ async fn handle_index(req: Request<State>) -> tide::Result {
}
async fn handle_rss(req: Request<State>) -> tide::Result {
- // Step 1: Show user+code input form for loading details, with prefill from get params (email link)
let mut query_account = None;
+ let mut include_live = false;
for (key, val) in req.url().query_pairs() {
if key == "account" {
query_account = Some(val.to_string());
}
+ if key == "live" {
+ include_live = true;
+ }
}
match query_account {
Some(account) => {
+ // TODO check userid cache, then try get_user_id
+ // TODO check video cache, then try get_videos
+ let user = twitch::get_user(&mut req.state().twitch_auth, account).await?;
+ let videos = twitch::get_videos(&mut req.state().twitch_auth, user.id).await?;
let mut items = vec![];
- // TODO fetch items from twitch over HTTP client and record them here...
- let mut response = surf::get("http://example.com").body(req.state().api_key.as_str()).await?;
- if !response.status().is_success() {
- // Pass through underlying response as-is, so that RSS reader can see it
- let response_body = response.body_string().await
- .map_or_else(
- |err| format!("Error when fetching response body: {}", err),
- |body| body
- );
- return Ok(tide::Response::builder(response.status())
- .body(response_body)
- .content_type(response.content_type().unwrap_or(tide::http::mime::PLAIN))
- .build());
+ for video in videos {
+ items.push(
+ ItemBuilder::default()
+ .title(format!("{} [{}]", video.title, video.duration))
+ // TODO embed player in content?
+ .content(format!("<img src='{}'>", video.thumbnail_url))
+ .link(video.url)
+ .description(video.description)
+ .pub_date(video.published_at)
+ .build()
+ .map_err(|e| anyhow!("Error when rendering RSS item: {}", e))?
+ );
}
- // use req.state().api_key
- items.push(
- ItemBuilder::default()
- .title("TODO video title".to_string())
- .link(format!("http://twitch.tv/{}/video/idhere", account))
- .description(format!("TODO video description"))
- .pub_date(format!("1970-01-01T00:00:00Z")) // TODO RFC2822
- .build()
- .map_err(|e| anyhow!("Error when rendering RSS item: {}", e))?
- );
let channel = ChannelBuilder::default()
- .title(format!("Twitch: {}", account))
- .link(format!("https://twitch.tv/{}", account))
- .description(format!("twitch-rss feed for {}", account))
+ .title(format!("Twitch: {}", user.display_name))
+ .image(Some(ImageBuilder::default()
+ .url(user.profile_image_url)
+ .build()
+ .map_err(|e| anyhow!("Error when rendering RSS channel image: {}", e))?))
+ .link(format!("https://twitch.tv/{}", user.login))
+ .description(user.description)
.generator("twitch-rss".to_string())
.items(items)
.build()
@@ 0,0 1,252 @@
+use anyhow::{anyhow, bail, Result};
+use serde::{Deserialize, Serialize};
+
+struct AccountInfo {
+ name: String,
+ id: usize,
+}
+
+// Request parameters for GET id.twitch.tv/oauth2/token
+#[derive(Debug, Serialize)]
+struct LoginQuery {
+ client_id: String,
+ client_secret: String,
+ grant_type: String,
+}
+
+// Response to GET id.twitch.tv/oauth2/token
+#[derive(Debug, Deserialize)]
+struct LoginResponse {
+ access_token: String,
+ expires_in: String,
+ token_type: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct ErrorResponse {
+ error: String,
+ status: usize,
+ message: String,
+}
+
+// Request parameters for GET api.twitch.tv/helix/users
+#[derive(Debug, Serialize)]
+struct GetUsersQuery {
+ login: String,
+}
+
+// Response to GET api.twitch.tv/helix/users
+#[derive(Debug, Deserialize)]
+struct GetUsersResponse {
+ data: Vec<GetUsersEntry>,
+}
+
+/*
+"id":"689331234",
+"login":"nextlander",
+"display_name":"Nextlander",
+"type":"",
+"broadcaster_type":"partner",
+"description":"Come watch Vinny, Brad, and Alex play through the latest games, a slew of classics, and chat with you, the viewer!",
+"profile_image_url":"https://static-cdn.jtvnw.net/jtv_user_pictures/9b9f0a1e-6a0d-45bc-b42f-cfd09e297332-profile_image-300x300.png",
+"offline_image_url":"",
+"view_count":102251,
+"created_at":"2021-05-20T22:34:07.072555Z"
+*/
+#[derive(Debug, Deserialize)]
+pub struct GetUsersEntry {
+ pub id: usize,
+ pub login: String,
+ pub display_name: String,
+ pub description: String,
+ pub profile_image_url: String,
+}
+
+// Request parameters for GET api.twitch.tv/helix/videos
+#[derive(Debug, Serialize)]
+struct GetVideosQuery {
+ user_id: usize,
+}
+
+// Response to GET api.twitch.tv/helix/videos
+#[derive(Debug, Deserialize)]
+struct GetVideosResponse {
+ data: Vec<GetVideosEntry>,
+}
+
+/*
+"id": "1057165773",
+"stream_id": "42361701644",
+"user_id": "689331234",
+"user_login": "nextlander",
+"user_name": "Nextlander",
+"title": "Nextlander @ E3: The Fresh Demos!",
+"description": "",
+"created_at": "2021-06-15T18:28:59Z",
+"published_at": "2021-06-15T18:28:59Z",
+"url": "https://www.twitch.tv/videos/1057165773",
+"thumbnail_url": "https://static-cdn.jtvnw.net/cf_vods/dgeft87wbj63p/81255e5739ae02f7ccc8_nextlander_42361701644_1623781727//thumb/thumb0-%{width}x%{height}.jpg",
+"viewable": "public",
+"view_count": 27163,
+"language": "en",
+"type": "archive",
+"duration": "3h11m2s",
+"muted_segments": null
+*/
+#[derive(Debug, Deserialize)]
+pub struct GetVideosEntry {
+ pub id: String,
+ pub title: String,
+ pub description: String, // may be empty
+ pub published_at: String,
+ pub url: String,
+ pub thumbnail_url: String,
+ pub duration: String,
+}
+
+pub struct TwitchAuth {
+ client_id: String,
+ client_secret: String,
+ access_token: Option<String>,
+}
+
+impl TwitchAuth {
+ pub fn new(client_id: String, client_secret: String) -> TwitchAuth {
+ TwitchAuth {
+ client_id,
+ client_secret,
+ access_token: None,
+ }
+ }
+
+ // see also https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#oauth-client-credentials-flow
+ async fn login(&mut self) -> Result<String> {
+ if let Some(token) = &self.access_token {
+ // Token is already set, assume that it's fine...
+ return Ok(token.clone());
+ }
+
+ let query = LoginQuery {
+ client_id: self.client_id.clone(),
+ client_secret: self.client_secret.clone(),
+ grant_type: "client_credentials".to_string(),
+ };
+ let mut response = surf::get("https://id.twitch.tv/oauth2/token")
+ .query(&query).map_err(|err| anyhow!(err))?
+ .send()
+ .await.map_err(|err| anyhow!(err))?;
+ if !response.status().is_success() {
+ let response_body = response.body_string().await
+ .map_or_else(
+ |err| format!("Error when fetching response body: {}", err),
+ |body| body
+ );
+ bail!("Failed to get Twitch auth token: {}", response_body);
+ }
+ let response_json: LoginResponse = response.body_json()
+ .await.map_err(|err| anyhow!(err))?;
+ if response_json.access_token.is_empty() {
+ bail!("Login response had empty access_token");
+ }
+
+ self.access_token = Some(response_json.access_token.clone());
+ return Ok(response_json.access_token);
+ }
+
+ // Returns true if the error was auth-related and the token was reset
+ async fn reset_if_auth_err(&mut self, response: &mut surf::Response) -> bool {
+ // Example errors to treat as needing a new login():
+ // {"error":"Unauthorized","status":401,"message":"Invalid OAuth token"}
+ // {"error":"Unauthorized","status":401,"message":"OAuth token is missing"}
+ // {"error":"Unauthorized","status":401,"message":"Client ID and OAuth token do not match"}
+ if response.status() != 401 {
+ return false;
+ }
+ if !response.body_string().await
+ .map_or_else(|_err| "".to_string(), |body| body)
+ .contains("OAuth token") {
+ return false;
+ }
+ self.access_token = None;
+ return true;
+ }
+}
+
+// see also https://dev.twitch.tv/docs/api/reference#get-users
+// curl -v -H 'Client-Id: CLIENT_ID' -H 'Authorization: Bearer AUTH_TOKEN' "https://api.twitch.tv/helix/users?login=USER_NAME"
+pub async fn get_user(auth: &mut TwitchAuth, user_name: String) -> Result<GetUsersEntry> {
+ let query = GetUsersQuery {
+ login: user_name.clone()
+ };
+ let mut response = surf::get("https://api.twitch.tv/helix/users")
+ .query(&query).map_err(|err| anyhow!(err))?
+ .header("Client-Id", auth.client_id.as_str())
+ .header("Authorization", format!("Bearer {}", auth.login().await?))
+ .send()
+ .await.map_err(|err| anyhow!(err))?;
+ if !response.status().is_success() {
+ if auth.reset_if_auth_err(&mut response).await {
+ // Assume auth token recently expired, try again with new token
+ response = surf::get("https://api.twitch.tv/helix/users")
+ .query(&query).map_err(|err| anyhow!(err))?
+ .header("Client-Id", auth.client_id.as_str())
+ .header("Authorization", format!("Bearer {}", auth.login().await?))
+ .send()
+ .await.map_err(|err| anyhow!(err))?;
+ }
+ if !response.status().is_success() {
+ let response_body = response.body_string().await
+ .map_or_else(
+ |err| format!("Error when fetching user response body: {}", err),
+ |body| body
+ );
+ bail!("Failed to get Twitch user id: {} {}", response.status(), response_body);
+ }
+ }
+ let mut response_json: GetUsersResponse = response.body_json()
+ .await.map_err(|err| anyhow!("Failed to read {} user response body as JSON: {}", user_name, err))?;
+ if response_json.data.len() > 1 {
+ bail!("Get users response for {} had {} results, expected 1", user_name, response_json.data.len());
+ }
+ if let Some(entry) = response_json.data.pop() {
+ Ok(entry)
+ } else {
+ bail!("Missing entry in users response for {}", user_name)
+ }
+}
+
+// see also https://dev.twitch.tv/docs/api/reference#get-videos
+// curl -v -H 'Client-Id: CLIENT_ID' -H 'Authorization: Bearer AUTH_TOKEN' "https://api.twitch.tv/helix/videos?user_id=USER_ID"
+pub async fn get_videos(auth: &mut TwitchAuth, user_id: usize) -> Result<Vec<GetVideosEntry>> {
+ let query = GetVideosQuery {
+ user_id
+ };
+ let mut response = surf::get("https://api.twitch.tv/helix/videos")
+ .query(&query).map_err(|err| anyhow!(err))?
+ .header("Client-Id", auth.client_id.as_str())
+ .header("Authorization", format!("Bearer {}", auth.login().await?))
+ .send()
+ .await.map_err(|err| anyhow!(err))?;
+ if !response.status().is_success() {
+ if auth.reset_if_auth_err(&mut response).await {
+ // Assume auth token recently expired, try again with new token
+ response = surf::get("https://api.twitch.tv/helix/videos")
+ .query(&query).map_err(|err| anyhow!(err))?
+ .header("Client-Id", auth.client_id.as_str())
+ .header("Authorization", format!("Bearer {}", auth.login().await?))
+ .send()
+ .await.map_err(|err| anyhow!(err))?;
+ }
+ if !response.status().is_success() {
+ let response_body = response.body_string().await
+ .map_or_else(
+ |err| format!("Error when fetching video response body: {}", err),
+ |body| body
+ );
+ bail!("Failed to get Twitch user videos: {} {}", response.status(), response_body);
+ }
+ }
+ let response_json: GetVideosResponse = response.body_json()
+ .await.map_err(|err| anyhow!("Failed to read {} video response body as JSON: {}", user_id, err))?;
+ return Ok(response_json.data);
+}