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

Correctly handle twitch subscriber emotes #591

Merged
merged 4 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 124 additions & 50 deletions src/emotes/downloader.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use color_eyre::Result;
use futures::StreamExt;
use reqwest::Client;
use reqwest::{Client, Response};
use std::{borrow::BorrowMut, collections::HashMap, path::Path};
use tokio::io::AsyncWriteExt;

use crate::{
emotes::DownloadedEmotes,
handlers::config::{CompleteConfig, FrontendConfig},
twitch::oauth::{get_channel_id, get_twitch_client},
twitch::oauth::{get_channel_id, get_twitch_client, get_twitch_client_id},
utils::pathing::cache_path,
};

Expand All @@ -17,64 +17,89 @@
mod twitch {
use crate::emotes::downloader::EmoteMap;
use color_eyre::Result;
use log::warn;
use reqwest::Client;
use serde::Deserialize;

#[derive(Deserialize)]
struct Image {
url_1x: String,
}

#[derive(Deserialize)]
#[derive(Deserialize, Debug)]

Check warning on line 24 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L24

Added line #L24 was not covered by tests
struct Emote {
id: String,
name: String,
images: Image,
format: Vec<String>,
scale: Vec<String>,
theme_mode: Vec<String>,
}

#[derive(Deserialize)]
#[derive(Deserialize, Debug)]

Check warning on line 33 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L33

Added line #L33 was not covered by tests
struct EmoteList {
data: Vec<Emote>,
template: String,
}

pub async fn get_emotes(client: &Client, channel_id: i32) -> Result<EmoteMap> {
let channel_emotes = client
.get(format!(
"https://api.twitch.tv/helix/chat/emotes?broadcaster_id={channel_id}",
))
.send()
.await?
.error_for_status()?
.json::<EmoteList>()
.await?
.data;
fn parse_emote_list(v: EmoteList) -> EmoteMap {
let template = v.template;

Check warning on line 40 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L39-L40

Added lines #L39 - L40 were not covered by tests

v.data
.into_iter()
.filter_map(|emote| {
let url = template.replace("{{id}}", &emote.id);

Check warning on line 45 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L42-L45

Added lines #L42 - L45 were not covered by tests

let url = url.replace(
"{{format}}",
if emote.format.contains(&String::from("animated")) {
"animated"

Check warning on line 50 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L47-L50

Added lines #L47 - L50 were not covered by tests
} else {
emote.format.first()?

Check warning on line 52 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L52

Added line #L52 was not covered by tests
},
);

let url = url.replace(
"{{theme_mode}}",
if emote.theme_mode.contains(&String::from("dark")) {
"dark"

Check warning on line 59 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L56-L59

Added lines #L56 - L59 were not covered by tests
} else {
emote.theme_mode.first()?

Check warning on line 61 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L61

Added line #L61 was not covered by tests
},
);

let url = url.replace(
"{{scale}}",
if emote.scale.contains(&String::from("1.0")) {
"1.0"

Check warning on line 68 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L65-L68

Added lines #L65 - L68 were not covered by tests
} else {
emote.scale.first()?

Check warning on line 70 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L70

Added line #L70 was not covered by tests
},
);

Some((emote.name, (emote.id, url, false)))
})
.collect()
}

Check warning on line 77 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L74-L77

Added lines #L74 - L77 were not covered by tests

pub async fn get_global_emotes(client: &Client) -> Result<EmoteMap> {

Check warning on line 79 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L79

Added line #L79 was not covered by tests
let global_emotes = client
.get("https://api.twitch.tv/helix/chat/emotes/global")
.send()
.await?
.error_for_status()?
.json::<EmoteList>()
.await?
.data;
.await?;

Check warning on line 86 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L86

Added line #L86 was not covered by tests

Ok(channel_emotes
.into_iter()
.chain(global_emotes)
.map(|emote| {
let (id, url) = if emote.format.contains(&String::from("animated")) {
(
emote.id + "-animated",
emote.images.url_1x.replace("/static/", "/animated/"),
)
} else {
(emote.id, emote.images.url_1x)
};
Ok(parse_emote_list(global_emotes))
}

Check warning on line 89 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L88-L89

Added lines #L88 - L89 were not covered by tests

(emote.name, (id, url, false))
})
.collect())
pub async fn get_user_emotes(client: &Client, user_id: &str) -> Result<EmoteMap> {
let user_emotes = client
.get(format!(
"https://api.twitch.tv/helix/chat/emotes/user?user_id={user_id}",
))
.send()
.await?
.error_for_status().map_err(|e| { warn!("Unable to get user emotes, please verify that the access token includes the user:read:emotes scope."); e})?
.json::<EmoteList>()
.await?;

Check warning on line 100 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L91-L100

Added lines #L91 - L100 were not covered by tests

Ok(parse_emote_list(user_emotes))

Check warning on line 102 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L102

Added line #L102 was not covered by tests
}
}

Expand Down Expand Up @@ -313,6 +338,16 @@
}
}

async fn save_emote(path: &Path, mut res: Response) -> Result<()> {
let mut file = tokio::fs::File::create(&path).await?;

Check warning on line 342 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L341-L342

Added lines #L341 - L342 were not covered by tests

while let Some(mut item) = res.chunk().await? {
file.write_all_buf(item.borrow_mut()).await?;

Check warning on line 345 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L344-L345

Added lines #L344 - L345 were not covered by tests
}

Ok(())
}

Check warning on line 349 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L348-L349

Added lines #L348 - L349 were not covered by tests

async fn download_emotes(emotes: EmoteMap) -> DownloadedEmotes {
let client = &Client::new();

Expand All @@ -329,13 +364,9 @@
return Ok((x, (filename, o)));
}

let mut res = client.get(&url).send().await?.error_for_status()?;
let res = client.get(&url).send().await?.error_for_status()?;

Check warning on line 367 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L367

Added line #L367 was not covered by tests

let mut file = tokio::fs::File::create(&path).await?;

while let Some(mut item) = res.chunk().await? {
file.write_all_buf(item.borrow_mut()).await?;
}
save_emote(path, res).await?;

Check warning on line 369 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L369

Added line #L369 was not covered by tests

Ok((x, (filename, o)))
}),
Expand All @@ -348,6 +379,7 @@
.collect()
}

#[derive(Eq, PartialEq)]
enum EmoteProvider {
Twitch,
BetterTTV,
Expand All @@ -374,21 +406,33 @@
providers
}

