jellyfin_rpc/
lib.rs

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
28/// Client used to interact with jellyfin and discord
29pub 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    /// Calls the `ClientBuilder::new()` function
50    pub fn builder() -> ClientBuilder {
51        ClientBuilder::new()
52    }
53
54    /// Connects to the discord socket
55    pub fn connect(&mut self) -> JfResult<()> {
56        self.discord_ipc_client.connect()?;
57        Ok(())
58    }
59
60    /// Reconnects to the discord socket
61    pub fn reconnect(&mut self) -> JfResult<()> {
62        self.discord_ipc_client.reconnect()?;
63        Ok(())
64    }
65
66    /// Clears current activity on discord if anything is being displayed
67    ///
68    /// # Example
69    /// ```no_run
70    /// use jellyfin_rpc::Client;
71    ///
72    /// let mut builder = Client::builder();
73    /// builder.api_key("abcd1234")
74    ///     .url("https://jellyfin.example.com")
75    ///     .username("user");
76    ///
77    /// let mut client = builder.build().unwrap();
78    ///
79    /// client.connect().unwrap();
80    ///
81    /// client.set_activity().unwrap();
82    ///
83    /// client.clear_activity().unwrap();
84    /// ```
85    pub fn clear_activity(&mut self) -> JfResult<()> {
86        self.discord_ipc_client.clear_activity()?;
87        Ok(())
88    }
89
90    /// Gathers information from jellyfin about what is being played and displays it according to the options supplied to the builder.
91    ///
92    /// # Example
93    /// ```no_run
94    /// use jellyfin_rpc::Client;
95    ///
96    /// let mut builder = Client::builder();
97    /// builder.api_key("abcd1234")
98    ///     .url("https://jellyfin.example.com")
99    ///     .username("user");
100    ///
101    /// let mut client = builder.build().unwrap();
102    ///
103    /// client.connect().unwrap();
104    ///
105    /// client.set_activity().unwrap();
106    /// ```
107    pub fn set_activity(&mut self) -> JfResult<String> {
108        self.get_session()?;
109
110        // Make sure the blacklist cache is loaded/valid
111        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                // This gets around the value being dropped immediately at the end of this if statement
185                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                // Add three zero width joiners due to discord requiring a minimum length of 3 chars in statuses
200                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                // add three (3) zero width joiners
209                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                // add three zero width joiners
222                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        // Remove unnecessary spaces
389        let mut result = input.split_whitespace().collect::<Vec<&str>>().join(" ");
390
391        // Remove duplicated separators
392        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        // Remove unnecessary separators
398        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        // One episode on Jellyfin can span across multiple actual episodes
537        // For example E01-03 is 3 episodes in one media file
538        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    /// Fetch the virtual folder list and filter out the blacklisted libraries
772    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    /// Reload the library list from Jellyfin and filter out the user-provided blacklisted libraries
790    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/// Represents the formatting details for `Display`.
814#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
815pub struct DisplayFormat {
816    /// First line of the activity.
817    pub details_text: Option<String>,
818    /// Second line of the activity.
819    pub state_text: Option<String>,
820    /// Third line / large image text of the activity.
821    pub image_text: Option<String>,
822}
823
824/// Converts legacy `Vec<String>` to `DisplayFormat`
825impl 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
849/// Reuses `DisplayFormat::from(Vec<String>)`
850impl 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
857/// Converts `EpisodeDisplayOptions` to `DisplayFormat`
858impl 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    /// Check whether a [NowPlayingItem] is in a blacklisted library
951    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    /// Check whether a path is in a blacklisted library
957    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/// Used to build a new Client
985#[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    /// Returns a ClientBuilder with some default options set
1020    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    /// Jellyfin URL to be used by the client.
1040    ///
1041    /// Has no default.
1042    pub fn url<T: Into<String>>(&mut self, url: T) -> &mut Self {
1043        self.url = url.into();
1044        self
1045    }
1046
1047    /// Discord Application ID that the client will use when connecting to Discord.
1048    ///
1049    /// Defaults to `"1053747938519679018"`.
1050    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    /// Jellyfin API Key that will be used to gather data about what is being played.
1056    ///
1057    /// Has no default.
1058    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    /// Controls the use of certificate validation in reqwest.
1064    ///
1065    /// Defaults to `false`.
1066    pub fn self_signed(&mut self, self_signed: bool) -> &mut Self {
1067        self.self_signed = self_signed;
1068        self
1069    }
1070
1071    /// Usernames that should be matched when checking Jellyfin sessions.
1072    ///
1073    /// Has no default.
1074    ///
1075    /// # Warning
1076    /// This overwrites the value set in `ClientBuilder::Username()`,
1077    /// only one of these 2 should be used
1078    pub fn usernames(&mut self, usernames: Vec<String>) -> &mut Self {
1079        self.usernames = usernames;
1080        self
1081    }
1082
1083    /// same as `ClientBuilder::Usernames()` but will only accept a single username
1084    ///
1085    /// Has no default.
1086    ///
1087    /// # Warning
1088    /// This overwrites the value set in `ClientBuilder::Usernames()`,
1089    /// only one of these 2 should be used
1090    pub fn username<T: Into<String>>(&mut self, username: T) -> &mut Self {
1091        self.usernames = vec![username.into()];
1092        self
1093    }
1094
1095    /// buttons to be displayed on the activity.
1096    /// Pass an empty `Vec::new()` to display no buttons
1097    ///
1098    /// Defaults to dynamic buttons generated from the Jellyfin session.
1099    pub fn buttons(&mut self, buttons: Vec<Button>) -> &mut Self {
1100        self.buttons = Some(buttons);
1101        self
1102    }
1103
1104    /// Splits season and episode numbers with a dash.
1105    ///
1106    /// Defaults to `false`.
1107    ///
1108    /// # Example
1109    /// S1E1 Pilot -> S1 - E1 Pilot
1110    pub fn episode_divider(&mut self, val: bool) -> &mut Self {
1111        self.episode_divider = val;
1112        self
1113    }
1114
1115    /// Adds leading 0's to season and episode numbers.
1116    ///
1117    /// Defaults to `false`.
1118    ///
1119    /// # Example
1120    /// S1E1 Pilot -> S01E01 Pilot
1121    pub fn episode_prefix(&mut self, val: bool) -> &mut Self {
1122        self.episode_prefix = val;
1123        self
1124    }
1125
1126    /// Removes the episode name from the activity.
1127    ///
1128    /// Defaults to `false`.
1129    ///
1130    /// # Example
1131    /// S1E1 Pilot -> S1E1
1132    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    /// Blacklist certain `MediaType`s so they don't display.
1183    ///
1184    /// Defaults to `Vec::new()`.
1185    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    /// Blacklist certain libraries so they don't display.
1191    ///
1192    /// Defaults to `Vec::new()`.
1193    pub fn blacklist_libraries(&mut self, libraries: Vec<String>) -> &mut Self {
1194        self.blacklist_libraries = libraries;
1195        self
1196    }
1197
1198    /// Show activity when paused.
1199    ///
1200    /// Defaults to `true`.
1201    pub fn show_paused(&mut self, val: bool) -> &mut Self {
1202        self.show_paused = val;
1203        self
1204    }
1205
1206    /// Show images from jellyfin on the activity.
1207    ///
1208    /// Defaults to `false`.
1209    pub fn show_images(&mut self, val: bool) -> &mut Self {
1210        self.show_images = val;
1211        self
1212    }
1213
1214    /// Use imgur for images, uploads images from jellyfin to imgur and stores the imgur links in a local cache
1215    ///
1216    /// Defaults to `false`.
1217    pub fn use_imgur(&mut self, val: bool) -> &mut Self {
1218        self.use_imgur = val;
1219        self
1220    }
1221
1222    /// Imgur client id, used to upload images through their API.
1223    ///
1224    /// Empty by default.
1225    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    /// Where to store the URLs to images uploaded to imgur.
1231    /// Having this cache lets you avoid uploading the same image several times to their service.
1232    ///
1233    /// Empty by default.
1234    ///
1235    /// # Warning
1236    /// Setting this to something like `/dev/null` is **NOT** recommended,
1237    /// jellyfin-rpc will upload the image every time you call `Client::set_activity()`
1238    /// if it can't find the image its looking for in the cache.
1239    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    /// Use litterbox.catbox.moe for images, uploads images from jellyfin to litterbox and stores the litterbox links in a local cache
1245    ///
1246    /// Defaults to `false`.
1247    pub fn use_litterbox(&mut self, val: bool) -> &mut Self {
1248        self.use_litterbox = val;
1249        self
1250    }
1251
1252    /// Where to store the URLs to images uploaded to litterbox.
1253    /// Having this cache lets you avoid uploading the same image several times to their service.
1254    ///
1255    /// Empty by default.
1256    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    /// Process images before uploading to imgur or litterbox
1262    ///
1263    /// Defaults to `true`.
1264    pub fn process_images(&mut self, val: bool) -> &mut Self {
1265        self.process_images = val;
1266        self
1267    }
1268
1269    /// Text to be displayed when hovering the large activity image in Discord
1270    ///
1271    /// Empty by default
1272    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    /// Builds a client from the options specified in the builder.
1278    ///
1279    /// # Example
1280    /// ```
1281    /// use jellyfin_rpc::ClientBuilder;
1282    ///
1283    /// let mut builder = ClientBuilder::new();
1284    /// builder.api_key("abcd1234")
1285    ///     .url("https://jellyfin.example.com")
1286    ///     .username("user");
1287    ///
1288    /// let mut client = builder.build().unwrap();
1289    /// ```
1290    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}