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:
@@ -6,7 +6,8 @@ use domain::ports::{
|
||||
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository,
|
||||
LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher,
|
||||
PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository,
|
||||
WatchlistRepository, WebhookTokenRepository,
|
||||
};
|
||||
|
||||
pub struct DatabaseAdapters {
|
||||
@@ -25,6 +26,8 @@ pub struct DatabaseAdapters {
|
||||
pub search_port: Arc<dyn SearchPort>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub watch_event_repo: Arc<dyn WatchEventRepository>,
|
||||
pub webhook_token_repo: Arc<dyn WebhookTokenRepository>,
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
@@ -45,6 +48,10 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
let (pc, pq) = postgres::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(pool.clone());
|
||||
let we: Arc<dyn WatchEventRepository> =
|
||||
Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> =
|
||||
Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone()));
|
||||
Ok(DatabaseAdapters {
|
||||
movie_repo: m,
|
||||
review_repo: r,
|
||||
@@ -61,6 +68,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields_repo: pf,
|
||||
watch_event_repo: we,
|
||||
webhook_token_repo: wt,
|
||||
db_pool: DbPool::Postgres(pool),
|
||||
})
|
||||
}
|
||||
@@ -72,6 +81,10 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
|
||||
let pf = sqlite::create_profile_fields_repo(pool.clone());
|
||||
let we: Arc<dyn WatchEventRepository> =
|
||||
Arc::new(sqlite::SqliteWatchEventRepository::new(pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> =
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone()));
|
||||
Ok(DatabaseAdapters {
|
||||
movie_repo: m,
|
||||
review_repo: r,
|
||||
@@ -88,6 +101,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields_repo: pf,
|
||||
watch_event_repo: we,
|
||||
webhook_token_repo: wt,
|
||||
db_pool: DbPool::Sqlite(pool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -261,6 +261,43 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integrations forms ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GenerateTokenForm {
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RevokeTokenForm {
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct IntegrationsQuery {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmWatchForm {
|
||||
pub rating: u8,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DismissWatchForm {
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/forms.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
319
crates/presentation/src/handlers/webhook.rs
Normal file
319
crates/presentation/src/handlers/webhook.rs
Normal 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 }))
|
||||
}
|
||||
@@ -75,6 +75,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let search_port = db.search_port;
|
||||
let search_command = db.search_command;
|
||||
let profile_fields_repo = db.profile_fields_repo;
|
||||
let watch_event_repository = db.watch_event_repo;
|
||||
let webhook_token_repository = db.webhook_token_repo;
|
||||
let db_pool = db.db_pool;
|
||||
|
||||
// Wire up event channel, federation service, and ap_router
|
||||
@@ -199,6 +201,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
import_profile_repository,
|
||||
movie_profile_repository,
|
||||
watchlist_repository,
|
||||
watch_event_repository,
|
||||
webhook_token_repository,
|
||||
profile_fields_repository: profile_fields_repo,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: remote_watchlist_repo,
|
||||
|
||||
@@ -6,6 +6,7 @@ mod search;
|
||||
mod social;
|
||||
mod users;
|
||||
mod watchlist;
|
||||
mod webhook;
|
||||
|
||||
use axum::Router;
|
||||
use utoipa::{
|
||||
@@ -40,6 +41,7 @@ fn build() -> utoipa::openapi::OpenApi {
|
||||
api.merge(import::ImportDoc::openapi());
|
||||
api.merge(search::SearchDoc::openapi());
|
||||
api.merge(watchlist::WatchlistDoc::openapi());
|
||||
api.merge(webhook::WebhookDoc::openapi());
|
||||
#[cfg(feature = "federation")]
|
||||
api.merge(social::SocialDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
|
||||
32
crates/presentation/src/openapi/webhook.rs
Normal file
32
crates/presentation/src/openapi/webhook.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use api_types::{
|
||||
ConfirmWatchEntry, ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest,
|
||||
DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto,
|
||||
WebhookTokenDto,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::webhook::post_jellyfin_webhook,
|
||||
crate::handlers::webhook::post_plex_webhook,
|
||||
crate::handlers::webhook::post_generate_webhook_token,
|
||||
crate::handlers::webhook::get_webhook_tokens,
|
||||
crate::handlers::webhook::delete_webhook_token,
|
||||
crate::handlers::webhook::get_watch_queue,
|
||||
crate::handlers::webhook::post_confirm_watch_events,
|
||||
crate::handlers::webhook::post_dismiss_watch_events,
|
||||
),
|
||||
components(schemas(
|
||||
GenerateTokenRequest,
|
||||
GenerateTokenResponse,
|
||||
WebhookTokenDto,
|
||||
WatchQueueEntryDto,
|
||||
ConfirmWatchRequest,
|
||||
ConfirmWatchEntry,
|
||||
ConfirmWatchResponse,
|
||||
DismissWatchRequest,
|
||||
DismissWatchResponse,
|
||||
))
|
||||
)]
|
||||
pub struct WebhookDoc;
|
||||
@@ -139,6 +139,30 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route(
|
||||
"/watchlist/{movie_id}/remove",
|
||||
routing::post(handlers::html::post_watchlist_remove),
|
||||
)
|
||||
.route(
|
||||
"/settings/integrations",
|
||||
routing::get(handlers::html::get_integrations_page),
|
||||
)
|
||||
.route(
|
||||
"/settings/integrations/generate",
|
||||
routing::post(handlers::html::post_generate_token),
|
||||
)
|
||||
.route(
|
||||
"/settings/integrations/{id}/revoke",
|
||||
routing::post(handlers::html::post_revoke_token),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue",
|
||||
routing::get(handlers::html::get_watch_queue_page),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue/{id}/confirm",
|
||||
routing::post(handlers::html::post_confirm_single),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue/{id}/dismiss",
|
||||
routing::post(handlers::html::post_dismiss_single),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
@@ -301,12 +325,52 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/watchlist/{movie_id}",
|
||||
routing::get(handlers::api::get_watchlist_status)
|
||||
.delete(handlers::api::delete_watchlist_entry),
|
||||
)
|
||||
.route(
|
||||
"/settings/webhook-tokens",
|
||||
routing::get(handlers::webhook::get_webhook_tokens)
|
||||
.post(handlers::webhook::post_generate_webhook_token),
|
||||
)
|
||||
.route(
|
||||
"/settings/webhook-tokens/{id}",
|
||||
routing::delete(handlers::webhook::delete_webhook_token),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue",
|
||||
routing::get(handlers::webhook::get_watch_queue),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue/confirm",
|
||||
routing::post(handlers::webhook::post_confirm_watch_events),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue/dismiss",
|
||||
routing::post(handlers::webhook::post_dismiss_watch_events),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_api_routes());
|
||||
|
||||
Router::new().nest("/api/v1", base.layer(GovernorLayer::new(cfg)))
|
||||
let webhook_cfg = GovernorConfigBuilder::default()
|
||||
.with_extractor(PeerIp::default())
|
||||
.expect_connect_info()
|
||||
.quota_default(per_minute(rate_limit / 4))
|
||||
.finish()
|
||||
.unwrap();
|
||||
let webhook_routes = Router::new()
|
||||
.route(
|
||||
"/webhooks/jellyfin",
|
||||
routing::post(handlers::webhook::post_jellyfin_webhook),
|
||||
)
|
||||
.route(
|
||||
"/webhooks/plex",
|
||||
routing::post(handlers::webhook::post_plex_webhook),
|
||||
)
|
||||
.layer(GovernorLayer::new(webhook_cfg));
|
||||
|
||||
Router::new()
|
||||
.nest("/api/v1", base.layer(GovernorLayer::new(cfg)))
|
||||
.nest("/api/v1", webhook_routes)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
|
||||
@@ -488,6 +488,18 @@ impl crate::ports::HtmlRenderer for Panic {
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_integrations_page(
|
||||
&self,
|
||||
_: application::ports::IntegrationsPageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_watch_queue_page(
|
||||
&self,
|
||||
_: application::ports::WatchQueuePageData,
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
@@ -571,6 +583,77 @@ impl domain::ports::RemoteWatchlistRepository for Panic {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::WatchEventRepository for Panic {
|
||||
async fn save(&self, _: &domain::models::WatchEvent) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_status(
|
||||
&self,
|
||||
_: &domain::value_objects::WatchEventId,
|
||||
_: domain::models::WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_pending(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::WatchEvent>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
_: &domain::value_objects::WatchEventId,
|
||||
) -> Result<Option<domain::models::WatchEvent>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &str,
|
||||
_: chrono::NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
_: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::WebhookTokenRepository for Panic {
|
||||
async fn save(&self, _: &domain::models::WebhookToken) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_token_hash(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Option<domain::models::WebhookToken>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_by_user(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::WebhookToken>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &domain::value_objects::WebhookTokenId,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn touch_last_used(
|
||||
&self,
|
||||
_: &domain::value_objects::WebhookTokenId,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — only auth_service varies ---
|
||||
|
||||
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
|
||||
@@ -593,6 +676,8 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
import_profile_repository: Arc::clone(&repo) as _,
|
||||
movie_profile_repository: Arc::clone(&repo) as _,
|
||||
watchlist_repository: Arc::clone(&repo) as _,
|
||||
watch_event_repository: Arc::clone(&repo) as _,
|
||||
webhook_token_repository: Arc::clone(&repo) as _,
|
||||
profile_fields_repository: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: Arc::clone(&repo) as _,
|
||||
|
||||
Reference in New Issue
Block a user