Some checks failed
CI / Check / Test (push) Failing after 6m5s
Webhook ingestion from media servers — movies land in a pending watch queue, user rates and confirms to create diary entries. - domain: WatchEvent, WebhookToken models, MediaServerParser port - adapters: jellyfin + plex parser crates, SQLite + Postgres repos - application: ingest/confirm/dismiss/cleanup use cases, token mgmt - presentation: webhook endpoints (bearer + query param auth), watch queue + integrations settings HTML pages, OpenAPI docs - worker: WatchEventCleanupJob (daily, 30d retention) Movie resolution deferred to confirm — single canonical path through log_review for enrichment, poster fetch, federation.
133 lines
3.9 KiB
Rust
133 lines
3.9 KiB
Rust
use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser};
|
|
use serde::Deserialize;
|
|
|
|
pub struct JellyfinParser;
|
|
|
|
impl MediaServerParser for JellyfinParser {
|
|
fn parse_playback_event(
|
|
&self,
|
|
body: &[u8],
|
|
) -> Result<Option<ParsedPlaybackEvent>, DomainError> {
|
|
let payload: JellyfinPayload = serde_json::from_slice(body)
|
|
.map_err(|e| DomainError::ValidationError(format!("invalid Jellyfin payload: {e}")))?;
|
|
|
|
if payload.notification_type != "PlaybackStop" {
|
|
return Ok(None);
|
|
}
|
|
|
|
let item_type = payload.item_type.as_deref().unwrap_or("");
|
|
if item_type != "Movie" {
|
|
return Ok(None);
|
|
}
|
|
|
|
if !payload.played_to_completion.unwrap_or(false) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let title = match payload.name {
|
|
Some(t) if !t.is_empty() => t,
|
|
_ => return Ok(None),
|
|
};
|
|
|
|
let tmdb_id = payload.provider_tmdb.map(|id| format!("tmdb:{id}"));
|
|
let imdb_id = payload.provider_imdb;
|
|
|
|
Ok(Some(ParsedPlaybackEvent {
|
|
title,
|
|
year: payload.year,
|
|
tmdb_id,
|
|
imdb_id,
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct JellyfinPayload {
|
|
#[serde(rename = "NotificationType")]
|
|
notification_type: String,
|
|
#[serde(rename = "ItemType")]
|
|
item_type: Option<String>,
|
|
#[serde(rename = "Name")]
|
|
name: Option<String>,
|
|
#[serde(rename = "Year")]
|
|
year: Option<u16>,
|
|
#[serde(rename = "PlayedToCompletion")]
|
|
played_to_completion: Option<bool>,
|
|
#[serde(rename = "Provider_tmdb")]
|
|
provider_tmdb: Option<String>,
|
|
#[serde(rename = "Provider_imdb")]
|
|
provider_imdb: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_valid_playback_stop() {
|
|
let body = serde_json::json!({
|
|
"NotificationType": "PlaybackStop",
|
|
"ItemType": "Movie",
|
|
"Name": "Blade Runner",
|
|
"Year": 1982,
|
|
"PlayedToCompletion": true,
|
|
"Provider_tmdb": "78",
|
|
"Provider_imdb": "tt0083658"
|
|
});
|
|
let parser = JellyfinParser;
|
|
let result = parser
|
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
.unwrap();
|
|
let event = result.expect("should parse");
|
|
assert_eq!(event.title, "Blade Runner");
|
|
assert_eq!(event.year, Some(1982));
|
|
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
|
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_non_movie() {
|
|
let body = serde_json::json!({
|
|
"NotificationType": "PlaybackStop",
|
|
"ItemType": "Episode",
|
|
"Name": "Some Episode",
|
|
"PlayedToCompletion": true
|
|
});
|
|
let parser = JellyfinParser;
|
|
let result = parser
|
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
.unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_incomplete_playback() {
|
|
let body = serde_json::json!({
|
|
"NotificationType": "PlaybackStop",
|
|
"ItemType": "Movie",
|
|
"Name": "Blade Runner",
|
|
"PlayedToCompletion": false
|
|
});
|
|
let parser = JellyfinParser;
|
|
let result = parser
|
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
.unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_playback_start() {
|
|
let body = serde_json::json!({
|
|
"NotificationType": "PlaybackStart",
|
|
"ItemType": "Movie",
|
|
"Name": "Blade Runner",
|
|
"PlayedToCompletion": false
|
|
});
|
|
let parser = JellyfinParser;
|
|
let result = parser
|
|
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
|
.unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
}
|