pub async fn get_emotes(config: &CompleteConfig, channel: &str) -> Result<DownloadedEmotes> {
pub async fn get_emotes(
config: &CompleteConfig,
channel: &str,
) -> Result<(DownloadedEmotes, DownloadedEmotes)> {

Check warning on line 412 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L409-L412

Added lines #L409 - L412 were not covered by tests
// Reuse the same client and headers for twitch requests
let twitch_client = get_twitch_client(config.twitch.token.clone()).await?;
let twitch_client = get_twitch_client(config.twitch.token.as_deref()).await?;
let user_id = &get_twitch_client_id(None).await?.user_id;

Check warning on line 415 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L414-L415

Added lines #L414 - L415 were not covered by tests

let channel_id = get_channel_id(&twitch_client, channel).await?;

let enabled_emotes = get_enabled_emote_providers(&config.frontend);

let twitch_get_emotes = |c: i32| twitch::get_emotes(&twitch_client, c);
let user_emotes = if enabled_emotes.contains(&EmoteProvider::Twitch) {
twitch::get_user_emotes(&twitch_client, user_id)
.await
.unwrap_or_default()

Check warning on line 424 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L421-L424

Added lines #L421 - L424 were not covered by tests
} else {
HashMap::default()

Check warning on line 426 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L426

Added line #L426 was not covered by tests
};

let twitch_get_global_emotes = || twitch::get_global_emotes(&twitch_client);

Check warning on line 429 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L429

Added line #L429 was not covered by tests

// Concurrently get the list of emotes for each provider
let emotes =
let global_emotes =

Check warning on line 432 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L432

Added line #L432 was not covered by tests
futures::stream::iter(enabled_emotes.into_iter().map(|emote_provider| async move {
match emote_provider {
EmoteProvider::Twitch => twitch_get_emotes(channel_id).await,
EmoteProvider::Twitch => twitch_get_global_emotes().await,

Check warning on line 435 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L435

Added line #L435 was not covered by tests
EmoteProvider::BetterTTV => betterttv::get_emotes(channel_id).await,
EmoteProvider::SevenTV => seventv::get_emotes(channel_id).await,
EmoteProvider::FrankerFaceZ => frankerfacez::get_emotes(channel_id).await,
Expand All @@ -402,5 +446,35 @@
.flatten()
.collect::<EmoteMap>();

Ok(download_emotes(emotes).await)
Ok((
download_emotes(user_emotes).await,
download_emotes(global_emotes).await,

Check warning on line 451 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L449-L451

Added lines #L449 - L451 were not covered by tests
))
}

Check warning on line 453 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L453

Added line #L453 was not covered by tests

pub async fn get_twitch_emote(name: &str) -> Result<()> {
// Checks if emote is already downloaded.
let path = cache_path(name);
let path = Path::new(&path);

if tokio::fs::metadata(&path).await.is_ok() {
return Ok(());
}

// Download it if it is not in the cache, try the animated version first.
let url = format!("https://static-cdn.jtvnw.net/emoticons/v2/{name}/animated/light/1.0");
let client = Client::new();
let res = client.get(&url).send().await?.error_for_status();

Check warning on line 467 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L455-L467

Added lines #L455 - L467 were not covered by tests

let res = if res.is_err() {
client
.get(&url.replace("animated", "static"))
.send()
.await?
.error_for_status()

Check warning on line 474 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L469-L474

Added lines #L469 - L474 were not covered by tests
} else {
res
}?;

Check warning on line 477 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L476-L477

Added lines #L476 - L477 were not covered by tests

save_emote(path, res).await

Check warning on line 479 in src/emotes/downloader.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/downloader.rs#L479

Added line #L479 was not covered by tests
}
23 changes: 18 additions & 5 deletions src/emotes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
mod downloader;
mod graphics_protocol;

pub use downloader::get_twitch_emote;
pub use graphics_protocol::{support_graphics_protocol, ApplyCommand, DecodedEmote};

// HashMap of emote name, emote filename, and if the emote is an overlay
Expand All @@ -50,8 +51,12 @@

#[derive(Default, Debug)]
pub struct Emotes {
/// Map of emote name, filename, and if the emote is an overlay
pub emotes: RefCell<DownloadedEmotes>,
/// Map of emote name, filename, and if the emote is an overlay.
/// We keep track of both emotes that can be used by the current user, and emotes that can be used and received.
/// `user_emotes` is only used in the emote picker, and when the current user sends a message.
/// `global_emotes` is used everywhere.
pub user_emotes: RefCell<DownloadedEmotes>,
pub global_emotes: RefCell<DownloadedEmotes>,
/// Info about loaded emotes
pub info: RefCell<HashMap<String, LoadedEmote>>,
/// Terminal cell size in pixels: (width, height)
Expand All @@ -76,7 +81,8 @@
.for_each(|(_, LoadedEmote { hash, .. })| {
graphics_protocol::Clear(*hash).apply().unwrap_or_default();
});
self.emotes.borrow_mut().clear();
self.user_emotes.borrow_mut().clear();
self.global_emotes.borrow_mut().clear();

Check warning on line 85 in src/emotes/mod.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/mod.rs#L84-L85

Added lines #L84 - L85 were not covered by tests
self.info.borrow_mut().clear();
}
}
Expand All @@ -91,7 +97,10 @@
}
}

pub fn query_emotes(config: &CompleteConfig, channel: String) -> OSReceiver<DownloadedEmotes> {
pub fn query_emotes(
config: &CompleteConfig,
channel: String,
) -> OSReceiver<(DownloadedEmotes, DownloadedEmotes)> {

Check warning on line 103 in src/emotes/mod.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/mod.rs#L100-L103

Added lines #L100 - L103 were not covered by tests
let (tx, mut rx) = tokio::sync::oneshot::channel();

if emotes_enabled(&config.frontend) {
Expand All @@ -104,7 +113,11 @@
rx
}

pub async fn send_emotes(config: &CompleteConfig, tx: OSSender<DownloadedEmotes>, channel: String) {
pub async fn send_emotes(
config: &CompleteConfig,
tx: OSSender<(DownloadedEmotes, DownloadedEmotes)>,
channel: String,
) {

Check warning on line 120 in src/emotes/mod.rs

View check run for this annotation

Codecov / codecov/patch

src/emotes/mod.rs#L116-L120

Added lines #L116 - L120 were not covered by tests
info!("Starting emotes download.");
match get_emotes(config, &channel).await {
Ok(emotes) => {
Expand Down
Loading
Loading