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

@@ -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>;
}