Repository: IgnisDa/ryot (language: rust)

Issue:
[FEATURE REQUEST] - Emby Webhook Integration
It would be great if Ryot could accept webhooks from Emby. When a movie finishes in Emby, a webhook could be sent to Ryot to track the date the movie was listened to, minimizing manual input.

Reference: https://emby.media/support/articles/Webhooks.html

Similar to https://github.com/IgnisDa/ryot/issues/67



Discussion / hints:
I don't have Emby premiere, so it won't be possible for me to develop this feature. However, I will develop a Jellyfin webhook integration, so anyone who wants to develop this can take reference for this.
I'd love to test the Jellyfin webhook
Will ping you when I make a PR.
Closing this since I do not have bandwidth to work on this. If anyone wants to work on this, please ping me here and I will happily re-open this issue.
Reopening this incase someone wants to complete this as a hacktoberfest task.
+1 vote for Emby integration.

Fix patch:
diff --git a/README.md b/README.md
index 89eb38ef19..a9f382cd8f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/apps/backend/src/integrations.rs b/apps/backend/src/integrations.rs
index d2ae0a2cb2..5fb2cf3980 100644
--- a/apps/backend/src/integrations.rs
+++ b/apps/backend/src/integrations.rs
@@ -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};
 
@@ -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::*;
@@ -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"),
         };
@@ -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,
diff --git a/apps/backend/src/miscellaneous.rs b/apps/backend/src/miscellaneous.rs
index f958af5da2..8a74d601d7 100644
--- a/apps/backend/src/miscellaneous.rs
+++ b/apps/backend/src/miscellaneous.rs
@@ -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();
diff --git a/docs/content/integrations.md b/docs/content/integrations.md
index 69e78d7fc1..04df0cf932 100644
--- a/docs/content/integrations.md
+++ b/docs/content/integrations.md
@@ -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
 
@@ -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,
@@ -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
diff --git a/libs/database/src/definitions.rs b/libs/database/src/definitions.rs
index bcab4d5819..d04e7094a9 100644
--- a/libs/database/src/definitions.rs
+++ b/libs/database/src/definitions.rs
@@ -448,6 +448,7 @@ pub enum IntegrationLot {
 pub enum IntegrationSource {
     Audiobookshelf,
     Jellyfin,
+    Emby,
     Plex,
     Kodi,
 }
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index 5967207528..4ef237c8df 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -713,6 +713,7 @@ export enum IntegrationLot {
 
 export enum IntegrationSource {
   Audiobookshelf = 'AUDIOBOOKSHELF',
+  Emby = 'EMBY',
   Jellyfin = 'JELLYFIN',
   Kodi = 'KODI',
   Plex = 'PLEX'


Test patch: