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

@@ -16,17 +16,24 @@ use application::ports::{
};
use application::{
commands::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RemoveFromWatchlistCommand,
AddToWatchlistCommand, ConfirmWatchEventsCommand, DeleteReviewCommand,
DismissWatchEventsCommand, GenerateWebhookTokenCommand, MovieInput,
RemoveFromWatchlistCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistPageData,
HtmlPageContext, IntegrationsPageData, LoginPageData, MovieDetailPageData,
NewReviewPageData, ProfileSettingsPageData, RegisterPageData, RemoteActorView,
WatchQueueDisplayEntry, WatchQueuePageData, WatchlistPageData, WebhookTokenView,
},
queries::{
ExportQuery, GetMovieSocialPageQuery, GetWatchQueueQuery, GetWebhookTokensQuery,
IsOnWatchlistQuery, LoginQuery,
},
queries::{ExportQuery, GetMovieSocialPageQuery, IsOnWatchlistQuery, LoginQuery},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
is_on_watchlist, log_review, login as login_uc, remove_from_watchlist, update_profile,
update_profile_fields,
add_to_watchlist, confirm_watch_events, delete_review, dismiss_watch_events,
export_diary as export_diary_uc, generate_webhook_token, get_movie_social_page,
get_watch_queue, get_webhook_tokens, is_on_watchlist, log_review, login as login_uc,
remove_from_watchlist, revoke_webhook_token, update_profile, update_profile_fields,
},
};
use domain::models::ExportFormat;
@@ -1499,3 +1506,209 @@ pub async fn post_profile_settings(
Redirect::to("/settings/profile?saved=1").into_response()
}
// ── Integrations ──────────────────────────────────────────────────────────────
pub async fn get_integrations_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<crate::forms::IntegrationsQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Integrations — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/integrations", state.app_ctx.config.base_url);
let query = GetWebhookTokensQuery {
user_id: user_id.value(),
};
let tokens = get_webhook_tokens::execute(&state.app_ctx, query)
.await
.unwrap_or_default();
let token_views: Vec<WebhookTokenView> = tokens
.into_iter()
.map(|t| WebhookTokenView {
id: t.id().value().to_string(),
provider: t.provider().to_string(),
label: t.label().map(String::from),
created_at: t.created_at().format("%Y-%m-%d %H:%M").to_string(),
last_used_at: t
.last_used_at()
.map(|d| d.format("%Y-%m-%d %H:%M").to_string()),
})
.collect();
let data = IntegrationsPageData {
ctx,
tokens: token_views,
webhook_base_url: state.app_ctx.config.base_url.clone(),
new_token: params.token,
};
match state.html_renderer.render_integrations_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("integrations template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_generate_token(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::GenerateTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let provider = match form.provider.parse::<domain::models::WatchEventSource>() {
Ok(p) => p,
Err(_) => return Redirect::to("/settings/integrations").into_response(),
};
let cmd = GenerateWebhookTokenCommand {
user_id: user_id.value(),
provider,
label: form.label.filter(|l| !l.trim().is_empty()),
};
match generate_webhook_token::execute(&state.app_ctx, cmd).await {
Ok(result) => {
let encoded = percent_encoding::utf8_percent_encode(
&result.token_plaintext,
percent_encoding::NON_ALPHANUMERIC,
);
Redirect::to(&format!("/settings/integrations?token={encoded}")).into_response()
}
Err(e) => {
tracing::error!("generate token failed: {:?}", e);
Redirect::to("/settings/integrations").into_response()
}
}
}
pub async fn post_revoke_token(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(token_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::RevokeTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = RevokeWebhookTokenCommand {
user_id: user_id.value(),
token_id,
};
if let Err(e) = revoke_webhook_token::execute(&state.app_ctx, cmd).await {
tracing::error!("revoke token failed: {:?}", e);
}
Redirect::to("/settings/integrations").into_response()
}
// ── Watch Queue ───────────────────────────────────────────────────────────────
pub async fn get_watch_queue_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Watch Queue — Movies Diary".to_string();
ctx.canonical_url = format!("{}/watch-queue", state.app_ctx.config.base_url);
let query = GetWatchQueueQuery {
user_id: user_id.value(),
};
let events = get_watch_queue::execute(&state.app_ctx, query)
.await
.unwrap_or_default();
let entries: Vec<WatchQueueDisplayEntry> = events
.into_iter()
.map(|e| WatchQueueDisplayEntry {
id: e.id().value().to_string(),
title: e.title().to_string(),
year: e.year(),
source: e.source().to_string(),
watched_at: e.watched_at().format("%Y-%m-%d %H:%M").to_string(),
movie_url: e.movie_id().map(|m| format!("/movies/{}", m.value())),
})
.collect();
let data = WatchQueuePageData {
ctx,
entries,
error: params.error,
};
match state.html_renderer.render_watch_queue_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("watch_queue template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_confirm_single(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::ConfirmWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = ConfirmWatchEventsCommand {
user_id: user_id.value(),
confirmations: vec![WatchEventConfirmation {
watch_event_id: event_id,
rating: form.rating,
comment: form.comment.filter(|c| !c.trim().is_empty()),
}],
};
match confirm_watch_events::execute(&state.app_ctx, cmd).await {
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}
}
pub async fn post_dismiss_single(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::DismissWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = DismissWatchEventsCommand {
user_id: user_id.value(),
event_ids: vec![event_id],
};
match dismiss_watch_events::execute(&state.app_ctx, cmd).await {
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}
}

View File

@@ -3,6 +3,7 @@ pub mod html;
pub mod images;
pub mod import;
pub mod rss;
pub mod webhook;
const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;

View File

