1use discord_rich_presence::activity::{
2 ActivityType, Button as ActButton, StatusDisplayType as DiscordIpcStatusDisplayType,
3};
4use discord_rich_presence::{
5 activity::{Activity, Assets, Timestamps},
6 DiscordIpc, DiscordIpcClient,
7};
8pub use error::JfError;
9pub use jellyfin::{Button, MediaType};
10use jellyfin::{ExternalUrl, NowPlayingItem, PlayTime, RawSession, Session, VirtualFolder};
11use log::{debug, warn};
12use reqwest::header::{HeaderMap, AUTHORIZATION};
13use serde::{Deserialize, Serialize};
14use std::str::FromStr;
15use std::time::SystemTime;
16use url::Url;
17
18mod error;
19mod external;
20mod jellyfin;
21#[cfg(test)]
22mod tests;
23
24pub(crate) type JfResult<T> = Result<T, Box<dyn std::error::Error>>;
25
26pub const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
27
28pub struct Client {
30 discord_ipc_client: DiscordIpcClient,
31 url: Url,
32 usernames: Vec<String>,
33 reqwest: reqwest::blocking::Client,
34 session: Option<Session>,
35 buttons: Option<Vec<Button>>,
36 music_display_options: DisplayOptions,
37 movies_display_options: DisplayOptions,
38 episodes_display_options: DisplayOptions,
39 blacklist: Blacklist,
40 show_paused: bool,
41 show_images: bool,
42 imgur_options: ImgurOptions,
43 litterbox_options: LitterboxOptions,
44 process_images: bool,
45 large_image_text: String,
46}
47
48impl Client {
49 pub fn builder() -> ClientBuilder {
51 ClientBuilder::new()
52 }
53
54 pub fn connect(&mut self) -> JfResult<()> {
56 self.discord_ipc_client.connect()?;
57 Ok(())
58 }
59
60 pub fn reconnect(&mut self) -> JfResult<()> {
62 self.discord_ipc_client.reconnect()?;
63 Ok(())
64 }
65
66 pub fn clear_activity(&mut self) -> JfResult<()> {
86 self.discord_ipc_client.clear_activity()?;
87 Ok(())
88 }
89
90 pub fn set_activity(&mut self) -> JfResult<String> {
108 self.get_session()?;
109
110 match &self.blacklist.libraries {
112 BlacklistedLibraries::Uninitialized => {
113 self.reload_blacklist();
114 }
115 BlacklistedLibraries::Initialized(_, init_time) => {
116 if SystemTime::now()
117 .duration_since(*init_time)
118 .map(|passed| passed.as_secs() > 3600)
119 .unwrap_or(false)
120 {
121 debug!("reloading blacklist after cache expiration");
122 self.reload_blacklist();
123 }
124 }
125 }
126
127 if let Some(session) = &self.session {
128 if session.now_playing_item.media_type == MediaType::None {
129 return Err(Box::new(JfError::UnrecognizedMediaType));
130 }
131
132 if self.check_blacklist()? {
133 return Err(Box::new(JfError::ContentBlacklist));
134 }
135
136 let mut activity = Activity::new();
137
138 let mut image_url = Url::from_str("https://i.imgur.com/oX6vcds.png")?;
139
140 if session.now_playing_item.media_type == MediaType::LiveTv {
141 image_url = Url::from_str("https://i.imgur.com/XxdHOqm.png")?;
142 } else if self.imgur_options.enabled && self.show_images {
143 if let Ok(imgur_url) = external::imgur::get_image(self) {
144 image_url = imgur_url;
145 } else {
146 debug!("imgur::get_image() didnt return an image, using default..")
147 }
148 } else if self.litterbox_options.enabled && self.show_images {
149 if let Ok(litterbox_url) = external::litterbox::get_image(self) {
150 image_url = litterbox_url;
151 } else {
152 debug!("litterbox::get_image() didn't return an image, using default..")
153 }
154 } else if self.show_images {
155 if let Ok(iu) = self.get_image() {
156 image_url = iu;
157 } else {
158 debug!("self.get_image() didnt return an image, using default..")
159 }
160 }
161
162 let mut assets = Assets::new().large_image(image_url.as_str());
163
164 if !self.large_image_text.is_empty() {
165 assets = assets.large_text(&self.large_image_text);
166 }
167
168 let mut timestamps = Timestamps::new();
169
170 match session.get_time()? {
171 PlayTime::Some(start, end) => timestamps = timestamps.start(start).end(end),
172 PlayTime::None => (),
173 PlayTime::Paused if self.show_paused => {
174 assets = assets
175 .small_image("https://i.imgur.com/wlHSvYy.png")
176 .small_text("Paused");
177 }
178 PlayTime::Paused => return Ok(String::new()),
179 }
180
181 let buttons: Vec<Button>;
182
183 if let Some(b) = self.get_buttons() {
184 buttons = b;
186 activity = activity.buttons(
187 buttons
188 .iter()
189 .map(|b| ActButton::new(&b.name, &b.url))
190 .collect(),
191 );
192 }
193
194 let mut state = self.get_state();
195
196 if state.len() > 128 {
197 state = state.chars().take(128).collect();
198 } else if state.len() < 3 {
199 state += "";
201 }
202
203 let mut details = self.get_details();
204
205 if details.len() > 128 {
206 details = details.chars().take(128).collect();
207 } else if details.len() < 3 {
208 details += "";
210 }
211
212 let mut image_text = self.get_image_text();
213
214 if image_text.is_empty() {
215 image_text = format!("Jellyfin-RPC v{}", VERSION.unwrap_or("UNKNOWN"));
216 }
217
218 if image_text.len() > 128 {
219 image_text = image_text.chars().take(128).collect();
220 } else if image_text.len() < 3 {
221 image_text += "";
223 }
224
225 assets = assets.large_text(image_text.as_str());
226
227 match session.now_playing_item.media_type {
228 MediaType::Book => (),
229 MediaType::Music | MediaType::AudioBook => {
230 activity = activity.activity_type(ActivityType::Listening)
231 }
232 _ => activity = activity.activity_type(ActivityType::Watching),
233 }
234
235 let status_display_type = self.get_status_display_type();
236
237 activity = activity
238 .timestamps(timestamps)
239 .assets(assets)
240 .details(&details)
241 .state(&state)
242 .status_display_type(status_display_type.into());
243
244 self.discord_ipc_client.set_activity(activity)?;
245
246 return Ok(format!("{} | {}", details, state));
247 }
248 Ok(String::new())
249 }
250
251 fn get_session(&mut self) -> JfResult<()> {
252 let sessions: Vec<RawSession> = self
253 .reqwest
254 .get(self.url.join("Sessions")?)
255 .send()?
256 .json()?;
257
258 debug!("Found {} sessions", sessions.len());
259
260 for session in sessions {
261 debug!("Session username is {:?}", session.user_name);
262 if let Some(username) = session.user_name.as_ref() {
263 if self
264 .usernames
265 .iter()
266 .all(|u| username.to_lowercase() != u.to_lowercase())
267 {
268 continue;
269 }
270
271 if session.now_playing_item.is_none() {
272 continue;
273 }
274 debug!("NowPlayingItem exists");
275
276 if session.play_state.is_none() {
277 continue;
278 }
279 debug!("PlayState exists");
280
281 let session = session.build();
282
283 if session
284 .now_playing_item
285 .extra_type
286 .as_ref()
287 .is_some_and(|et| et == "ThemeSong")
288 {
289 debug!("Session is playing a theme song, continuing loop");
290 continue;
291 }
292
293 self.session = Some(session);
294 return Ok(());
295 }
296 }
297 self.session = None;
298 Ok(())
299 }
300
301 fn get_buttons(&self) -> Option<Vec<Button>> {
302 let session = self.session.as_ref()?;
303
304 let mut activity_buttons: Vec<Button> = Vec::new();
305
306 if let (Some(ext_urls), Some(buttons)) = (
307 &session.now_playing_item.external_urls,
308 self.buttons.as_ref(),
309 ) {
310 let ext_urls: Vec<&ExternalUrl> = ext_urls
311 .iter()
312 .filter(|eu| {
313 !eu.url.starts_with("http://localhost")
314 && !eu.url.starts_with("https://localhost")
315 })
316 .collect();
317 let mut i = 0;
318 for button in buttons {
319 if activity_buttons.len() == 2 {
320 break;
321 }
322
323 if button.is_dynamic() {
324 if ext_urls.len() > i {
325 activity_buttons.push(Button::new(
326 ext_urls[i].name.clone(),
327 ext_urls[i].url.clone(),
328 ));
329 i += 1;
330 }
331 } else {
332 activity_buttons.push(button.clone())
333 }
334 }
335 return Some(activity_buttons);
336 } else if let Some(buttons) = self.buttons.as_ref() {
337 for button in buttons {
338 if activity_buttons.len() == 2 {
339 break;
340 }
341
342 if !button.is_dynamic() {
343 activity_buttons.push(button.clone())
344 }
345 }
346 return Some(activity_buttons);
347 } else if let Some(ext_urls) = &session.now_playing_item.external_urls {
348 let ext_urls: Vec<&ExternalUrl> = ext_urls
349 .iter()
350 .filter(|eu| {
351 !eu.url.starts_with("http://localhost")
352 && !eu.url.starts_with("https://localhost")
353 })
354 .collect();
355 for ext_url in ext_urls {
356 if activity_buttons.len() == 2 {
357 break;
358 }
359
360 activity_buttons.push(Button::new(ext_url.name.clone(), ext_url.url.clone()))
361 }
362 return Some(activity_buttons);
363 }
364 None
365 }
366
367 fn get_image(&self) -> JfResult<Url> {
368 let session = self.session.as_ref().unwrap();
369
370 let path = "Items/".to_string() + &session.item_id + "/Images/Primary";
371
372 let image_url = self.url.join(&path)?;
373
374 if self
375 .reqwest
376 .get(image_url.as_ref())
377 .send()?
378 .text()?
379 .contains("does not have an image of type Primary")
380 {
381 Err(Box::new(JfError::NoImage))
382 } else {
383 Ok(image_url)
384 }
385 }
386
387 fn sanitize_display_format(input: &str) -> String {
388 let mut result = input.split_whitespace().collect::<Vec<&str>>().join(" ");
390
391 while result.contains("{sep}{sep}") || result.contains("{sep} {sep}") {
393 result = result.replace("{sep}{sep}", "{sep}");
394 result = result.replace("{sep} {sep}", "{sep}");
395 }
396
397 while result.starts_with("{sep}") {
399 result = result
400 .drain(5..)
401 .collect::<String>()
402 .trim_start()
403 .to_string();
404 }
405
406 while result.ends_with("{sep}") {
407 result = result
408 .drain(..result.len() - 5)
409 .collect::<String>()
410 .trim_end()
411 .to_string();
412 }
413
414 result
415 }
416
417 fn parse_music_display(&self, input: &str) -> String {
418 let mut result = input.trim().to_string();
419 let session = self.session.as_ref().unwrap();
420
421 let separator = &self.music_display_options.separator;
422 let track = session.now_playing_item.name.as_ref();
423 let artists = session.format_artists();
424 let genres = session
425 .now_playing_item
426 .genres
427 .as_ref()
428 .unwrap_or(&vec!["".to_string()])
429 .join(", ");
430 let year = session
431 .now_playing_item
432 .production_year
433 .map(|y| y.to_string())
434 .unwrap_or_default();
435 let album = session
436 .now_playing_item
437 .album
438 .as_ref()
439 .unwrap_or(&"".to_string())
440 .clone();
441
442 result = result
443 .replace("{track}", track)
444 .replace("{album}", &album)
445 .replace("{artists}", &artists)
446 .replace("{genres}", &genres)
447 .replace("{year}", &year)
448 .replace("{version}", VERSION.unwrap_or("UNKNOWN"));
449
450 Self::sanitize_display_format(&result).replace("{sep}", separator)
451 }
452
453 fn parse_movies_display(&self, input: &str) -> String {
454 let mut result = input.trim().to_string();
455 let session = self.session.as_ref().unwrap();
456
457 let separator = &self.movies_display_options.separator;
458 let title = session.now_playing_item.name.as_ref();
459 let original_title = session
460 .now_playing_item
461 .original_title
462 .as_ref()
463 .unwrap_or(&"".to_string())
464 .clone();
465 let genres = &session
466 .now_playing_item
467 .genres
468 .as_ref()
469 .unwrap_or(&vec!["".to_string()])
470 .join(", ");
471 let year = session
472 .now_playing_item
473 .production_year
474 .map(|y| y.to_string())
475 .unwrap_or_default();
476 let critic_score = &session
477 .now_playing_item
478 .critic_rating
479 .map(|s| format!("🍅 {}/100", s))
480 .unwrap_or_default();
481 let community_score = &session
482 .now_playing_item
483 .community_rating
484 .map(|s| format!("⭐ {:.1}/10", s))
485 .unwrap_or_default();
486
487 result = result
488 .replace("{title}", title)
489 .replace("{original-title}", &original_title)
490 .replace("{genres}", &genres)
491 .replace("{year}", &year)
492 .replace("{critic-score}", critic_score)
493 .replace("{community-score}", community_score)
494 .replace("{version}", VERSION.unwrap_or("UNKNOWN"));
495
496 Self::sanitize_display_format(&result).replace("{sep}", separator)
497 }
498
499 fn parse_episodes_display(&self, input: &str) -> String {
500 let mut result = input.trim().to_string();
501 let session = self.session.as_ref().unwrap();
502
503 let separator = &self.episodes_display_options.separator;
504 let show_title = session
505 .now_playing_item
506 .series_name
507 .as_ref()
508 .unwrap_or(&"".to_string())
509 .clone();
510 let episode_title = session.now_playing_item.name.as_ref();
511 let original_title = session
512 .now_playing_item
513 .original_title
514 .as_ref()
515 .unwrap_or(&"".to_string())
516 .clone();
517 let season = session.now_playing_item.parent_index_number.unwrap_or(0);
518 let year = session
519 .now_playing_item
520 .production_year
521 .map(|y| y.to_string())
522 .unwrap_or_default();
523 let genres = session
524 .now_playing_item
525 .genres
526 .as_ref()
527 .unwrap_or(&vec!["".to_string()])
528 .join(", ");
529 let studio = session
530 .now_playing_item
531 .series_studio
532 .as_ref()
533 .unwrap_or(&"".to_string())
534 .clone();
535
536 let episode_range = (
539 session.now_playing_item.index_number.unwrap_or(0),
540 session.now_playing_item.index_number_end,
541 );
542 result = result
543 .replace("{show-title}", &show_title)
544 .replace("{title}", episode_title)
545 .replace("{original-title}", &original_title)
546 .replace(
547 "{episode}",
548 &match episode_range {
549 (first, Some(last)) => format!("{}-{}", first, last),
550 (episode, None) => format!("{}", episode),
551 },
552 )
553 .replace(
554 "{episode-padded}",
555 &match episode_range {
556 (first, Some(last)) => format!("{:02}-{:02}", first, last),
557 (episode, None) => format!("{:02}", episode),
558 },
559 )
560 .replace("{season}", &season.to_string())
561 .replace("{season-padded}", &format!("{:02}", season))
562 .replace("{year}", &year)
563 .replace("{genres}", &genres)
564 .replace("{studio}", &studio)
565 .replace("{version}", VERSION.unwrap_or("UNKNOWN"));
566
567 Self::sanitize_display_format(&result).replace("{sep}", separator)
568 }
569
570 fn get_details(&self) -> String {
571 let session = self.session.as_ref().unwrap();
572
573 match session.now_playing_item.media_type {
574 MediaType::Music => {
575 let display_details_format = &self
576 .music_display_options
577 .display
578 .details_text
579 .as_ref()
580 .unwrap();
581 self.parse_music_display(
582 display_details_format
583 .replace("{__default}", "{track}")
584 .as_str(),
585 )
586 }
587 MediaType::Movie => {
588 let display_details_format = &self
589 .movies_display_options
590 .display
591 .details_text
592 .as_ref()
593 .unwrap();
594 self.parse_movies_display(
595 display_details_format
596 .replace("{__default}", "{title}")
597 .as_str(),
598 )
599 }
600 MediaType::Episode => {
601 let display_details_format = &self
602 .episodes_display_options
603 .display
604 .details_text
605 .as_ref()
606 .unwrap();
607 self.parse_episodes_display(
608 display_details_format
609 .replace("{__default}", "{show-title}")
610 .as_str(),
611 )
612 }
613 MediaType::AudioBook => session
614 .now_playing_item
615 .album
616 .as_ref()
617 .map(|a| a.to_string())
618 .unwrap_or_else(|| session.now_playing_item.name.to_string()),
619 _ => session.now_playing_item.name.to_string(),
620 }
621 }
622
623 fn get_state(&self) -> String {
624 let session = self.session.as_ref().unwrap();
625
626 match session.now_playing_item.media_type {
627 MediaType::Episode => {
628 let display_state_format = &self
629 .episodes_display_options
630 .display
631 .state_text
632 .as_ref()
633 .unwrap();
634 self.parse_episodes_display(
635 display_state_format.replace("{__default}", "").as_str(),
636 )
637 }
638 MediaType::LiveTv => "Live TV".to_string(),
639 MediaType::Music => {
640 let display_state_format = &self
641 .music_display_options
642 .display
643 .state_text
644 .as_ref()
645 .unwrap();
646 self.parse_music_display(
647 display_state_format
648 .replace("{__default}", "By {artists} {sep} ")
649 .as_str(),
650 )
651 }
652 MediaType::Book => {
653 let mut state = String::new();
654
655 if let Some(position_ticks) = session.play_state.position_ticks {
656 let ticks_to_pages = 10000;
657
658 let page = position_ticks / ticks_to_pages;
659
660 state += &format!("Reading page {}", page);
661 }
662
663 state
664 }
665 MediaType::AudioBook => {
666 let mut state = String::new();
667
668 let artists = session.format_artists();
669
670 let genres = session
671 .now_playing_item
672 .genres
673 .as_ref()
674 .unwrap_or(&vec!["".to_string()])
675 .join(", ");
676
677 if !artists.is_empty() {
678 state += &format!("By {}", artists)
679 }
680
681 if !state.is_empty() && !genres.is_empty() {
682 state += " - "
683 }
684
685 state += &genres;
686
687 state
688 }
689 MediaType::Movie => {
690 let display_state_format = &self
691 .movies_display_options
692 .display
693 .state_text
694 .as_ref()
695 .unwrap();
696 self.parse_movies_display(display_state_format.replace("{__default}", "").as_str())
697 }
698 _ => session
699 .now_playing_item
700 .genres
701 .as_ref()
702 .unwrap_or(&vec!["".to_string()])
703 .join(", "),
704 }
705 }
706
707 fn get_status_display_type(&self) -> StatusType {
708 let session = self.session.as_ref().unwrap();
709 match session.now_playing_item.media_type {
710 MediaType::Episode => self.episodes_display_options.status_display_type.clone(),
711 MediaType::Movie => self.movies_display_options.status_display_type.clone(),
712 MediaType::Music => self.music_display_options.status_display_type.clone(),
713 _ => Default::default(),
714 }
715 }
716
717 fn get_image_text(&self) -> String {
718 let session = self.session.as_ref().unwrap();
719
720 match session.now_playing_item.media_type {
721 MediaType::Music => {
722 let display_image_format = &self
723 .music_display_options
724 .display
725 .image_text
726 .as_ref()
727 .unwrap();
728 self.parse_music_display(display_image_format)
729 }
730 MediaType::Movie => {
731 let display_image_format = &self
732 .movies_display_options
733 .display
734 .image_text
735 .as_ref()
736 .unwrap();
737 self.parse_movies_display(display_image_format)
738 }
739 MediaType::Episode => {
740 let display_image_format = &self
741 .episodes_display_options
742 .display
743 .image_text
744 .as_ref()
745 .unwrap();
746 self.parse_episodes_display(display_image_format)
747 }
748 _ => "".to_string(),
749 }
750 }
751
752 fn check_blacklist(&self) -> JfResult<bool> {
753 let session = self.session.as_ref().unwrap();
754
755 if self
756 .blacklist
757 .media_types
758 .iter()
759 .any(|m| m == &session.now_playing_item.media_type)
760 {
761 return Ok(true);
762 }
763
764 if self.blacklist.check_item(&session.now_playing_item) {
765 return Ok(true);
766 }
767
768 Ok(false)
769 }
770
771 fn fetch_blacklist(&self) -> JfResult<Vec<VirtualFolder>> {
773 let virtual_folders: Vec<VirtualFolder> = self
774 .reqwest
775 .get(self.url.join("Library/VirtualFolders")?)
776 .send()?
777 .json()?;
778
779 Ok(virtual_folders
780 .into_iter()
781 .filter(|library_folder| {
782 self.blacklist
783 .libraries_names
784 .contains(library_folder.name.as_ref().unwrap_or(&String::new()))
785 })
786 .collect())
787 }
788
789 fn reload_blacklist(&mut self) {
791 self.blacklist.libraries = match self.fetch_blacklist() {
792 Ok(blacklist) => BlacklistedLibraries::Initialized(blacklist, SystemTime::now()),
793 Err(err) => {
794 warn!("Failed to intialize blacklist: {}", err);
795 BlacklistedLibraries::Uninitialized
796 }
797 }
798 }
799}
800
801pub struct EpisodeDisplayOptions {
802 pub divider: bool,
803 pub prefix: bool,
804 pub simple: bool,
805}
806
807struct DisplayOptions {
808 separator: String,
809 display: DisplayFormat,
810 status_display_type: StatusType,
811}
812
813#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
815pub struct DisplayFormat {
816 pub details_text: Option<String>,
818 pub state_text: Option<String>,
820 pub image_text: Option<String>,
822}
823
824impl From<Vec<String>> for DisplayFormat {
826 fn from(items: Vec<String>) -> Self {
827 let details_text = "{__default}".to_string();
828 let image_text = "Jellyfin-RPC v{version}".to_string();
829 let mut state_text = "{__default}".to_string();
830
831 let items_joined = items
832 .iter()
833 .map(|i| format!("{{{}}}", i.trim()))
834 .collect::<Vec<String>>()
835 .join(" {sep} ");
836
837 if !items_joined.is_empty() {
838 state_text += &items_joined;
839 }
840
841 DisplayFormat {
842 details_text: Some(details_text),
843 state_text: Some(state_text),
844 image_text: Some(image_text),
845 }
846 }
847}
848
849impl From<String> for DisplayFormat {
851 fn from(item: String) -> Self {
852 let data: Vec<String> = item.split(',').map(|d| d.to_string()).collect();
853 DisplayFormat::from(data)
854 }
855}
856
857impl From<EpisodeDisplayOptions> for DisplayFormat {
859 fn from(value: EpisodeDisplayOptions) -> Self {
860 let details_text = "{show-title}".to_string();
861 let state_text = {
862 let (season_tag, episode_tag) = if value.prefix {
863 (
864 "S{season-padded}".to_string(),
865 "E{episode-padded}".to_string(),
866 )
867 } else {
868 ("S{season}".to_string(), "E{episode}".to_string())
869 };
870
871 let divider = if value.divider { " - " } else { "" };
872
873 if value.simple {
874 format!("{}{}{}", season_tag, divider, episode_tag)
875 } else {
876 format!("{}{}{} {}", season_tag, divider, episode_tag, "{title}")
877 }
878 };
879 let image_text = "Jellyfin-RPC v{version}".to_string();
880
881 DisplayFormat {
882 details_text: Some(details_text),
883 state_text: Some(state_text),
884 image_text: Some(image_text),
885 }
886 }
887}
888
889#[derive(Clone, Debug)]
890pub enum StatusType {
891 Name,
892 State,
893 Details,
894}
895impl Default for StatusType {
896 fn default() -> Self {
897 Self::Name
898 }
899}
900
901impl From<DiscordIpcStatusDisplayType> for StatusType {
902 fn from(x: DiscordIpcStatusDisplayType) -> Self {
903 use DiscordIpcStatusDisplayType as T;
904 match x {
905 T::Name => Self::Name,
906 T::State => Self::State,
907 T::Details => Self::Details,
908 }
909 }
910}
911
912impl Into<DiscordIpcStatusDisplayType> for StatusType {
913 fn into(self) -> DiscordIpcStatusDisplayType {
914 use DiscordIpcStatusDisplayType as T;
915 match self {
916 Self::Name => T::Name,
917 Self::State => T::State,
918 Self::Details => T::Details,
919 }
920 }
921}
922
923#[derive(Debug)]
924pub struct StatusTypeFromStringError;
925
926impl TryFrom<String> for StatusType {
927 type Error = StatusTypeFromStringError;
928 fn try_from(x: String) -> Result<Self, Self::Error> {
929 match x.as_ref() {
930 "name" => Ok(Self::Name),
931 "state" => Ok(Self::State),
932 "details" => Ok(Self::Details),
933 _ => Err(StatusTypeFromStringError),
934 }
935 }
936}
937
938struct Blacklist {
939 media_types: Vec<MediaType>,
940 libraries_names: Vec<String>,
941 libraries: BlacklistedLibraries,
942}
943
944enum BlacklistedLibraries {
945 Uninitialized,
946 Initialized(Vec<VirtualFolder>, SystemTime),
947}
948
949impl Blacklist {
950 fn check_item(&self, playing_item: &NowPlayingItem) -> bool {
952 debug!("Checking if an item is blacklisted: {}", playing_item.name);
953 self.check_path(playing_item.path.as_ref().unwrap_or(&String::new()))
954 }
955
956 fn check_path(&self, item_path: &str) -> bool {
958 match &self.libraries {
959 BlacklistedLibraries::Initialized(libraries, _) => {
960 debug!("Checking path: {}", item_path);
961 libraries.iter().any(|blacklisted_mf| {
962 blacklisted_mf.locations.iter().any(|physical_folder| {
963 debug!("BL path: {}", physical_folder);
964 item_path.starts_with(physical_folder)
965 })
966 })
967 }
968 BlacklistedLibraries::Uninitialized => false,
969 }
970 }
971}
972
973struct ImgurOptions {
974 enabled: bool,
975 client_id: String,
976 urls_location: String,
977}
978
979struct LitterboxOptions {
980 enabled: bool,
981 urls_location: String
982}
983
984#[derive(Default)]
986pub struct ClientBuilder {
987 url: String,
988 client_id: String,
989 api_key: String,
990 self_signed: bool,
991 usernames: Vec<String>,
992 buttons: Option<Vec<Button>>,
993 episode_divider: bool,
994 episode_prefix: bool,
995 episode_simple: bool,
996 music_separator: String,
997 music_display: DisplayFormat,
998 music_status_display_type: StatusType,
999 movies_separator: String,
1000 movies_display: DisplayFormat,
1001 movies_status_display_type: StatusType,
1002 episodes_separator: String,
1003 episodes_display: DisplayFormat,
1004 episodes_status_display_type: StatusType,
1005 blacklist_media_types: Vec<MediaType>,
1006 blacklist_libraries: Vec<String>,
1007 show_paused: bool,
1008 show_images: bool,
1009 use_imgur: bool,
1010 imgur_client_id: String,
1011 imgur_urls_file_location: String,
1012 use_litterbox: bool,
1013 litterbox_urls_file_location: String,
1014 large_image_text: String,
1015 process_images: bool,
1016}
1017
1018impl ClientBuilder {
1019 pub fn new() -> Self {
1021 Self {
1022 client_id: "1053747938519679018".to_string(),
1023 music_separator: "-".to_string(),
1024 music_display: DisplayFormat::from(vec!["genres".to_string()]),
1025 movies_separator: "-".to_string(),
1026 movies_display: DisplayFormat::from(vec!["genres".to_string()]),
1027 episodes_separator: "-".to_string(),
1028 episodes_display: DisplayFormat::from(EpisodeDisplayOptions {
1029 divider: true,
1030 prefix: true,
1031 simple: false,
1032 }),
1033 show_paused: true,
1034 process_images: true,
1035 ..Default::default()
1036 }
1037 }
1038
1039 pub fn url<T: Into<String>>(&mut self, url: T) -> &mut Self {
1043 self.url = url.into();
1044 self
1045 }
1046
1047 pub fn client_id<T: Into<String>>(&mut self, client_id: T) -> &mut Self {
1051 self.client_id = client_id.into();
1052 self
1053 }
1054
1055 pub fn api_key<T: Into<String>>(&mut self, api_key: T) -> &mut Self {
1059 self.api_key = api_key.into();
1060 self
1061 }
1062
1063 pub fn self_signed(&mut self, self_signed: bool) -> &mut Self {
1067 self.self_signed = self_signed;
1068 self
1069 }
1070
1071 pub fn usernames(&mut self, usernames: Vec<String>) -> &mut Self {
1079 self.usernames = usernames;
1080 self
1081 }
1082
1083 pub fn username<T: Into<String>>(&mut self, username: T) -> &mut Self {
1091 self.usernames = vec![username.into()];
1092 self
1093 }
1094
1095 pub fn buttons(&mut self, buttons: Vec<Button>) -> &mut Self {
1100 self.buttons = Some(buttons);
1101 self
1102 }
1103
1104 pub fn episode_divider(&mut self, val: bool) -> &mut Self {
1111 self.episode_divider = val;
1112 self
1113 }
1114
1115 pub fn episode_prefix(&mut self, val: bool) -> &mut Self {
1122 self.episode_prefix = val;
1123 self
1124 }
1125
1126 pub fn episode_simple(&mut self, val: bool) -> &mut Self {
1133 self.episode_simple = val;
1134 self
1135 }
1136
1137 pub fn music_separator<T: Into<String>>(&mut self, separator: T) -> &mut Self {
1138 self.music_separator = separator.into();
1139 self
1140 }
1141
1142 pub fn music_display(&mut self, display: DisplayFormat) -> &mut Self {
1143 self.music_display = display;
1144 self
1145 }
1146
1147 pub fn music_status_display_type(&mut self, status_type: StatusType) -> &mut Self {
1148 self.music_status_display_type = status_type;
1149 self
1150 }
1151
1152 pub fn movies_separator<T: Into<String>>(&mut self, separator: T) -> &mut Self {
1153 self.movies_separator = separator.into();
1154 self
1155 }
1156
1157 pub fn movies_display(&mut self, display: DisplayFormat) -> &mut Self {
1158 self.movies_display = display;
1159 self
1160 }
1161
1162 pub fn movies_status_display_type(&mut self, status_type: StatusType) -> &mut Self {
1163 self.movies_status_display_type = status_type;
1164 self
1165 }
1166
1167 pub fn episodes_separator<T: Into<String>>(&mut self, separator: T) -> &mut Self {
1168 self.episodes_separator = separator.into();
1169 self
1170 }
1171
1172 pub fn episodes_display(&mut self, display: DisplayFormat) -> &mut Self {
1173 self.episodes_display = display;
1174 self
1175 }
1176
1177 pub fn episodes_status_display_type(&mut self, status_type: StatusType) -> &mut Self {
1178 self.episodes_status_display_type = status_type;
1179 self
1180 }
1181
1182 pub fn blacklist_media_types(&mut self, media_types: Vec<MediaType>) -> &mut Self {
1186 self.blacklist_media_types = media_types;
1187 self
1188 }
1189
1190 pub fn blacklist_libraries(&mut self, libraries: Vec<String>) -> &mut Self {
1194 self.blacklist_libraries = libraries;
1195 self
1196 }
1197
1198 pub fn show_paused(&mut self, val: bool) -> &mut Self {
1202 self.show_paused = val;
1203 self
1204 }
1205
1206 pub fn show_images(&mut self, val: bool) -> &mut Self {
1210 self.show_images = val;
1211 self
1212 }
1213
1214 pub fn use_imgur(&mut self, val: bool) -> &mut Self {
1218 self.use_imgur = val;
1219 self
1220 }
1221
1222 pub fn imgur_client_id<T: Into<String>>(&mut self, client_id: T) -> &mut Self {
1226 self.imgur_client_id = client_id.into();
1227 self
1228 }
1229
1230 pub fn imgur_urls_file_location<T: Into<String>>(&mut self, location: T) -> &mut Self {
1240 self.imgur_urls_file_location = location.into();
1241 self
1242 }
1243
1244 pub fn use_litterbox(&mut self, val: bool) -> &mut Self {
1248 self.use_litterbox = val;
1249 self
1250 }
1251
1252 pub fn litterbox_urls_file_location<T: Into<String>>(&mut self, location: T) -> &mut Self {
1257 self.litterbox_urls_file_location = location.into();
1258 self
1259 }
1260
1261 pub fn process_images(&mut self, val: bool) -> &mut Self {
1265 self.process_images = val;
1266 self
1267 }
1268
1269 pub fn large_image_text<T: Into<String>>(&mut self, text: T) -> &mut Self {
1273 self.large_image_text = text.into();
1274 self
1275 }
1276
1277 pub fn build(self) -> JfResult<Client> {
1291 if self.url.is_empty() || self.usernames.is_empty() || self.api_key.is_empty() {
1292 return Err(Box::new(JfError::MissingRequiredValues));
1293 }
1294
1295 let mut headers = HeaderMap::new();
1296
1297 headers.insert(
1298 AUTHORIZATION,
1299 format!("MediaBrowser Token=\"{}\"", self.api_key).parse()?,
1300 );
1301 headers.insert("X-Emby-Token", self.api_key.parse()?);
1302
1303 Ok(Client {
1304 discord_ipc_client: DiscordIpcClient::new(&self.client_id),
1305 url: self.url.parse()?,
1306 reqwest: reqwest::blocking::Client::builder()
1307 .default_headers(headers)
1308 .danger_accept_invalid_certs(self.self_signed)
1309 .build()?,
1310 usernames: self.usernames,
1311 buttons: self.buttons,
1312 session: None,
1313 music_display_options: DisplayOptions {
1314 separator: self.music_separator,
1315 display: self.music_display,
1316 status_display_type: self.music_status_display_type,
1317 },
1318 movies_display_options: DisplayOptions {
1319 separator: self.movies_separator,
1320 display: self.movies_display,
1321 status_display_type: self.movies_status_display_type,
1322 },
1323 episodes_display_options: DisplayOptions {
1324 separator: self.episodes_separator,
1325 display: self.episodes_display,
1326 status_display_type: self.episodes_status_display_type,
1327 },
1328 blacklist: Blacklist {
1329 media_types: self.blacklist_media_types,
1330 libraries_names: self.blacklist_libraries,
1331 libraries: BlacklistedLibraries::Uninitialized,
1332 },
1333 show_paused: self.show_paused,
1334 show_images: self.show_images,
1335 imgur_options: ImgurOptions {
1336 enabled: self.use_imgur,
1337 client_id: self.imgur_client_id,
1338 urls_location: self.imgur_urls_file_location,
1339 },
1340 litterbox_options: LitterboxOptions {
1341 enabled: self.use_litterbox,
1342 urls_location: self.litterbox_urls_file_location,
1343 },
1344 process_images: self.process_images,
1345 large_image_text: self.large_image_text,
1346 })
1347 }
1348}