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

@@ -70,6 +70,11 @@ pub enum DomainEvent {
activity_json: String,
signing_actor_id: uuid::Uuid,
},
WatchEventIngested {
user_id: UserId,
title: String,
source: String,
},
}
#[async_trait]

View File

@@ -18,6 +18,11 @@ pub mod watchlist;
pub use watchlist::{WatchlistEntry, WatchlistWithMovie};
pub mod remote_watchlist;
pub use remote_watchlist::RemoteWatchlistEntry;
pub mod watch_event;
pub use watch_event::{
ParsedPlaybackEvent, PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus,
WebhookToken,
};
pub use import::{
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError, ImportRow, ParsedFile,

View File

@@ -0,0 +1,236 @@
use chrono::NaiveDateTime;
use crate::value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WatchEventSource {
Jellyfin,
Plex,
}
impl std::fmt::Display for WatchEventSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Jellyfin => write!(f, "jellyfin"),
Self::Plex => write!(f, "plex"),
}
}
}
impl std::str::FromStr for WatchEventSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"jellyfin" => Ok(Self::Jellyfin),
"plex" => Ok(Self::Plex),
other => Err(format!("unknown watch event source: {other}")),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum WatchEventStatus {
#[default]
Pending,
Confirmed,
Dismissed,
}
impl std::fmt::Display for WatchEventStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Confirmed => write!(f, "confirmed"),
Self::Dismissed => write!(f, "dismissed"),
}
}
}
impl std::str::FromStr for WatchEventStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(Self::Pending),
"confirmed" => Ok(Self::Confirmed),
"dismissed" => Ok(Self::Dismissed),
other => Err(format!("unknown watch event status: {other}")),
}
}
}
pub struct PersistedWatchEvent {
pub id: WatchEventId,
pub user_id: UserId,
pub movie_id: Option<MovieId>,
pub title: String,
pub year: Option<u16>,
pub external_metadata_id: Option<String>,
pub source: WatchEventSource,
pub watched_at: NaiveDateTime,
pub status: WatchEventStatus,
pub created_at: NaiveDateTime,
}
#[derive(Clone, Debug)]
pub struct WatchEvent {
id: WatchEventId,
user_id: UserId,
movie_id: Option<MovieId>,
title: String,
year: Option<u16>,
external_metadata_id: Option<String>,
source: WatchEventSource,
watched_at: NaiveDateTime,
status: WatchEventStatus,
created_at: NaiveDateTime,
}
impl WatchEvent {
pub fn new(
user_id: UserId,
title: String,
year: Option<u16>,
external_metadata_id: Option<String>,
source: WatchEventSource,
watched_at: NaiveDateTime,
movie_id: Option<MovieId>,
) -> Self {
Self {
id: WatchEventId::generate(),
user_id,
movie_id,
title,
year,
external_metadata_id,
source,
watched_at,
status: WatchEventStatus::Pending,
created_at: chrono::Utc::now().naive_utc(),
}
}
pub fn from_persistence(row: PersistedWatchEvent) -> Self {
Self {
id: row.id,
user_id: row.user_id,
movie_id: row.movie_id,
title: row.title,
year: row.year,
external_metadata_id: row.external_metadata_id,
source: row.source,
watched_at: row.watched_at,
status: row.status,
created_at: row.created_at,
}
}
pub fn id(&self) -> &WatchEventId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn movie_id(&self) -> Option<&MovieId> {
self.movie_id.as_ref()
}
pub fn title(&self) -> &str {
&self.title
}
pub fn year(&self) -> Option<u16> {
self.year
}
pub fn external_metadata_id(&self) -> Option<&str> {
self.external_metadata_id.as_deref()
}
pub fn source(&self) -> &WatchEventSource {
&self.source
}
pub fn watched_at(&self) -> &NaiveDateTime {
&self.watched_at
}
pub fn status(&self) -> &WatchEventStatus {
&self.status
}
pub fn created_at(&self) -> &NaiveDateTime {
&self.created_at
}
}
#[derive(Clone, Debug)]
pub struct WebhookToken {
id: WebhookTokenId,
user_id: UserId,
token_hash: String,
provider: WatchEventSource,
label: Option<String>,
created_at: NaiveDateTime,
last_used_at: Option<NaiveDateTime>,
}
impl WebhookToken {
pub fn new(
user_id: UserId,
token_hash: String,
provider: WatchEventSource,
label: Option<String>,
) -> Self {
Self {
id: WebhookTokenId::generate(),
user_id,
token_hash,
provider,
label,
created_at: chrono::Utc::now().naive_utc(),
last_used_at: None,
}
}
pub fn from_persistence(
id: WebhookTokenId,
user_id: UserId,
token_hash: String,
provider: WatchEventSource,
label: Option<String>,
created_at: NaiveDateTime,
last_used_at: Option<NaiveDateTime>,
) -> Self {
Self {
id,
user_id,
token_hash,
provider,
label,
created_at,
last_used_at,
}
}
pub fn id(&self) -> &WebhookTokenId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn token_hash(&self) -> &str {
&self.token_hash
}
pub fn provider(&self) -> &WatchEventSource {
&self.provider
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn created_at(&self) -> &NaiveDateTime {
&self.created_at
}
pub fn last_used_at(&self) -> Option<&NaiveDateTime> {
self.last_used_at.as_ref()
}
}
pub struct ParsedPlaybackEvent {
pub title: String,
pub year: Option<u16>,
pub tmdb_id: Option<String>,
pub imdb_id: Option<String>,
}

View File

@@ -8,14 +8,15 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
Person, PersonCredits, PersonId, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery,
SearchResults, User, UserStats, UserSummary, UserTrends, WatchlistEntry,
WatchlistWithMovie,
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteWatchlistEntry, Review,
ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary, UserTrends,
WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
collections::{self, PageParams, Paginated},
},
value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, WatchEventId,
WebhookTokenId,
},
};
@@ -415,3 +416,41 @@ pub trait LocalApContentQuery: Send + Sync {
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
}
// ── Media server integration ──────────────────────────────────────────────────
pub trait MediaServerParser: Send + Sync {
fn parse_playback_event(&self, body: &[u8])
-> Result<Option<ParsedPlaybackEvent>, DomainError>;
}
#[async_trait]
pub trait WatchEventRepository: Send + Sync {
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
async fn update_status(
&self,
id: &WatchEventId,
status: WatchEventStatus,
) -> Result<(), DomainError>;
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError>;
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError>;
async fn find_duplicate(
&self,
user_id: &UserId,
external_id: &str,
after: chrono::NaiveDateTime,
) -> Result<bool, DomainError>;
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError>;
}
#[async_trait]
pub trait WebhookTokenRepository: Send + Sync {
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError>;
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError>;
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError>;
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>;
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>;
}

