Skip to content

Commit

Permalink
Emby integration (#927)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Jacob-Tate and IgnisDa authored Jul 28, 2024
1 parent 69d9fad commit 2ae467b
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 23 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ You can also open an issue on GitHub if you find any bugs or have feature reques
-[Supports](https://github.com/IgnisDa/ryot/discussions/4) tracking media
and fitness
- ✅ Import data from Goodreads, Trakt, Strong App [etc](https://docs.ryot.io/importing.html)
- ✅ Integration with Jellyfin, Kodi, Plex, Audiobookshelf [etc](https://docs.ryot.io/integrations.html)
- ✅ Integration with Jellyfin, Kodi, Plex, Emby, Audiobookshelf [etc](https://docs.ryot.io/integrations.html)
-[Supports](https://docs.ryot.io/guides/openid.html) OpenID Connect
- ✅ Sends notifications to Discord, Ntfy, Apprise etc
- ✅ Self-hosted
Expand Down
155 changes: 136 additions & 19 deletions apps/backend/src/integrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::header::{HeaderValue, AUTHORIZATION};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter};
use sea_query::{extension::postgres::PgExpr, Alias, Expr, Func};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -52,6 +52,38 @@ impl IntegrationService {
Self { db: db.clone() }
}

// DEV: Fuzzy search for show by episode name and series name.
async fn get_show_by_episode_identifier(
&self,
series: &str,
episode: &str,
) -> Result<metadata::Model> {
let db_show = Metadata::find()
.filter(metadata::Column::Lot.eq(MediaLot::Show))
.filter(metadata::Column::Source.eq(MediaSource::Tmdb))
.filter(
Condition::all()
.add(
Expr::expr(Func::cast_as(
Expr::col(metadata::Column::ShowSpecifics),
Alias::new("text"),
))
.ilike(ilike_sql(episode)),
)
.add(Expr::col(metadata::Column::Title).ilike(ilike_sql(series))),
)
.one(&self.db)
.await?;
match db_show {
Some(show) => Ok(show),
None => bail!(
"No show found with Series {:#?} and Episode {:#?}",
series,
episode
),
}
}

pub async fn jellyfin_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
mod models {
use super::*;
Expand Down Expand Up @@ -215,25 +247,11 @@ impl IntegrationService {
let (identifier, lot) = match payload.metadata.item_type.as_str() {
"movie" => (identifier.to_owned(), MediaLot::Movie),
"episode" => {
// DEV: Since Plex and Ryot both use TMDb, we can safely assume that the
// TMDB ID sent by Plex (which is actually the episode ID) is also present
// in the media specifics we have in DB.
let db_show = Metadata::find()
.filter(metadata::Column::Lot.eq(MediaLot::Show))
.filter(metadata::Column::Source.eq(MediaSource::Tmdb))
.filter(
Expr::expr(Func::cast_as(
Expr::col(metadata::Column::ShowSpecifics),
Alias::new("text"),
))
.ilike(ilike_sql(identifier)),
)
.one(&self.db)
let series_name = payload.metadata.show_name.as_ref().unwrap();
let db_show = self
.get_show_by_episode_identifier(series_name, identifier)
.await?;
if db_show.is_none() {
bail!("No show found with TMDb ID {}", identifier);
}
(db_show.unwrap().identifier, MediaLot::Show)
(db_show.identifier, MediaLot::Show)
}
_ => bail!("Only movies and shows supported"),
};
Expand All @@ -257,6 +275,105 @@ impl IntegrationService {
})
}

pub async fn emby_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
mod models {
use super::*;

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct EmbyWebhookPlaybackInfoPayload {
pub position_ticks: Option<Decimal>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct EmbyWebhookItemProviderIdsPayload {
pub tmdb: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct EmbyWebhookItemPayload {
pub run_time_ticks: Option<Decimal>,
#[serde(rename = "Type")]
pub item_type: String,
pub provider_ids: EmbyWebhookItemProviderIdsPayload,
#[serde(rename = "ParentIndexNumber")]
pub season_number: Option<i32>,
#[serde(rename = "IndexNumber")]
pub episode_number: Option<i32>,
#[serde(rename = "Name")]
pub episode_name: Option<String>,
pub series_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct EmbyWebhookPayload {
pub event: Option<String>,
pub item: EmbyWebhookItemPayload,
pub series: Option<EmbyWebhookItemPayload>,
pub playback_info: EmbyWebhookPlaybackInfoPayload,
}
}

let payload = serde_json::from_str::<models::EmbyWebhookPayload>(payload)?;

let identifier = if let Some(id) = payload.item.provider_ids.tmdb.as_ref() {
Some(id.clone())
} else {
payload
.series
.as_ref()
.and_then(|s| s.provider_ids.tmdb.clone())
};

if payload.item.run_time_ticks.is_none() {
bail!("No run time associated with this media")
}
if payload.playback_info.position_ticks.is_none() {
bail!("No position associated with this media")
}

let runtime = payload.item.run_time_ticks.unwrap();
let position = payload.playback_info.position_ticks.unwrap();

let (identifier, lot) = match payload.item.item_type.as_str() {
"Movie" => {
if identifier.is_none() {
bail!("No TMDb ID associated with this media")
}

(identifier.unwrap().to_owned(), MediaLot::Movie)
}
"Episode" => {
if payload.item.episode_name.is_none() {
bail!("No episode name associated with this media")
}

if payload.item.series_name.is_none() {
bail!("No series name associated with this media")
}

let series_name = payload.item.series_name.unwrap();
let episode_name = payload.item.episode_name.unwrap();
let db_show = self
.get_show_by_episode_identifier(&series_name, &episode_name)
.await?;
(db_show.identifier, MediaLot::Show)
}
_ => bail!("Only movies and shows supported"),
};

Ok(IntegrationMediaSeen {
identifier,
lot,
source: MediaSource::Tmdb,
progress: position / runtime * dec!(100),
show_season_number: payload.item.season_number,
show_episode_number: payload.item.episode_number,
provider_watched_on: Some("Emby".to_string()),
..Default::default()
})
}

pub async fn kodi_progress(&self, payload: &str) -> Result<IntegrationMediaSeen> {
let mut payload = match serde_json::from_str::<IntegrationMediaSeen>(payload) {
Result::Ok(val) => val,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/miscellaneous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5883,6 +5883,7 @@ impl MiscellaneousService {
let service = self.get_integration_service();
let maybe_progress_update = match integration.source {
IntegrationSource::Kodi => service.kodi_progress(&payload).await,
IntegrationSource::Emby => service.emby_progress(&payload).await,
IntegrationSource::Jellyfin => service.jellyfin_progress(&payload).await,
IntegrationSource::Plex => {
let specifics = integration.clone().source_specifics.unwrap();
Expand Down
27 changes: 24 additions & 3 deletions docs/content/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ https://pro.ryot.io/backend/_i/int_a6cGGXEq6KOI # example
### Jellyfin

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

!!! info

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

### Emby

Automatically add new [Emby](https://emby.media/) movie and show plays to Ryot. It
will work for all the media that have a valid TMDb ID attached to their metadata.

1. Generate a slug in the integration settings page. Copy the newly generated
webhook Url.
2. In the Emby notification settings page, add a new notification using the
Webhooks option:
- Name => `ryot`
- Url => `<paste_url_copied>`
- Request Content Type => `application/json`
- Events => `Play`, `Pause`, `Resume`, `Stop` and `Progress`
- Limit user events to => Choose your user

!!! warning

Since Emby does not send the expected TMDb ID for shows, progress will only be synced
if you already have the show in the Ryot database. To do this, simply add the show to
your watchlist.

### Plex

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

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

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

1. Generate a slug in the integration settings page. Copy the newly generated
Expand Down
1 change: 1 addition & 0 deletions libs/database/src/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ pub enum IntegrationLot {
pub enum IntegrationSource {
Audiobookshelf,
Jellyfin,
Emby,
Plex,
Kodi,
}
Expand Down
1 change: 1 addition & 0 deletions libs/generated/src/graphql/backend/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ export enum IntegrationLot {

export enum IntegrationSource {
Audiobookshelf = 'AUDIOBOOKSHELF',
Emby = 'EMBY',
Jellyfin = 'JELLYFIN',
Kodi = 'KODI',
Plex = 'PLEX'
Expand Down

0 comments on commit 2ae467b

Please sign in to comment.