Skip to content

Commit 2ae467b

Browse files
Jacob-TateIgnisDa
andauthored
Emby integration (#927)
* backend added emby integration * Removed accidental include * Moved the db search into its own function since its used in both plex and emby integrations * refactor(backend): extract common function * refactor(backend): accept show name as argument as well * chore(graphql): generate types correctly * refactor(backend): remove usage of option * chore(backend): remove useless reference * chore(backend): add logging about show and series * build(backend): upgrade dependencies * Revert "build(backend): upgrade dependencies" This reverts commit ec55820. * added Emby integration documentation and corrected grammar error on Jellyfin/Plex/Kodi lines. * docs: add info about emby --------- Co-authored-by: Diptesh Choudhuri <ignisda2001@gmail.com>
1 parent 69d9fad commit 2ae467b

File tree

6 files changed

+164
-23
lines changed

6 files changed

+164
-23
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ You can also open an issue on GitHub if you find any bugs or have feature reques
6868
-[Supports](https://github.com/IgnisDa/ryot/discussions/4) tracking media
6969
and fitness
7070
- ✅ Import data from Goodreads, Trakt, Strong App [etc](https://docs.ryot.io/importing.html)
71-
- ✅ Integration with Jellyfin, Kodi, Plex, Audiobookshelf [etc](https://docs.ryot.io/integrations.html)
71+
- ✅ Integration with Jellyfin, Kodi, Plex, Emby, Audiobookshelf [etc](https://docs.ryot.io/integrations.html)
7272
-[Supports](https://docs.ryot.io/guides/openid.html) OpenID Connect
7373
- ✅ Sends notifications to Discord, Ntfy, Apprise etc
7474
- ✅ Self-hosted

apps/backend/src/integrations.rs

+136-19
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use regex::Regex;
77
use reqwest::header::{HeaderValue, AUTHORIZATION};
88
use rust_decimal::Decimal;
99
use rust_decimal_macros::dec;
10-
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
10+
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter};
1111
use sea_query::{extension::postgres::PgExpr, Alias, Expr, Func};
1212
use serde::{Deserialize, Serialize};
1313

@@ -52,6 +52,38 @@ impl IntegrationService {
5252
Self { db: db.clone() }
5353
}
5454

55+
// DEV: Fuzzy search for show by episode name and series name.
56+
async fn get_show_by_episode_identifier(
57+
&self,
58+
series: &str,
59+
episode: &str,
60+
) -> Result<metadata::Model> {
61+
let db_show = Metadata::find()
62+
.filter(metadata::Column::Lot.eq(MediaLot::Show))
63+
.filter(metadata::Column::Source.eq(MediaSource::Tmdb))
64+
.filter(
65+
Condition::all()
66+
.add(
67+
Expr::expr(Func::cast_as(
68+
Expr::col(metadata::Column::ShowSpecifics),
69+
Alias::new("text"),
70+
))
71+
.ilike(ilike_sql(episode)),
72+
)
73+
.add(Expr::col(metadata::Column::Title).ilike(ilike_sql(series))),
74+
)
75+
.one(&self.db)
76+
.await?;
77+
match db_show {
78+
Some(show) => Ok(show),
79+
None => bail!(
80+
"No show found with Series {:#?} and Episode {:#?}",
81+
series,
82+
episode
83+
),
84+
}
85+
}
86+
5587
pub async fn jellyfin_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
5688
mod models {
5789
use super::*;
@@ -215,25 +247,11 @@ impl IntegrationService {
215247
let (identifier, lot) = match payload.metadata.item_type.as_str() {
216248
"movie" => (identifier.to_owned(), MediaLot::Movie),
217249
"episode" => {
218-
// DEV: Since Plex and Ryot both use TMDb, we can safely assume that the
219-
// TMDB ID sent by Plex (which is actually the episode ID) is also present
220-
// in the media specifics we have in DB.
221-
let db_show = Metadata::find()
222-
.filter(metadata::Column::Lot.eq(MediaLot::Show))
223-
.filter(metadata::Column::Source.eq(MediaSource::Tmdb))
224-
.filter(
225-
Expr::expr(Func::cast_as(
226-
Expr::col(metadata::Column::ShowSpecifics),
227-
Alias::new("text"),
228-
))
229-
.ilike(ilike_sql(identifier)),
230-
)
231-
.one(&self.db)
250+
let series_name = payload.metadata.show_name.as_ref().unwrap();
251+
let db_show = self
252+
.get_show_by_episode_identifier(series_name, identifier)
232253
.await?;
233-
if db_show.is_none() {
234-
bail!("No show found with TMDb ID {}", identifier);
235-
}
236-
(db_show.unwrap().identifier, MediaLot::Show)
254+
(db_show.identifier, MediaLot::Show)
237255
}
238256
_ => bail!("Only movies and shows supported"),
239257
};
@@ -257,6 +275,105 @@ impl IntegrationService {
257275
})
258276
}
259277

278+
pub async fn emby_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
279+
mod models {
280+
use super::*;
281+
282+
#[derive(Serialize, Deserialize, Debug, Clone)]
283+
#[serde(rename_all = "PascalCase")]
284+
pub struct EmbyWebhookPlaybackInfoPayload {
285+
pub position_ticks: Option<Decimal>,
286+
}
287+
#[derive(Serialize, Deserialize, Debug, Clone)]
288+
#[serde(rename_all = "PascalCase")]
289+
pub struct EmbyWebhookItemProviderIdsPayload {
290+
pub tmdb: Option<String>,
291+
}
292+
#[derive(Serialize, Deserialize, Debug, Clone)]
293+
#[serde(rename_all = "PascalCase")]
294+
pub struct EmbyWebhookItemPayload {
295+
pub run_time_ticks: Option<Decimal>,
296+
#[serde(rename = "Type")]
297+
pub item_type: String,
298+
pub provider_ids: EmbyWebhookItemProviderIdsPayload,
299+
#[serde(rename = "ParentIndexNumber")]
300+
pub season_number: Option<i32>,
301+
#[serde(rename = "IndexNumber")]
302+
pub episode_number: Option<i32>,
303+
#[serde(rename = "Name")]
304+
pub episode_name: Option<String>,
305+
pub series_name: Option<String>,
306+
}
307+
#[derive(Serialize, Deserialize, Debug, Clone)]
308+
#[serde(rename_all = "PascalCase")]
309+
pub struct EmbyWebhookPayload {
310+
pub event: Option<String>,
311+
pub item: EmbyWebhookItemPayload,
312+
pub series: Option<EmbyWebhookItemPayload>,
313+
pub playback_info: EmbyWebhookPlaybackInfoPayload,
314+
}
315+
}
316+
317+
let payload = serde_json::from_str::<models::EmbyWebhookPayload>(payload)?;
318+
319+
let identifier = if let Some(id) = payload.item.provider_ids.tmdb.as_ref() {
320+
Some(id.clone())
321+
} else {
322+
payload
323+
.series
324+
.as_ref()
325+
.and_then(|s| s.provider_ids.tmdb.clone())
326+
};
327+
328+
if payload.item.run_time_ticks.is_none() {
329+
bail!("No run time associated with this media")
330+
}
331+
if payload.playback_info.position_ticks.is_none() {
332+
bail!("No position associated with this media")
333+
}
334+
335+
let runtime = payload.item.run_time_ticks.unwrap();
336+
let position = payload.playback_info.position_ticks.unwrap();
337+
338+
let (identifier, lot) = match payload.item.item_type.as_str() {
339+
"Movie" => {
340+
if identifier.is_none() {
341+
bail!("No TMDb ID associated with this media")
342+
}
343+
344+
(identifier.unwrap().to_owned(), MediaLot::Movie)
345+
}
346+
"Episode" => {
347+
if payload.item.episode_name.is_none() {
348+
bail!("No episode name associated with this media")
349+
}
350+
351+
if payload.item.series_name.is_none() {
352+
bail!("No series name associated with this media")
353+
}
354+
355+
let series_name = payload.item.series_name.unwrap();
356+
let episode_name = payload.item.episode_name.unwrap();
357+
let db_show = self
358+
.get_show_by_episode_identifier(&series_name, &episode_name)
359+
.await?;
360+
(db_show.identifier, MediaLot::Show)
361+
}
362+
_ => bail!("Only movies and shows supported"),
363+
};
364+
365+
Ok(IntegrationMediaSeen {
366+
identifier,
367+
lot,
368+
source: MediaSource::Tmdb,
369+
progress: position / runtime * dec!(100),
370+
show_season_number: payload.item.season_number,
371+
show_episode_number: payload.item.episode_number,
372+
provider_watched_on: Some("Emby".to_string()),
373+
..Default::default()
374+
})
375+
}
376+
260377
pub async fn kodi_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
261378
let mut payload = match serde_json::from_str::<IntegrationMediaSeen>(payload) {
262379
Result::Ok(val) => val,

apps/backend/src/miscellaneous.rs

+1
Original file line numberDiff line numberDiff line change
@@ -5883,6 +5883,7 @@ impl MiscellaneousService {
58835883
let service = self.get_integration_service();
58845884
let maybe_progress_update = match integration.source {
58855885
IntegrationSource::Kodi => service.kodi_progress(&payload).await,
5886+
IntegrationSource::Emby => service.emby_progress(&payload).await,
58865887
IntegrationSource::Jellyfin => service.jellyfin_progress(&payload).await,
58875888
IntegrationSource::Plex => {
58885889
let specifics = integration.clone().source_specifics.unwrap();

docs/content/integrations.md

+24-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ https://pro.ryot.io/backend/_i/int_a6cGGXEq6KOI # example
4747
### Jellyfin
4848

4949
Automatically add new [Jellyin](https://jellyfin.org/) movie and show plays to Ryot. It
50-
will work for all the media that have been a valid TMDb ID attached to their metadata.
50+
will work for all the media that have a valid TMDb ID attached to their metadata.
5151

5252
!!! info
5353

@@ -64,10 +64,31 @@ will work for all the media that have been a valid TMDb ID attached to their met
6464
- Listen to events only for => Choose your user
6565
- Events => `Play`, `Pause`, `Resume`, `Stop` and `Progress`
6666

67+
### Emby
68+
69+
Automatically add new [Emby](https://emby.media/) movie and show plays to Ryot. It
70+
will work for all the media that have a valid TMDb ID attached to their metadata.
71+
72+
1. Generate a slug in the integration settings page. Copy the newly generated
73+
webhook Url.
74+
2. In the Emby notification settings page, add a new notification using the
75+
Webhooks option:
76+
- Name => `ryot`
77+
- Url => `<paste_url_copied>`
78+
- Request Content Type => `application/json`
79+
- Events => `Play`, `Pause`, `Resume`, `Stop` and `Progress`
80+
- Limit user events to => Choose your user
81+
82+
!!! warning
83+
84+
Since Emby does not send the expected TMDb ID for shows, progress will only be synced
85+
if you already have the show in the Ryot database. To do this, simply add the show to
86+
your watchlist.
87+
6788
### Plex
6889

6990
Automatically add [Plex](https://www.plex.tv/) show and movie plays to Ryot. It will
70-
work for all the media that have been a valid TMDb ID attached to their metadata.
91+
work for all the media that have a valid TMDb ID attached to their metadata.
7192

7293
1. Generate a slug in the integration settings page using the following settings:
7394
- Username => Your Plex `Fullname`. If you have no `Fullname` specified in Plex,
@@ -85,7 +106,7 @@ work for all the media that have been a valid TMDb ID attached to their metadata
85106
### Kodi
86107

87108
The [Kodi](https://kodi.tv/) integration allows syncing the current movie or TV
88-
show you are watching. It will work for all the media that have been a valid
109+
show you are watching. It will work for all the media that have a valid
89110
TMDb ID attached to their metadata.
90111

91112
1. Generate a slug in the integration settings page. Copy the newly generated

libs/database/src/definitions.rs

+1
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ pub enum IntegrationLot {
448448
pub enum IntegrationSource {
449449
Audiobookshelf,
450450
Jellyfin,
451+
Emby,
451452
Plex,
452453
Kodi,
453454
}

libs/generated/src/graphql/backend/graphql.ts

+1
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ export enum IntegrationLot {
713713

714714
export enum IntegrationSource {
715715
Audiobookshelf = 'AUDIOBOOKSHELF',
716+
Emby = 'EMBY',
716717
Jellyfin = 'JELLYFIN',
717718
Kodi = 'KODI',
718719
Plex = 'PLEX'

0 commit comments

Comments
 (0)