@@ -0,0 +1,319 @@
use axum::{
Json,
extract::{Multipart, Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use uuid::Uuid;
use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
};
use application::{
commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
},
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
use_cases::{
confirm_watch_events, dismiss_watch_events, generate_webhook_token, get_watch_queue,
get_webhook_tokens, ingest_watch_event, revoke_webhook_token,
},
};
use domain::models::WatchEventSource;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|t| t.trim().to_string())
}
#[derive(serde::Deserialize, Default)]
pub struct WebhookTokenQuery {
pub token: Option<String>,
}
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
extract_bearer_token(headers).or_else(|| query.token.clone())
}
// ── Webhook ingestion (no JWT, uses webhook bearer token) ─────────────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/jellyfin",
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
),
security(("bearer_auth" = []))
)]
pub async fn post_jellyfin_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
let cmd = IngestWatchEventCommand {
token,
raw_payload: body.to_vec(),
source: WatchEventSource::Jellyfin,
};
run_ingest(&state, cmd, &jellyfin::JellyfinParser).await
}
// ── Plex webhook (multipart form data with `payload` JSON field) ──────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/plex",
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
),
security(("bearer_auth" = []))
)]
pub async fn post_plex_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
let mut payload_bytes: Option<Vec<u8>> = None;
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() == Some("payload")
&& let Ok(bytes) = field.bytes().await
{
payload_bytes = Some(bytes.to_vec());
break;
}
}
let raw_payload = match payload_bytes {
Some(b) => b,
None => return StatusCode::BAD_REQUEST,
};
let cmd = IngestWatchEventCommand {
token,
raw_payload,
source: WatchEventSource::Plex,
};
run_ingest(&state, cmd, &plex::PlexParser).await
}
async fn run_ingest(
state: &AppState,
cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser,
) -> StatusCode {
match ingest_watch_event::execute(&state.app_ctx, cmd, parser).await {
Ok(()) => StatusCode::OK,
Err(domain::errors::DomainError::Unauthorized(_)) => StatusCode::UNAUTHORIZED,
Err(domain::errors::DomainError::ValidationError(_)) => StatusCode::BAD_REQUEST,
Err(e) => {
tracing::error!("webhook ingestion failed: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
// ── Token management (JWT-authenticated) ──────────────────────────────────────
#[utoipa::path(
post, path = "/api/v1/settings/webhook-tokens",
request_body = GenerateTokenRequest,
responses(
(status = 200, body = GenerateTokenResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, ApiError> {
let provider: WatchEventSource = req
.provider
.parse()
.map_err(|e: String| domain::errors::DomainError::ValidationError(e))?;
let cmd = GenerateWebhookTokenCommand {
user_id: user.0.value(),
provider: provider.clone(),
label: req.label,
};
let result = generate_webhook_token::execute(&state.app_ctx, cmd).await?;
let base_url = &state.app_ctx.config.base_url;
let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}");
Ok(Json(GenerateTokenResponse {
id: result.token.id().value().to_string(),
token: result.token_plaintext,
webhook_url,
}))
}
#[utoipa::path(
get, path = "/api/v1/settings/webhook-tokens",
responses(
(status = 200, body = Vec<WebhookTokenDto>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_webhook_tokens(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WebhookTokenDto>>, ApiError> {
let query = GetWebhookTokensQuery {
user_id: user.0.value(),
};
let tokens = get_webhook_tokens::execute(&state.app_ctx, query).await?;
let dtos = tokens
.into_iter()
.map(|t| WebhookTokenDto {
id: t.id().value().to_string(),
provider: t.provider().to_string(),
label: t.label().map(String::from),
created_at: t.created_at().to_string(),
last_used_at: t.last_used_at().map(|d| d.to_string()),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
delete, path = "/api/v1/settings/webhook-tokens/{id}",
params(("id" = Uuid, Path, description = "Webhook token ID")),
responses(
(status = 204, description = "Token revoked"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Token not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = RevokeWebhookTokenCommand {
user_id: user.0.value(),
token_id: id,
};
revoke_webhook_token::execute(&state.app_ctx, cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Watch queue (JWT-authenticated) ───────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/watch-queue",
responses(
(status = 200, body = Vec<WatchQueueEntryDto>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watch_queue(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WatchQueueEntryDto>>, ApiError> {
let query = GetWatchQueueQuery {
user_id: user.0.value(),
};
let events = get_watch_queue::execute(&state.app_ctx, query).await?;
let dtos = events
.into_iter()
.map(|e| WatchQueueEntryDto {
id: e.id().value().to_string(),
title: e.title().to_string(),
year: e.year(),
movie_id: e.movie_id().map(|m| m.value().to_string()),
source: e.source().to_string(),
watched_at: e.watched_at().to_string(),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/confirm",
request_body = ConfirmWatchRequest,
responses(
(status = 200, body = ConfirmWatchResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn post_confirm_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
let cmd = ConfirmWatchEventsCommand {
user_id: user.0.value(),
confirmations: req
.confirmations
.into_iter()
.map(|c| WatchEventConfirmation {
watch_event_id: c.watch_event_id,
rating: c.rating,
comment: c.comment,
})
.collect(),
};
let confirmed = confirm_watch_events::execute(&state.app_ctx, cmd).await?;
Ok(Json(ConfirmWatchResponse { confirmed }))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/dismiss",
request_body = DismissWatchRequest,
responses(
(status = 200, body = DismissWatchResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn post_dismiss_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand {
user_id: user.0.value(),
event_ids: req.event_ids,
};
let dismissed = dismiss_watch_events::execute(&state.app_ctx, cmd).await?;
Ok(Json(DismissWatchResponse { dismissed }))
}