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:
@@ -72,6 +72,11 @@ pub enum EventPayload {
|
||||
activity_json: String,
|
||||
signing_actor_id: String,
|
||||
},
|
||||
WatchEventIngested {
|
||||
user_id: String,
|
||||
title: String,
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -90,6 +95,7 @@ impl EventPayload {
|
||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +214,15 @@ impl From<&DomainEvent> for EventPayload {
|
||||
activity_json: activity_json.clone(),
|
||||
signing_actor_id: signing_actor_id.to_string(),
|
||||
},
|
||||
DomainEvent::WatchEventIngested {
|
||||
user_id,
|
||||
title,
|
||||
source,
|
||||
} => EventPayload::WatchEventIngested {
|
||||
user_id: user_id.value().to_string(),
|
||||
title: title.clone(),
|
||||
source: source.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +339,15 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
activity_json,
|
||||
signing_actor_id: parse_uuid(&signing_actor_id, "signing_actor_id")?,
|
||||
}),
|
||||
EventPayload::WatchEventIngested {
|
||||
user_id,
|
||||
title,
|
||||
source,
|
||||
} => Ok(DomainEvent::WatchEventIngested {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
title,
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/adapters/jellyfin/Cargo.toml
Normal file
9
crates/adapters/jellyfin/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "jellyfin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
132
crates/adapters/jellyfin/src/lib.rs
Normal file
132
crates/adapters/jellyfin/src/lib.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct JellyfinParser;
|
||||
|
||||
impl MediaServerParser for JellyfinParser {
|
||||
fn parse_playback_event(
|
||||
&self,
|
||||
body: &[u8],
|
||||
) -> Result<Option<ParsedPlaybackEvent>, DomainError> {
|
||||
let payload: JellyfinPayload = serde_json::from_slice(body)
|
||||
.map_err(|e| DomainError::ValidationError(format!("invalid Jellyfin payload: {e}")))?;
|
||||
|
||||
if payload.notification_type != "PlaybackStop" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let item_type = payload.item_type.as_deref().unwrap_or("");
|
||||
if item_type != "Movie" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !payload.played_to_completion.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let title = match payload.name {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let tmdb_id = payload.provider_tmdb.map(|id| format!("tmdb:{id}"));
|
||||
let imdb_id = payload.provider_imdb;
|
||||
|
||||
Ok(Some(ParsedPlaybackEvent {
|
||||
title,
|
||||
year: payload.year,
|
||||
tmdb_id,
|
||||
imdb_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JellyfinPayload {
|
||||
#[serde(rename = "NotificationType")]
|
||||
notification_type: String,
|
||||
#[serde(rename = "ItemType")]
|
||||
item_type: Option<String>,
|
||||
#[serde(rename = "Name")]
|
||||
name: Option<String>,
|
||||
#[serde(rename = "Year")]
|
||||
year: Option<u16>,
|
||||
#[serde(rename = "PlayedToCompletion")]
|
||||
played_to_completion: Option<bool>,
|
||||
#[serde(rename = "Provider_tmdb")]
|
||||
provider_tmdb: Option<String>,
|
||||
#[serde(rename = "Provider_imdb")]
|
||||
provider_imdb: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_playback_stop() {
|
||||
let body = serde_json::json!({
|
||||
"NotificationType": "PlaybackStop",
|
||||
"ItemType": "Movie",
|
||||
"Name": "Blade Runner",
|
||||
"Year": 1982,
|
||||
"PlayedToCompletion": true,
|
||||
"Provider_tmdb": "78",
|
||||
"Provider_imdb": "tt0083658"
|
||||
});
|
||||
let parser = JellyfinParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
let event = result.expect("should parse");
|
||||
assert_eq!(event.title, "Blade Runner");
|
||||
assert_eq!(event.year, Some(1982));
|
||||
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
||||
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_movie() {
|
||||
let body = serde_json::json!({
|
||||
"NotificationType": "PlaybackStop",
|
||||
"ItemType": "Episode",
|
||||
"Name": "Some Episode",
|
||||
"PlayedToCompletion": true
|
||||
});
|
||||
let parser = JellyfinParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_incomplete_playback() {
|
||||
let body = serde_json::json!({
|
||||
"NotificationType": "PlaybackStop",
|
||||
"ItemType": "Movie",
|
||||
"Name": "Blade Runner",
|
||||
"PlayedToCompletion": false
|
||||
});
|
||||
let parser = JellyfinParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_playback_start() {
|
||||
let body = serde_json::json!({
|
||||
"NotificationType": "PlaybackStart",
|
||||
"ItemType": "Movie",
|
||||
"Name": "Blade Runner",
|
||||
"PlayedToCompletion": false
|
||||
});
|
||||
let parser = JellyfinParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
||||
DomainEvent::BackfillFollower { .. } => "backfill.follower",
|
||||
DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
||||
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
9
crates/adapters/plex/Cargo.toml
Normal file
9
crates/adapters/plex/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "plex"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
172
crates/adapters/plex/src/lib.rs
Normal file
172
crates/adapters/plex/src/lib.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use domain::{errors::DomainError, models::ParsedPlaybackEvent, ports::MediaServerParser};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct PlexParser;
|
||||
|
||||
impl MediaServerParser for PlexParser {
|
||||
/// Plex sends multipart form data with a `payload` JSON field.
|
||||
/// The caller must extract the JSON string from the multipart body
|
||||
/// and pass it here as raw bytes.
|
||||
fn parse_playback_event(
|
||||
&self,
|
||||
body: &[u8],
|
||||
) -> Result<Option<ParsedPlaybackEvent>, DomainError> {
|
||||
let payload: PlexPayload = serde_json::from_slice(body)
|
||||
.map_err(|e| DomainError::ValidationError(format!("invalid Plex payload: {e}")))?;
|
||||
|
||||
if payload.event != "media.scrobble" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata = match payload.metadata {
|
||||
Some(m) => m,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if metadata.media_type != "movie" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if metadata.title.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut tmdb_id = None;
|
||||
let mut imdb_id = None;
|
||||
for guid in &metadata.guids {
|
||||
if let Some(id) = guid.id.strip_prefix("tmdb://") {
|
||||
tmdb_id = Some(format!("tmdb:{id}"));
|
||||
} else if let Some(id) = guid.id.strip_prefix("imdb://") {
|
||||
imdb_id = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(ParsedPlaybackEvent {
|
||||
title: metadata.title,
|
||||
year: metadata.year.map(|y| y as u16),
|
||||
tmdb_id,
|
||||
imdb_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlexPayload {
|
||||
event: String,
|
||||
#[serde(rename = "Metadata")]
|
||||
metadata: Option<PlexMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlexMetadata {
|
||||
#[serde(rename = "type")]
|
||||
media_type: String,
|
||||
title: String,
|
||||
year: Option<i32>,
|
||||
#[serde(rename = "Guid", default)]
|
||||
guids: Vec<PlexGuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlexGuid {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_movie_scrobble() {
|
||||
let body = serde_json::json!({
|
||||
"event": "media.scrobble",
|
||||
"Metadata": {
|
||||
"type": "movie",
|
||||
"title": "Blade Runner",
|
||||
"year": 1982,
|
||||
"Guid": [
|
||||
{"id": "tmdb://78"},
|
||||
{"id": "imdb://tt0083658"}
|
||||
]
|
||||
}
|
||||
});
|
||||
let parser = PlexParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
let event = result.expect("should parse");
|
||||
assert_eq!(event.title, "Blade Runner");
|
||||
assert_eq!(event.year, Some(1982));
|
||||
assert_eq!(event.tmdb_id, Some("tmdb:78".into()));
|
||||
assert_eq!(event.imdb_id, Some("tt0083658".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_tv_episode() {
|
||||
let body = serde_json::json!({
|
||||
"event": "media.scrobble",
|
||||
"Metadata": {
|
||||
"type": "episode",
|
||||
"title": "Pilot",
|
||||
"grandparentTitle": "Breaking Bad",
|
||||
"year": 2008,
|
||||
"Guid": []
|
||||
}
|
||||
});
|
||||
let parser = PlexParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_play_event() {
|
||||
let body = serde_json::json!({
|
||||
"event": "media.play",
|
||||
"Metadata": {
|
||||
"type": "movie",
|
||||
"title": "Blade Runner",
|
||||
"year": 1982,
|
||||
"Guid": []
|
||||
}
|
||||
});
|
||||
let parser = PlexParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_no_guids() {
|
||||
let body = serde_json::json!({
|
||||
"event": "media.scrobble",
|
||||
"Metadata": {
|
||||
"type": "movie",
|
||||
"title": "Some Indie Film",
|
||||
"year": 2023
|
||||
}
|
||||
});
|
||||
let parser = PlexParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
let event = result.expect("should parse");
|
||||
assert_eq!(event.title, "Some Indie Film");
|
||||
assert!(event.tmdb_id.is_none());
|
||||
assert!(event.imdb_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_metadata() {
|
||||
let body = serde_json::json!({
|
||||
"event": "media.scrobble"
|
||||
});
|
||||
let parser = PlexParser;
|
||||
let result = parser
|
||||
.parse_playback_event(serde_json::to_vec(&body).unwrap().as_slice())
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
28
crates/adapters/postgres/migrations/0023_watch_events.sql
Normal file
28
crates/adapters/postgres/migrations/0023_watch_events.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS webhook_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_tokens_hash ON webhook_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_tokens_user ON webhook_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watch_events (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
movie_id TEXT REFERENCES movies(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
external_metadata_id TEXT,
|
||||
source TEXT NOT NULL,
|
||||
watched_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_events_user_status ON watch_events(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_events_dedup ON watch_events(user_id, external_metadata_id, created_at);
|
||||
@@ -21,6 +21,7 @@ mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod users;
|
||||
mod watch_event;
|
||||
mod watchlist;
|
||||
|
||||
use models::{
|
||||
@@ -36,6 +37,7 @@ pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||
pub use profile::PostgresMovieProfileRepository;
|
||||
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||
pub use users::PostgresUserRepository;
|
||||
pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository};
|
||||
pub use watchlist::PostgresWatchlistRepository;
|
||||
|
||||
fn format_year_month(ym: &str) -> String {
|
||||
|
||||
317
crates/adapters/postgres/src/watch_event.rs
Normal file
317
crates/adapters/postgres/src/watch_event.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
||||
ports::{WatchEventRepository, WebhookTokenRepository},
|
||||
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{parse_datetime, parse_uuid};
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
// ── WatchEventRepository ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct PostgresWatchEventRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresWatchEventRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
let id = event.id().value().to_string();
|
||||
let user_id = event.user_id().value().to_string();
|
||||
let movie_id = event.movie_id().map(|m| m.value().to_string());
|
||||
let source = event.source().to_string();
|
||||
let status = event.status().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO watch_events \
|
||||
(id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(event.title())
|
||||
.bind(event.year().map(|y| y as i32))
|
||||
.bind(event.external_metadata_id())
|
||||
.bind(&source)
|
||||
.bind(event.watched_at())
|
||||
.bind(&status)
|
||||
.bind(event.created_at())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WatchEventId,
|
||||
status: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let status_str = status.to_string();
|
||||
|
||||
sqlx::query("UPDATE watch_events SET status = $1 WHERE id = $2")
|
||||
.bind(&status_str)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, \
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
|
||||
status, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM watch_events \
|
||||
WHERE user_id = $1 AND status = 'pending' \
|
||||
ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, \
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
|
||||
status, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM watch_events WHERE id = $1",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.as_ref().map(row_to_watch_event).transpose()
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
external_id: &str,
|
||||
after: chrono::NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM watch_events \
|
||||
WHERE user_id = $1 AND external_metadata_id = $2 AND created_at > $3",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(external_id)
|
||||
.bind(after)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
|
||||
.bind(before)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result<WatchEvent, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
||||
let movie_id_str: Option<String> = row.try_get("movie_id").map_err(map_err)?;
|
||||
let title: String = row.try_get("title").map_err(map_err)?;
|
||||
let year: Option<i32> = row.try_get("year").map_err(map_err)?;
|
||||
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?;
|
||||
let source_str: String = row.try_get("source").map_err(map_err)?;
|
||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
|
||||
let source: WatchEventSource = source_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
let status: WatchEventStatus = status_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let movie_id = movie_id_str
|
||||
.as_deref()
|
||||
.map(parse_uuid)
|
||||
.transpose()?
|
||||
.map(MovieId::from_uuid);
|
||||
|
||||
Ok(WatchEvent::from_persistence(PersistedWatchEvent {
|
||||
id: WatchEventId::from_uuid(parse_uuid(&id_str)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id_str)?),
|
||||
movie_id,
|
||||
title,
|
||||
year: year.map(|y| y as u16),
|
||||
external_metadata_id: ext_id,
|
||||
source,
|
||||
watched_at: parse_datetime(&watched_at_str)?,
|
||||
status,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── WebhookTokenRepository ────────────────────────────────────────────────────
|
||||
|
||||
pub struct PostgresWebhookTokenRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresWebhookTokenRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> {
|
||||
let id = token.id().value().to_string();
|
||||
let user_id = token.user_id().value().to_string();
|
||||
let provider = token.provider().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO webhook_tokens \
|
||||
(id, user_id, token_hash, provider, label, created_at, last_used_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(token.token_hash())
|
||||
.bind(&provider)
|
||||
.bind(token.label())
|
||||
.bind(token.created_at())
|
||||
.bind(token.last_used_at())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \
|
||||
to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \
|
||||
FROM webhook_tokens WHERE token_hash = $1",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.as_ref().map(row_to_webhook_token).transpose()
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \
|
||||
to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \
|
||||
FROM webhook_tokens WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.iter().map(row_to_webhook_token).collect()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = $1 AND user_id = $2")
|
||||
.bind(&id_str)
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
|
||||
sqlx::query("UPDATE webhook_tokens SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
||||
let token_hash: String = row.try_get("token_hash").map_err(map_err)?;
|
||||
let provider_str: String = row.try_get("provider").map_err(map_err)?;
|
||||
let label: Option<String> = row.try_get("label").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?;
|
||||
|
||||
let provider: WatchEventSource = provider_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let last_used = last_used_str.as_deref().map(parse_datetime).transpose()?;
|
||||
|
||||
Ok(WebhookToken::from_persistence(
|
||||
WebhookTokenId::from_uuid(parse_uuid(&id_str)?),
|
||||
UserId::from_uuid(parse_uuid(&user_id_str)?),
|
||||
token_hash,
|
||||
provider,
|
||||
label,
|
||||
parse_datetime(&created_at_str)?,
|
||||
last_used,
|
||||
))
|
||||
}
|
||||
@@ -72,7 +72,10 @@ fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> Remote
|
||||
.and_then(|s| {
|
||||
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|ndt| ndt.and_utc())
|
||||
.or_else(|_| chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&chrono::Utc)))
|
||||
.or_else(|_| {
|
||||
chrono::DateTime::parse_from_rfc3339(&s)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
})
|
||||
.ok()
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use super::{create_search_adapter, SqliteSearchAdapter};
|
||||
use super::create_search_adapter;
|
||||
use domain::{
|
||||
models::{
|
||||
collections::PageParams, EntityType, ExternalPersonId, IndexableDocument, Movie, Person,
|
||||
PersonId, SearchFilters, SearchQuery,
|
||||
collections::PageParams, EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery,
|
||||
},
|
||||
ports::{SearchCommand, SearchPort},
|
||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
28
crates/adapters/sqlite/migrations/0023_watch_events.sql
Normal file
28
crates/adapters/sqlite/migrations/0023_watch_events.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS webhook_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_tokens_hash ON webhook_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_tokens_user ON webhook_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watch_events (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
movie_id TEXT REFERENCES movies(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
external_metadata_id TEXT,
|
||||
source TEXT NOT NULL,
|
||||
watched_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_events_user_status ON watch_events(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_events_dedup ON watch_events(user_id, external_metadata_id, created_at);
|
||||
@@ -22,6 +22,7 @@ mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod users;
|
||||
mod watch_event;
|
||||
mod watchlist;
|
||||
|
||||
use models::{
|
||||
@@ -37,6 +38,7 @@ pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||
pub use profile::SqliteMovieProfileRepository;
|
||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||
pub use users::SqliteUserRepository;
|
||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
||||
pub use watchlist::SqliteWatchlistRepository;
|
||||
|
||||
pub fn create_profile_fields_repo(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::super::persons::SqlitePersonAdapter;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{ExternalPersonId, Person, PersonId},
|
||||
ports::{PersonCommand, PersonQuery},
|
||||
};
|
||||
|
||||
327
crates/adapters/sqlite/src/watch_event.rs
Normal file
327
crates/adapters/sqlite/src/watch_event.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
||||
ports::{WatchEventRepository, WebhookTokenRepository},
|
||||
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::models::datetime_to_str;
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
s.parse()
|
||||
.map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}")))
|
||||
}
|
||||
|
||||
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}")))
|
||||
}
|
||||
|
||||
// ── WatchEventRepository ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct SqliteWatchEventRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWatchEventRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventRepository for SqliteWatchEventRepository {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
let id = event.id().value().to_string();
|
||||
let user_id = event.user_id().value().to_string();
|
||||
let movie_id = event.movie_id().map(|m| m.value().to_string());
|
||||
let source = event.source().to_string();
|
||||
let watched_at = datetime_to_str(event.watched_at());
|
||||
let status = event.status().to_string();
|
||||
let created_at = datetime_to_str(event.created_at());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO watch_events \
|
||||
(id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(event.title())
|
||||
.bind(event.year().map(|y| y as i64))
|
||||
.bind(event.external_metadata_id())
|
||||
.bind(&source)
|
||||
.bind(&watched_at)
|
||||
.bind(&status)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WatchEventId,
|
||||
status: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let status_str = status.to_string();
|
||||
|
||||
sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?")
|
||||
.bind(&status_str)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, watched_at, status, created_at \
|
||||
FROM watch_events \
|
||||
WHERE user_id = ? AND status = 'pending' \
|
||||
ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, watched_at, status, created_at \
|
||||
FROM watch_events WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.as_ref().map(row_to_watch_event).transpose()
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
external_id: &str,
|
||||
after: chrono::NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let after_str = datetime_to_str(&after);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watch_events \
|
||||
WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(external_id)
|
||||
.bind(&after_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let before_str = datetime_to_str(&before);
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
|
||||
.bind(&before_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result<WatchEvent, DomainError> {
|
||||
let id_str: &str = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: &str = row.try_get("user_id").map_err(map_err)?;
|
||||
let movie_id_str: Option<&str> = row.try_get("movie_id").map_err(map_err)?;
|
||||
let title: String = row.try_get("title").map_err(map_err)?;
|
||||
let year: Option<i64> = row.try_get("year").map_err(map_err)?;
|
||||
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?;
|
||||
let source_str: String = row.try_get("source").map_err(map_err)?;
|
||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
|
||||
let source: WatchEventSource = source_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
let status: WatchEventStatus = status_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let movie_id = movie_id_str
|
||||
.map(parse_uuid)
|
||||
.transpose()?
|
||||
.map(MovieId::from_uuid);
|
||||
|
||||
Ok(WatchEvent::from_persistence(PersistedWatchEvent {
|
||||
id: WatchEventId::from_uuid(parse_uuid(id_str)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(user_id_str)?),
|
||||
movie_id,
|
||||
title,
|
||||
year: year.map(|y| y as u16),
|
||||
external_metadata_id: ext_id,
|
||||
source,
|
||||
watched_at: parse_datetime(&watched_at_str)?,
|
||||
status,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── WebhookTokenRepository ────────────────────────────────────────────────────
|
||||
|
||||
pub struct SqliteWebhookTokenRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWebhookTokenRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebhookTokenRepository for SqliteWebhookTokenRepository {
|
||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> {
|
||||
let id = token.id().value().to_string();
|
||||
let user_id = token.user_id().value().to_string();
|
||||
let provider = token.provider().to_string();
|
||||
let created_at = datetime_to_str(token.created_at());
|
||||
let last_used = token.last_used_at().map(datetime_to_str);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO webhook_tokens \
|
||||
(id, user_id, token_hash, provider, label, created_at, last_used_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(token.token_hash())
|
||||
.bind(&provider)
|
||||
.bind(token.label())
|
||||
.bind(&created_at)
|
||||
.bind(&last_used)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \
|
||||
FROM webhook_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.as_ref().map(row_to_webhook_token).transpose()
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \
|
||||
FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.iter().map(row_to_webhook_token).collect()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?")
|
||||
.bind(&id_str)
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let now = datetime_to_str(&chrono::Utc::now().naive_utc());
|
||||
|
||||
sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result<WebhookToken, DomainError> {
|
||||
let id_str: &str = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: &str = row.try_get("user_id").map_err(map_err)?;
|
||||
let token_hash: String = row.try_get("token_hash").map_err(map_err)?;
|
||||
let provider_str: String = row.try_get("provider").map_err(map_err)?;
|
||||
let label: Option<String> = row.try_get("label").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?;
|
||||
|
||||
let provider: WatchEventSource = provider_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?;
|
||||
|
||||
Ok(WebhookToken::from_persistence(
|
||||
WebhookTokenId::from_uuid(parse_uuid(id_str)?),
|
||||
UserId::from_uuid(parse_uuid(user_id_str)?),
|
||||
token_hash,
|
||||
provider,
|
||||
label,
|
||||
parse_datetime(&created_at_str)?,
|
||||
last_used,
|
||||
))
|
||||
}
|
||||
@@ -2,8 +2,9 @@ use application::ports::{
|
||||
ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry,
|
||||
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData,
|
||||
ImportRowStatus, ImportUploadPageData, IntegrationsPageData, LoginPageData,
|
||||
MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData,
|
||||
RegisterPageData, UsersPageData, WatchQueuePageData, WatchlistPageData, WebhookTokenView,
|
||||
};
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
@@ -366,6 +367,23 @@ struct ProfileSettingsTemplate<'a> {
|
||||
saved: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "integrations.html")]
|
||||
struct IntegrationsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
tokens: &'a [WebhookTokenView],
|
||||
webhook_base_url: &'a str,
|
||||
new_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watch_queue.html")]
|
||||
struct WatchQueueTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
entries: &'a [application::ports::WatchQueueDisplayEntry],
|
||||
error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_upload.html")]
|
||||
struct ImportUploadTemplate<'a> {
|
||||
@@ -750,4 +768,25 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_integrations_page(&self, data: IntegrationsPageData) -> Result<String, String> {
|
||||
IntegrationsTemplate {
|
||||
ctx: &data.ctx,
|
||||
tokens: &data.tokens,
|
||||
webhook_base_url: &data.webhook_base_url,
|
||||
new_token: data.new_token.as_deref(),
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result<String, String> {
|
||||
WatchQueueTemplate {
|
||||
ctx: &data.ctx,
|
||||
entries: &data.entries,
|
||||
error: data.error.as_deref(),
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<a href="/users/{{ uid }}">Profile</a>
|
||||
<a href="/reviews/new">Add Review</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/watch-queue">Queue</a>
|
||||
<a href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
|
||||
92
crates/adapters/template-askama/templates/integrations.html
Normal file
92
crates/adapters/template-askama/templates/integrations.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Integrations</h1>
|
||||
<p style="font-size:.85em;opacity:.7;margin-bottom:1rem">
|
||||
<a href="/settings/profile">Profile Settings</a>
|
||||
</p>
|
||||
|
||||
<section style="margin-bottom:2rem">
|
||||
<h2 style="font-size:1.1em">Jellyfin / Plex Webhook</h2>
|
||||
<p style="opacity:.7;font-size:.9em">
|
||||
Automatically log movies you finish watching. Configure your media server's
|
||||
webhook plugin to POST to the URL below.
|
||||
</p>
|
||||
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:8px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Jellyfin Webhook URL</label>
|
||||
<code style="word-break:break-all">{{ webhook_base_url }}/api/v1/webhooks/jellyfin</code>
|
||||
</div>
|
||||
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:8px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Plex Webhook URL (append token as query param)</label>
|
||||
<code style="word-break:break-all">{{ webhook_base_url }}/api/v1/webhooks/plex?token=YOUR_TOKEN</code>
|
||||
</div>
|
||||
|
||||
<details style="margin:1rem 0;font-size:.85em;opacity:.8">
|
||||
<summary style="cursor:pointer">Jellyfin setup</summary>
|
||||
<ol style="margin-top:0.5rem;padding-left:1.2rem">
|
||||
<li>Install the <strong>Webhook</strong> plugin (Dashboard → Plugins → Catalog)</li>
|
||||
<li>Add Generic Destination with the Jellyfin URL above</li>
|
||||
<li>Add header: <code>Authorization</code> = <code>Bearer YOUR_TOKEN</code></li>
|
||||
<li>Check <strong>Send All Properties</strong></li>
|
||||
<li>Notification Type: <strong>Playback Stop</strong> only</li>
|
||||
<li>Item Type: <strong>Movies</strong> only</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<details style="margin:1rem 0;font-size:.85em;opacity:.8">
|
||||
<summary style="cursor:pointer">Plex setup (requires Plex Pass)</summary>
|
||||
<ol style="margin-top:0.5rem;padding-left:1.2rem">
|
||||
<li>Go to Settings → Webhooks in your Plex server</li>
|
||||
<li>Add the Plex URL above, replacing <code>YOUR_TOKEN</code> with your generated token</li>
|
||||
<li>Plex automatically sends scrobble events when a movie is watched to 90%+</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
{% if let Some(token) = new_token %}
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:rgba(229,192,52,.1);border:1px solid rgba(229,192,52,.3);border-radius:8px">
|
||||
<strong>New token (copy now — shown only once):</strong><br>
|
||||
<code style="word-break:break-all;font-size:1.1em">{{ token }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/integrations/generate" style="display:flex;gap:0.5rem;align-items:flex-end;margin:1rem 0">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<input type="hidden" name="provider" value="jellyfin">
|
||||
<div style="flex:1;min-width:150px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Label (optional)</label>
|
||||
<input type="text" name="label" placeholder="e.g. Living Room Server" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Generate Token</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if !tokens.is_empty() %}
|
||||
<section>
|
||||
<h2 style="font-size:1.1em">Active Tokens</h2>
|
||||
<div class="diary">
|
||||
{% for t in tokens %}
|
||||
<article class="entry" style="padding:0.75rem 1rem">
|
||||
<div class="entry-body">
|
||||
<div class="entry-title" style="font-size:0.95em">
|
||||
{{ t.provider }}{% if let Some(l) = &t.label %} — {{ l }}{% endif %}
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.3rem">
|
||||
<span class="feed-time">Created {{ t.created_at }}</span>
|
||||
{% if let Some(used) = &t.last_used_at %}
|
||||
<span class="feed-time" style="margin-left:0.5rem">Last used {{ used }}</span>
|
||||
{% else %}
|
||||
<span class="feed-time" style="margin-left:0.5rem;opacity:.5">Never used</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/settings/integrations/{{ t.id }}/revoke" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -70,6 +70,7 @@
|
||||
<h3>Account</h3>
|
||||
<a href="/users/{{ profile_user_id }}/watchlist">Watchlist</a>
|
||||
<a href="/settings/profile">Profile settings</a>
|
||||
<a href="/settings/integrations">Integrations</a>
|
||||
<a href="/social/blocked">Blocked users</a>
|
||||
{% if ctx.is_admin %}
|
||||
<a href="/admin/blocked-domains">Admin — blocked domains</a>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Profile Settings</h1>
|
||||
<p style="font-size:.85em;opacity:.7;margin-bottom:1rem">
|
||||
<a href="/settings/integrations">Integrations (Jellyfin/Plex)</a>
|
||||
</p>
|
||||
{% if saved %}
|
||||
<p class="success">Saved.</p>
|
||||
{% endif %}
|
||||
|
||||
68
crates/adapters/template-askama/templates/watch_queue.html
Normal file
68
crates/adapters/template-askama/templates/watch_queue.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
<div class="entry-title" style="margin-bottom:1rem">Watch Queue</div>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<p class="form-error">{{ err }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if entries.is_empty() %}
|
||||
<p class="empty">
|
||||
No pending watches.
|
||||
<a href="/settings/integrations">Connect Jellyfin</a> to start auto-logging.
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="opacity:.7;font-size:.85em;margin-bottom:1rem">
|
||||
Movies you watched via Jellyfin. Rate and confirm to add to your diary, or dismiss.
|
||||
</p>
|
||||
<div class="diary">
|
||||
{% for entry in entries %}
|
||||
<article class="entry">
|
||||
<div class="entry-body">
|
||||
<div class="entry-title">
|
||||
{% if let Some(url) = &entry.movie_url %}
|
||||
<a href="{{ url }}" class="movie-title-link">{{ entry.title }}</a>
|
||||
{% else %}
|
||||
{{ entry.title }}
|
||||
{% endif %}
|
||||
{% if let Some(y) = entry.year %}
|
||||
<span class="year">({{ y }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.3rem">
|
||||
<span class="feed-time">Watched {{ entry.watched_at }}</span>
|
||||
<span class="feed-time" style="margin-left:0.5rem;opacity:.6">via {{ entry.source }}</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/watch-queue/{{ entry.id }}/confirm" style="margin-top:0.6rem;display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<div>
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Rating</label>
|
||||
<select name="rating" style="width:auto">
|
||||
<option value="0">—</option>
|
||||
<option value="1">1★</option>
|
||||
<option value="2">2★</option>
|
||||
<option value="3">3★</option>
|
||||
<option value="4">4★</option>
|
||||
<option value="5">5★</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:120px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Comment</label>
|
||||
<input type="text" name="comment" placeholder="Optional" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Confirm</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/watch-queue/{{ entry.id }}/dismiss" style="margin-top:0.3rem">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3);font-size:.8em">Dismiss</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user