~nickbp/twitch-rss

c2c750e4a0226654ce89db759590b47322544cb5 — Nick Parker 3 months ago 38d1842
Twitch client mostly done, need to figure out auth token access in HTTP server
5 files changed, 299 insertions(+), 35 deletions(-)

M Cargo.lock
M Cargo.toml
M src/http.rs
M src/main.rs
A src/twitch.rs
M Cargo.lock => Cargo.lock +1 -0
@@ 1852,6 1852,7 @@ dependencies = [
 "async-std",
 "async-trait",
 "rss",
 "serde",
 "surf",
 "tide",
 "tracing",

M Cargo.toml => Cargo.toml +1 -0
@@ 17,6 17,7 @@ rss = "1.10"
async-std = "1.8"
# http client
surf = { version = "2.2", default-features = false, features = ["h1-client-rustls"] }
serde = "1.0"
# http server
async-trait = "0.1"
tide = { version = "0.16", default-features = false, features = ["h1-server"] }

M src/http.rs => src/http.rs +35 -32
@@ 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()

M src/main.rs => src/main.rs +10 -3
@@ 1,14 1,21 @@
mod http;
mod logging;
mod twitch;

use anyhow::{Context, Result};
use std::env;

use crate::twitch::TwitchAuth;

fn main() -> Result<()> {
    logging::init_logging();
    let listen_endpoint = getenv("LISTEN", "Endpoint for HTTP service to listen on")?;
    let api_key = getenv("API_KEY", "Twitch API key for querying feeds")?;
    http::run(listen_endpoint.as_str(), api_key)
    http::run(
        getenv("LISTEN", "Endpoint for HTTP service to listen on")?,
        TwitchAuth::new(
            getenv("CLIENT_ID", "Twitch Client ID for querying feeds")?,
            getenv("CLIENT_SECRET", "Twitch Client Secret for querying feeds")?
        )
    )
}

fn getenv(name: &str, desc: &str) -> Result<String> {

A src/twitch.rs => src/twitch.rs +252 -0
@@ 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);
}