View File

@@ -839,3 +839,83 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
Ok(vec![])
}
}
// ── PanicWatchEventRepository ────────────────────────────────────────────────
pub struct PanicWatchEventRepository;
#[async_trait]
impl crate::ports::WatchEventRepository for PanicWatchEventRepository {
async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn update_status(
&self,
_: &crate::value_objects::WatchEventId,
_: crate::models::WatchEventStatus,
) -> Result<(), DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn list_pending(
&self,
_: &UserId,
) -> Result<Vec<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn get_by_id(
&self,
_: &crate::value_objects::WatchEventId,
) -> Result<Option<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn find_duplicate(
&self,
_: &UserId,
_: &str,
_: chrono::NaiveDateTime,
) -> Result<bool, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn delete_non_pending_older_than(
&self,
_: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
panic!("PanicWatchEventRepository called")
}
}
// ── PanicWebhookTokenRepository ──────────────────────────────────────────────
pub struct PanicWebhookTokenRepository;
#[async_trait]
impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository {
async fn save(&self, _: &crate::models::WebhookToken) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn find_by_token_hash(
&self,
_: &str,
) -> Result<Option<crate::models::WebhookToken>, DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn list_by_user(
&self,
_: &UserId,
) -> Result<Vec<crate::models::WebhookToken>, DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn delete(
&self,
_: &crate::value_objects::WebhookTokenId,
_: &UserId,
) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn touch_last_used(
&self,
_: &crate::value_objects::WebhookTokenId,
) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
}

View File

@@ -26,6 +26,8 @@ uuid_id!(UserId);
uuid_id!(ImportSessionId);
uuid_id!(ImportProfileId);
uuid_id!(WatchlistEntryId);
uuid_id!(WatchEventId);
uuid_id!(WebhookTokenId);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMetadataId(String);