feat: Jellyfin/Plex auto-import via watch queue
Some checks failed
CI / Check / Test (push) Failing after 6m5s
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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
236
crates/domain/src/models/watch_event.rs
Normal file
236
crates/domain/src/models/watch_event.rs
Normal 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>,
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user