feat: Jellyfin/Plex auto-import via watch queue
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.
This commit is contained in:
2026-06-02 17:34:16 +02:00
parent 6bd728fd50
commit aadad3cfb0
65 changed files with 2946 additions and 38 deletions

View File

@@ -0,0 +1,172 @@
use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser};
use serde::Deserialize;
pub struct PlexParser;
impl MediaServerParser for PlexParser {
/// Plex sends multipart form data with a `payload` JSON field.
/// The caller must extract the JSON string from the multipart body
/// and pass it here as raw bytes.
fn parse_playback_event(
&self,
body: &[u8],
) -> Result<Option<ParsedPlaybackEvent>, DomainError> {
let payload: PlexPayload = serde_json::from_slice(body)
.map_err(|e| DomainError::ValidationError(format!("invalid Plex payload: {e}")))?;
if payload.event != "media.scrobble" {
return Ok(None);
}
let metadata = match payload.metadata {
Some(m) => m,
None => return Ok(None),
};
if metadata.media_type != "movie" {
return Ok(None);
}
if metadata.title.is_empty() {
return Ok(None);
}
let mut tmdb_id = None;
let mut imdb_id = None;
for guid in &metadata.guids {
if let Some(id) = guid.id.strip_prefix("tmdb://") {
tmdb_id = Some(format!("tmdb:{id}"));
} else if let Some(id) = guid.id.strip_prefix("imdb://") {
imdb_id = Some(id.to_string());
}
}
Ok(Some(ParsedPlaybackEvent {
title: metadata.title,
year: metadata.year.map(|y| y as u16),
tmdb_id,
imdb_id,
}))
}
}
#[derive(Deserialize)]
struct PlexPayload {
event: String,
#[serde(rename = "Metadata")]
metadata: Option<PlexMetadata>,
}
#[derive(Deserialize)]
struct PlexMetadata {
#[serde(rename = "type")]
media_type: String,
title: String,
year: Option<i32>,
#[serde(rename = "Guid", default)]
guids: Vec<PlexGuid>,
}
#[derive(Deserialize)]
struct PlexGuid {
id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_movie_scrobble() {
let body = serde_json::json!({
"event": "media.scrobble",
"Metadata": {
"type": "movie",
"title": "Blade Runner",
"year": 1982,
"Guid": [
{"id": "tmdb://78"},
{"id": "imdb://tt0083658"}
]
}
});
let parser = PlexParser;
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_tv_episode() {
let body = serde_json::json!({
"event": "media.scrobble",
"Metadata": {
"type": "episode",
"title": "Pilot",
"grandparentTitle": "Breaking Bad",
"year": 2008,
"Guid": []
}
});
let parser = PlexParser;
let result = parser
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
.unwrap();
assert!(result.is_none());
}
#[test]
fn ignores_play_event() {
let body = serde_json::json!({
"event": "media.play",
"Metadata": {
"type": "movie",
"title": "Blade Runner",
"year": 1982,
"Guid": []
}
});
let parser = PlexParser;
let result = parser
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
.unwrap();
assert!(result.is_none());
}
#[test]
fn handles_no_guids() {
let body = serde_json::json!({
"event": "media.scrobble",
"Metadata": {
"type": "movie",
"title": "Some Indie Film",
"year": 2023
}
});
let parser = PlexParser;
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, "Some Indie Film");
assert!(event.tmdb_id.is_none());
assert!(event.imdb_id.is_none());
}
#[test]
fn handles_missing_metadata() {
let body = serde_json::json!({
"event": "media.scrobble"
});
let parser = PlexParser;
let result = parser
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
.unwrap();
assert!(result.is_none());
}
}