Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/streamlink and live #659

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
65 changes: 61 additions & 4 deletions src/handlers/app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use std::{cell::RefCell, collections::VecDeque, rc::Rc};
use std::{
cell::RefCell,
collections::VecDeque,
process::{Child, Stdio},
rc::Rc,
};

use chrono::{DateTime, Local};
use rustyline::line_buffer::LineBuffer;
Expand All @@ -18,6 +23,7 @@ use crate::{
user_input::events::{Event, Key},
},
terminal::TerminalAction,
twitch::TwitchAction,
ui::{
components::{Component, Components},
statics::LINE_BUFFER_CAPACITY,
Expand Down Expand Up @@ -50,6 +56,10 @@ pub struct App {
pub theme: Theme,
/// Emotes
pub emotes: SharedEmotes,
/// Running stream.
// TODO:
// Review if this needs to be a `Rc<RefCell>`. I haven't bothered to check
pub running_stream: Rc<RefCell<Option<Child>>>,
}

macro_rules! shared {
Expand Down Expand Up @@ -93,6 +103,7 @@ impl App {
Self {
components,
config: shared_config.clone(),
running_stream: shared!(None),
messages,
storage,
filters,
Expand Down Expand Up @@ -143,10 +154,15 @@ impl App {
}
}

pub async fn event(&mut self, event: &Event) -> Option<TerminalAction> {
pub async fn event(&mut self, event: &Event) -> Option<TerminalAction<TwitchAction>> {
if let Event::Input(key) = event {
if self.components.debug.is_focused() {
return self.components.debug.event(event).await;
return self
.components
.debug
.event(event)
.await
.map(|ta| ta.map_enter(|()| TwitchAction::Join("".into())));
}

match key {
Expand All @@ -158,7 +174,12 @@ impl App {
return match self.state {
State::Dashboard => self.components.dashboard.event(event).await,
State::Normal => self.components.chat.event(event).await,
State::Help => self.components.help.event(event).await,
State::Help => self
.components
.help
.event(event)
.await
.map(|ta| ta.map_enter(|()| TwitchAction::Join("".into()))),
};
}
}
Expand All @@ -167,7 +188,43 @@ impl App {
None
}

// TODO:
// Should Properly handle if a stream is not available.
// WARN:
// closes a previous stream if open. This is technically overloading this function, but
// whatever.
pub fn open_stream(&self, channel: &str) {
let mut t = self.running_stream.borrow_mut();
if let Some(c) = t.as_mut() {
c.kill().unwrap();
}
*t = Some(
std::process::Command::new("streamlink")
.args([
(String::from("twitch.tv/") + channel).as_str(),
"--default-stream",
"720p, 720p60, best",
"--player",
"mpv",
])
.stdout(Stdio::null())
.spawn()
.expect("Pog"),
);
}

// TODO:
// This probably sucks
pub fn close_stream(&self) {
let mut t = self.running_stream.borrow_mut();
if let Some(c) = t.as_mut() {
c.kill().unwrap();
}
*t = None;
}

pub fn cleanup(&self) {
self.close_stream();
self.storage.borrow().dump_data();
self.emotes.unload();
}
Expand Down
6 changes: 6 additions & 0 deletions src/handlers/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ pub struct FiltersConfig {
pub struct FrontendConfig {
/// If the time and date is to be shown.
pub show_datetimes: bool,
/// Play stream with `streamlink` upon join a channel.
pub auto_start_streamlink: bool,
/// Only shows currently streaming channels instead of all following channels
pub only_show_live_channels: bool,
/// The format of string that will show up in the terminal.
pub datetime_format: String,
/// If the username should be shown.
Expand Down Expand Up @@ -169,6 +173,8 @@ impl Default for FrontendConfig {
fn default() -> Self {
Self {
show_datetimes: true,
only_show_live_channels: true,
auto_start_streamlink: false,
datetime_format: "%a %b %e %T %Y".to_string(),
username_shown: true,
palette: Palette::default(),
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl FromStr for NormalMode {
}
}

#[derive(Debug, PartialEq, Eq, Clone, Serialize, DeserializeFromStr)]
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, DeserializeFromStr)]
pub enum State {
Dashboard,
Normal,
Expand Down
22 changes: 20 additions & 2 deletions src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ use crate::{
utils::emotes::emotes_enabled,
};

pub enum TerminalAction {
pub enum TerminalAction<T> {
Quit,
BackOneLayer,
SwitchState(State),
ClearMessages,
Enter(TwitchAction),
Enter(T),
}

impl<T> TerminalAction<T> {
pub fn map_enter<B, F: Fn(&T) -> B>(&self, a_map: F) -> TerminalAction<B> {
match self {
TerminalAction::Quit => TerminalAction::Quit,
TerminalAction::BackOneLayer => TerminalAction::BackOneLayer,
TerminalAction::SwitchState(s) => TerminalAction::SwitchState(*s),
TerminalAction::ClearMessages => TerminalAction::ClearMessages,
TerminalAction::Enter(a) => TerminalAction::Enter(a_map(a)),
}
}
}

pub async fn ui_driver(
Expand Down Expand Up @@ -181,10 +193,16 @@ pub async fn ui_driver(
app.emotes.unload();

tx.send(TwitchAction::Join(channel.clone())).unwrap();

if config.frontend.auto_start_streamlink {
app.open_stream(channel.as_str());
}

erx = query_emotes(&config, channel);

app.set_state(State::Normal);
}

TwitchAction::ClearMessages => {}
},
}
Expand Down
119 changes: 104 additions & 15 deletions src/twitch/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ use std::{
};

use color_eyre::Result;
use reqwest::Client;
use futures::TryFutureExt;
use serde::Deserialize;

use crate::{handlers::config::TwitchConfig, ui::components::utils::SearchItemGetter};
use crate::{
handlers::config::TwitchConfig,
ui::components::utils::{SearchItemGetter, ToQueryString},
};

use super::oauth::{get_twitch_client, get_twitch_client_id};

Expand All @@ -23,12 +26,68 @@ pub struct FollowingUser {
followed_at: String,
}

impl ToQueryString for FollowingUser {
fn to_query_string(&self) -> String {
self.broadcaster_name.clone()
}
}

// "id": "42170724654",
// "user_id": "132954738",
// "user_login": "aws",
// "user_name": "AWS",
// "game_id": "417752",
// "game_name": "Talk Shows & Podcasts",
// "type": "live",
// "title": "AWS Howdy Partner! Y'all welcome ExtraHop to the show!",
// "viewer_count": 20,
// "started_at": "2021-03-31T20:57:26Z",
// "language": "en",
// "thumbnail_url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_aws-{width}x{height}.jpg",
// "tag_ids": [],
// "tags": ["English"]
#[derive(Deserialize, Debug, Clone, Default)]
#[allow(dead_code)]
pub struct StreamingUser {
pub user_login: String,
pub game_name: String,
pub title: String,
}

impl Display for StreamingUser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
user_login,
game_name,
title,
} = self;
let fmt_game = format!("[{game_name:.22}]");
write!(f, "{user_login:<16.16}: {fmt_game:<24} {title}",)
}
}

impl ToQueryString for StreamingUser {
fn to_query_string(&self) -> String {
self.user_login.clone()
}
}

#[derive(Deserialize, Debug, Clone, Default)]
#[allow(dead_code)]
pub struct StreamingList {
pub data: Vec<StreamingUser>,
pagination: Pagination,
}

impl Display for FollowingUser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.broadcaster_login)
}
}

// data Object[] The list of streams.
// pagination Object The information used to page through the list of results. The object is empty if there are no more pages left to page through. Read More
// cursor String The cursor used to get the next page of results. Set the request’s after or before query parameter to this value depending on whether you’re paging forwards or backwards.
#[derive(Deserialize, Debug, Clone, Default)]
#[allow(dead_code)]
struct Pagination {
Expand All @@ -52,8 +111,20 @@ pub struct Following {
list: FollowingList,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FollowingStreaming {
// TODO: Don't re-create client on new requests
// client: &Client,
twitch_config: TwitchConfig,
list: StreamingList,
}

// https://dev.twitch.tv/docs/api/reference/#get-followed-channels
pub async fn get_user_following(client: &Client, user_id: &str) -> Result<FollowingList> {
pub async fn get_following(twitch_config: &TwitchConfig) -> Result<FollowingList> {
let client = get_twitch_client(twitch_config.token.as_deref()).await?;
let user_id = &get_twitch_client_id(None).await?.user_id;

Ok(client
.get(format!(
"https://api.twitch.tv/helix/channels/followed?user_id={user_id}&first={FOLLOWER_COUNT}",
Expand All @@ -65,11 +136,36 @@ pub async fn get_user_following(client: &Client, user_id: &str) -> Result<Follow
.await?)
}

pub async fn get_following(twitch_config: &TwitchConfig) -> Result<FollowingList> {
// https://dev.twitch.tv/docs/api/reference/#get-followed-streams
pub async fn get_streams(twitch_config: &TwitchConfig) -> Result<StreamingList> {
let client = get_twitch_client(twitch_config.token.as_deref()).await?;
let user_id = &get_twitch_client_id(None).await?.user_id;

get_user_following(&client, user_id).await
let res = client
.clone()
.get(format!(
"https://api.twitch.tv/helix/streams/followed?user_id={user_id}&first={FOLLOWER_COUNT}",
))
.send()
.await?
.error_for_status()?;

Ok(res.json::<StreamingList>().await?)
}

impl FollowingStreaming {
pub fn new(twitch_config: TwitchConfig) -> Self {
Self {
twitch_config,
list: StreamingList::default(),
}
}
}

impl SearchItemGetter<StreamingUser> for FollowingStreaming {
async fn get_items(&mut self) -> Result<Vec<StreamingUser>> {
get_streams(&self.twitch_config).await.map(|x| x.data)
}
}

impl Following {
Expand All @@ -81,15 +177,8 @@ impl Following {
}
}

impl SearchItemGetter<String> for Following {
async fn get_items(&mut self) -> Result<Vec<String>> {
let following = get_following(&self.twitch_config).await;

following.map(|v| {
v.data
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
})
impl SearchItemGetter<FollowingUser> for Following {
async fn get_items(&mut self) -> Result<Vec<FollowingUser>> {
get_following(&self.twitch_config).await.map(|v| v.data)
}
}
2 changes: 2 additions & 0 deletions src/twitch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod oauth;

use std::{collections::HashMap, hash::BuildHasher};

use channels::StreamingUser;
use color_eyre::Result;
use futures::StreamExt;
use irc::{
Expand Down Expand Up @@ -120,6 +121,7 @@ pub async fn twitch_irc(
tx.send(data_builder.twitch(err.to_string())).await.unwrap();
}


// Set old channel to new channel
config.twitch.channel = channel;
}
Expand Down
4 changes: 2 additions & 2 deletions src/ui/components/channel_switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ impl Display for ChannelSwitcherWidget {
}
}

impl Component for ChannelSwitcherWidget {
impl Component<TwitchAction> for ChannelSwitcherWidget {
fn draw(&mut self, f: &mut Frame, area: Option<Rect>) {
let mut r = area.map_or_else(|| centered_rect(60, 60, 23, f.area()), |a| a);
// Make sure we have space for the input widget, which has a height of 3.
Expand Down Expand Up @@ -256,7 +256,7 @@ impl Component for ChannelSwitcherWidget {
self.search_input.draw(f, Some(input_rect));
}

async fn event(&mut self, event: &Event) -> Option<TerminalAction> {
async fn event(&mut self, event: &Event) -> Option<TerminalAction<TwitchAction>> {
if let Event::Input(key) = event {
match key {
Key::Esc => {
Expand Down
Loading
Loading