Compare commits

..

9 Commits

Author SHA1 Message Date
722b09e400 docs: update Dockerfile, README, env.example for image-converter 2026-05-12 15:12:26 +02:00
cea414fe60 refactor: split ImageRefPort into ImageRefCommand and ImageRefQuery 2026-05-12 15:10:49 +02:00
696e3e170c feat: async image conversion service (avif/webp) with backfill 2026-05-12 15:05:28 +02:00
4269eca582 feat: implement movie listing functionality with pagination and search 2026-05-12 13:57:55 +02:00
fb81aa10c1 feat: enable TMDb enrichment with conditional event handling 2026-05-12 13:29:43 +02:00
78c2d9b1d3 feat: implement database connection and event bus handling 2026-05-12 13:28:13 +02:00
38d13fbff1 feat: implement TMDb enrichment for movie profiles
- Add SqliteMovieProfileRepository for managing movie profiles in SQLite.
- Create TmdbEnrichmentClient to fetch movie details from TMDb API.
- Implement enrichment event handling with EnrichmentHandler.
- Introduce periodic jobs for cleaning up expired import sessions and checking for stale movie profiles.
- Update application context to include movie profile repository.
- Add API endpoint to retrieve movie profiles.
- Extend domain models with new structures for movie enrichment (Genre, Keyword, CastMember, CrewMember, MovieProfile).
- Modify event system to include MovieEnrichmentRequested event.
- Enhance tests to cover new functionality and ensure stability.
2026-05-12 13:23:41 +02:00
c696a3b780 feat: add endpoint to retrieve user by username 2026-05-12 12:02:30 +02:00
99ce81efe5 refactor: deps cleanup, split openapi, extract api-types crate 2026-05-12 11:54:00 +02:00
88 changed files with 3580 additions and 986 deletions

View File

@@ -57,4 +57,9 @@ EVENT_BUS_BACKEND=db
# NATS_STREAM_NAME=MOVIES_DIARY_EVENTS # NATS_STREAM_NAME=MOVIES_DIARY_EVENTS
# NATS_CONSUMER_NAME=worker # NATS_CONSUMER_NAME=worker
# Image conversion (optional — converts stored images to save disk space)
# Disable by default; enable in the worker by setting ENABLED=true.
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif | webp
RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=info RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=info

559
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,18 +19,20 @@ members = [
"crates/adapters/export", "crates/adapters/export",
"crates/adapters/event-payload", "crates/adapters/event-payload",
"crates/adapters/nats", "crates/adapters/nats",
"crates/api-types",
"crates/application", "crates/application",
"crates/adapters/tmdb-enrichment",
"crates/adapters/image-converter",
"crates/domain", "crates/domain",
"crates/presentation", "crates/presentation",
"crates/tui", "crates/tui",
"crates/doc",
"crates/worker", "crates/worker",
"crates/adapters/importer", "crates/adapters/importer",
] ]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
futures = "0.3" futures = "0.3"
dotenvy = "0.15" dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -53,7 +55,9 @@ object_store = { version = "0.11", features = ["aws"] }
axum = { version = "0.8.8", features = ["macros", "multipart"] } axum = { version = "0.8.8", features = ["macros", "multipart"] }
csv = "1" csv = "1"
api-types = { path = "crates/api-types" }
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
application = { path = "crates/application" } application = { path = "crates/application" }
presentation = { path = "crates/presentation" } presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
@@ -71,9 +75,9 @@ postgres-federation = { path = "crates/adapters/postgres-federation" }
template-askama = { path = "crates/adapters/template-askama" } template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" } activitypub = { path = "crates/adapters/activitypub" }
activitypub-base = { path = "crates/adapters/activitypub-base" } activitypub-base = { path = "crates/adapters/activitypub-base" }
doc = { path = "crates/doc" }
event-payload = { path = "crates/adapters/event-payload" } event-payload = { path = "crates/adapters/event-payload" }
nats = { path = "crates/adapters/nats" } nats = { path = "crates/adapters/nats" }
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
importer = { path = "crates/adapters/importer" } importer = { path = "crates/adapters/importer" }
image-converter = { path = "crates/adapters/image-converter" }

View File

@@ -27,17 +27,25 @@ COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Car
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/doc/Cargo.toml crates/doc/Cargo.toml
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps # Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs' xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
# libwebp-dev: required at build time by the `webp` crate (C bindings)
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebp-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN cargo fetch RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes) # Now copy real sources (invalidates cache only on source changes)
@@ -59,6 +67,7 @@ FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
wget \ wget \
libwebp7 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -7,6 +7,7 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 rating - Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 rating
- Immutable append-only viewing ledger (tracks re-watches) - Immutable append-only viewing ledger (tracks re-watches)
- Background poster fetching and storage (local filesystem or S3-compatible) - Background poster fetching and storage (local filesystem or S3-compatible)
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
- RSS/Atom feed for public subscription (global and per-user) - RSS/Atom feed for public subscription (global and per-user)
- JWT authentication via cookie (HTML) or Bearer token (REST API) - JWT authentication via cookie (HTML) or Bearer token (REST API)
- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable) - ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable)
@@ -24,9 +25,10 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
Hexagonal (Ports & Adapters) with Domain-Driven Design: Hexagonal (Ports & Adapters) with Domain-Driven Design:
``` ```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
domain — pure types and trait definitions, no external deps domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration application — use cases / business logic orchestration
presentation — Axum HTTP router, composition root for the HTTP process presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation) worker — standalone worker binary (event consumer, poster sync, federation)
adapters/ adapters/
auth — JWT issuance and validation (Argon2 passwords) auth — JWT issuance and validation (Argon2 passwords)
@@ -36,6 +38,8 @@ adapters/
poster-fetcher — downloads poster images poster-fetcher — downloads poster images
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
tmdb-enrichment — event handler: fetches full movie profile (cast, crew, genres, keywords, box office) from TMDb on MovieEnrichmentRequested; resolves IMDb IDs automatically
template-askama — Askama HTML rendering template-askama — Askama HTML rendering
rss — RSS/Atom feed generation rss — RSS/Atom feed generation
export — CSV and JSON diary serialization export — CSV and JSON diary serialization
@@ -44,13 +48,12 @@ adapters/
sqlite-event-queue — durable polling event queue backed by SQLite sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (tests only) event-publisher — in-memory event channel (used in tests)
activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor) activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor)
activitypub-base — core ActivityPub protocol types and service activitypub-base — core ActivityPub protocol types and service
sqlite-federation — SQLite-backed federation repository sqlite-federation — SQLite-backed federation repository
postgres-federation — PostgreSQL-backed federation repository postgres-federation — PostgreSQL-backed federation repository
docOpenAPI spec assembly and Swagger UI / Scalar serving tuiterminal UI client (ratatui); shares api-types with presentation for typed API access
tui — terminal UI client (ratatui)
``` ```
## Prerequisites ## Prerequisites
@@ -74,6 +77,9 @@ JWT_SECRET=change-me
# OMDb metadata # OMDb metadata
OMDB_API_KEY=your-key OMDB_API_KEY=your-key
# TMDb metadata + enrichment (optional — enables full cast/crew/genre data)
# TMDB_API_KEY=your-key
# Public base URL (used for ActivityPub actor URLs and canonical links) # Public base URL (used for ActivityPub actor URLs and canonical links)
BASE_URL=https://yourdomain.example.com BASE_URL=https://yourdomain.example.com
@@ -91,6 +97,10 @@ IMAGE_STORAGE_PATH=./images
# MINIO_ACCESS_KEY_ID=minioadmin # MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin # MINIO_SECRET_ACCESS_KEY=minioadmin
# Image conversion (optional — converts stored images to AVIF or WebP to save space)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif or webp
# Optional # Optional
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 PORT=3000

View File

@@ -7,10 +7,8 @@ edition = "2024"
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }

View File

@@ -7,7 +7,6 @@ edition = "2024"
activitypub-base = { workspace = true } activitypub-base = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -8,5 +8,4 @@ domain = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@@ -39,6 +39,13 @@ pub enum EventPayload {
review_id: String, review_id: String,
user_id: String, user_id: String,
}, },
MovieEnrichmentRequested {
movie_id: String,
external_metadata_id: String,
},
ImageStored {
key: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -50,6 +57,8 @@ impl EventPayload {
EventPayload::MovieDeleted { .. } => "MovieDeleted", EventPayload::MovieDeleted { .. } => "MovieDeleted",
EventPayload::UserUpdated { .. } => "UserUpdated", EventPayload::UserUpdated { .. } => "UserUpdated",
EventPayload::ReviewDeleted { .. } => "ReviewDeleted", EventPayload::ReviewDeleted { .. } => "ReviewDeleted",
EventPayload::MovieEnrichmentRequested { .. } => "MovieEnrichmentRequested",
EventPayload::ImageStored { .. } => "ImageStored",
} }
} }
} }
@@ -103,6 +112,13 @@ impl From<&DomainEvent> for EventPayload {
review_id: review_id.value().to_string(), review_id: review_id.value().to_string(),
user_id: user_id.value().to_string(), user_id: user_id.value().to_string(),
}, },
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
EventPayload::MovieEnrichmentRequested {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.clone(),
}
}
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
} }
} }
} }
@@ -154,6 +170,15 @@ impl TryFrom<EventPayload> for DomainEvent {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}) })
} }
EventPayload::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id,
})
}
EventPayload::ImageStored { key } => {
Ok(DomainEvent::ImageStored { key })
}
} }
} }
} }
@@ -230,4 +255,20 @@ mod tests {
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
} }
#[test]
fn round_trip_image_stored() {
let event = DomainEvent::ImageStored { key: "avatars/abc123".into() };
let payload = EventPayload::from(&event);
let json = serde_json::to_string(&payload).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap();
let recovered = DomainEvent::try_from(back).unwrap();
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
}
#[test]
fn image_stored_event_type() {
let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() });
assert_eq!(payload.event_type(), "ImageStored");
}
} }

View File

@@ -7,5 +7,4 @@ edition = "2024"
domain = { workspace = true } domain = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
futures = { workspace = true } futures = { workspace = true }

View File

@@ -0,0 +1,19 @@
[package]
name = "image-converter"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
ravif = { version = "0.11", default-features = false }
webp = "0.3"
[dev-dependencies]
image-storage = { workspace = true }
object_store = "0.11"
uuid = { workspace = true }

View File

@@ -0,0 +1,140 @@
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, ImageRefQuery, PeriodicJob},
};
pub struct ConversionBackfillJob {
image_ref: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>,
}
impl ConversionBackfillJob {
pub fn new(
image_ref: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self { image_ref, event_publisher }
}
}
#[async_trait]
impl PeriodicJob for ConversionBackfillJob {
fn interval(&self) -> Duration {
Duration::from_secs(60 * 60 * 24) // 24h
}
async fn run(&self) -> Result<(), DomainError> {
let keys = self.image_ref.list_keys().await?;
for key in keys {
if key.ends_with(".avif") || key.ends_with(".webp") {
continue;
}
if let Err(e) = self.event_publisher
.publish(&DomainEvent::ImageStored { key: key.clone() })
.await
{
tracing::warn!("backfill: failed to emit ImageStored for {key}: {e}");
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct MockImageRef {
keys: Vec<String>,
}
#[async_trait::async_trait]
impl ImageRefQuery for MockImageRef {
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
Ok(self.keys.clone())
}
}
struct MockPublisher {
emitted: Mutex<Vec<String>>,
}
impl MockPublisher {
fn new() -> Arc<Self> {
Arc::new(Self { emitted: Mutex::new(vec![]) })
}
fn emitted(&self) -> Vec<String> {
self.emitted.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl EventPublisher for MockPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
if let DomainEvent::ImageStored { key } = event {
self.emitted.lock().unwrap().push(key.clone());
}
Ok(())
}
}
#[tokio::test]
async fn emits_image_stored_for_unconverted_keys() {
let image_ref = Arc::new(MockImageRef {
keys: vec!["avatars/u1".into(), "posters/m1".into()],
});
let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new(
image_ref,
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap();
let mut emitted = publisher.emitted();
emitted.sort();
assert_eq!(emitted, vec!["avatars/u1", "posters/m1"]);
}
#[tokio::test]
async fn skips_already_converted_keys() {
let image_ref = Arc::new(MockImageRef {
keys: vec![
"avatars/u1.avif".into(),
"posters/m1".into(),
"avatars/u2.webp".into(),
],
});
let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new(
image_ref,
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap();
assert_eq!(publisher.emitted(), vec!["posters/m1"]);
}
#[tokio::test]
async fn empty_keys_emits_nothing() {
let image_ref = Arc::new(MockImageRef { keys: vec![] });
let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new(
image_ref,
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap();
assert!(publisher.emitted().is_empty());
}
}

View File

@@ -0,0 +1,90 @@
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Format {
Avif,
Webp,
}
impl Format {
pub fn extension(self) -> &'static str {
match self {
Format::Avif => ".avif",
Format::Webp => ".webp",
}
}
}
pub struct ConversionConfig {
pub format: Format,
}
impl ConversionConfig {
pub fn from_env() -> anyhow::Result<Option<Self>> {
Self::from_vars(
std::env::var("IMAGE_CONVERSION_ENABLED").ok().as_deref(),
std::env::var("IMAGE_CONVERSION_FORMAT").ok().as_deref(),
)
}
fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result<Option<Self>> {
if enabled != Some("true") {
return Ok(None);
}
let format_str = format.ok_or_else(|| {
anyhow::anyhow!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true")
})?;
let format = match format_str {
"avif" => Format::Avif,
"webp" => Format::Webp,
other => anyhow::bail!(
"Unknown IMAGE_CONVERSION_FORMAT: {other:?}. Valid values: avif, webp"
),
};
Ok(Some(Self { format }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_by_default() {
assert!(ConversionConfig::from_vars(None, None).unwrap().is_none());
assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none());
}
#[test]
fn enabled_avif() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap();
assert_eq!(cfg.format, Format::Avif);
}
#[test]
fn enabled_webp() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap();
assert_eq!(cfg.format, Format::Webp);
}
#[test]
fn unknown_format_is_error() {
assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err());
}
#[test]
fn missing_format_when_enabled_is_error() {
assert!(ConversionConfig::from_vars(Some("true"), None).is_err());
}
#[test]
fn avif_extension() {
assert_eq!(Format::Avif.extension(), ".avif");
}
#[test]
fn webp_extension() {
assert_eq!(Format::Webp.extension(), ".webp");
}
}

View File

@@ -0,0 +1,221 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventHandler, ImageRefCommand, ImageStorage},
};
use crate::Format;
pub struct ImageConversionHandler {
storage: Arc<dyn ImageStorage>,
image_ref: Arc<dyn ImageRefCommand>,
format: Format,
}
impl ImageConversionHandler {
pub fn new(
storage: Arc<dyn ImageStorage>,
image_ref: Arc<dyn ImageRefCommand>,
format: Format,
) -> Self {
Self { storage, image_ref, format }
}
}
#[async_trait]
impl EventHandler for ImageConversionHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let key = match event {
DomainEvent::ImageStored { key } => key.clone(),
_ => return Ok(()),
};
if key.ends_with(".avif") || key.ends_with(".webp") {
return Ok(());
}
let bytes = self.storage.get(&key).await?;
let format = self.format;
let converted = tokio::task::spawn_blocking(move || convert(bytes, format))
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.map_err(|e| DomainError::InfrastructureError(e))?;
let ext = format.extension();
let new_key = format!("{key}{ext}");
self.storage.store(&new_key, &converted).await?;
if let Err(e) = self.image_ref.swap(&key, &new_key).await {
tracing::error!("swap failed for {key} → {new_key}: {e}");
return Err(e);
}
if let Err(e) = self.storage.delete(&key).await {
tracing::warn!("failed to delete old image key {key}: {e}");
}
tracing::info!("converted {key} → {new_key}");
Ok(())
}
}
fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
match format {
Format::Avif => {
let rgba = img.to_rgba8();
let width = rgba.width() as usize;
let height = rgba.height() as usize;
let pixels: Vec<ravif::RGBA8> = rgba
.pixels()
.map(|p| ravif::RGBA8 { r: p.0[0], g: p.0[1], b: p.0[2], a: p.0[3] })
.collect();
let result = ravif::Encoder::new()
.with_quality(80.0)
.with_speed(6)
.encode_rgba(ravif::Img::new(&pixels, width, height))
.map_err(|e| e.to_string())?;
Ok(result.avif_file.to_vec())
}
Format::Webp => {
let rgba = img.to_rgba8();
let (width, height) = (rgba.width(), rgba.height());
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), width, height);
Ok(encoder.encode(80.0).to_vec())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use object_store::memory::InMemory;
use image_storage::ImageStorageAdapter;
struct MockImageRef {
swaps: Mutex<Vec<(String, String)>>,
}
impl MockImageRef {
fn new() -> Arc<Self> {
Arc::new(Self { swaps: Mutex::new(vec![]) })
}
fn swaps(&self) -> Vec<(String, String)> {
self.swaps.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl ImageRefCommand for MockImageRef {
async fn swap(&self, old: &str, new: &str) -> Result<(), DomainError> {
self.swaps.lock().unwrap().push((old.into(), new.into()));
Ok(())
}
}
fn in_memory_storage() -> Arc<ImageStorageAdapter> {
Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new())))
}
fn tiny_jpeg() -> Vec<u8> {
use image::{DynamicImage, ImageBuffer, Rgb};
let img = DynamicImage::ImageRgb8(
ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])),
);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
buf.into_inner()
}
#[tokio::test]
async fn ignores_non_image_stored_events() {
let storage = in_memory_storage();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif,
);
handler.handle(&DomainEvent::UserUpdated {
user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()),
}).await.unwrap();
assert!(image_ref.swaps().is_empty());
}
#[tokio::test]
async fn skips_already_converted_avif_key() {
let storage = in_memory_storage();
storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif,
);
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap();
assert!(image_ref.swaps().is_empty());
}
#[tokio::test]
async fn skips_already_converted_webp_key() {
let storage = in_memory_storage();
storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Webp,
);
handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap();
assert!(image_ref.swaps().is_empty());
}
#[tokio::test]
async fn converts_jpeg_to_avif_and_swaps_key() {
let storage = in_memory_storage();
storage.store("avatars/u1", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif,
);
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]);
assert!(storage.get("avatars/u1.avif").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err());
}
#[tokio::test]
async fn converts_jpeg_to_webp_and_swaps_key() {
let storage = in_memory_storage();
storage.store("avatars/u1", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Webp,
);
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]);
assert!(storage.get("avatars/u1.webp").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err());
}
}

View File

@@ -0,0 +1,37 @@
mod backfill;
mod config;
mod handler;
pub use backfill::ConversionBackfillJob;
pub use config::{ConversionConfig, Format};
pub use handler::ImageConversionHandler;
use std::sync::Arc;
use domain::ports::{EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob};
pub fn build(
image_storage: Arc<dyn ImageStorage>,
image_ref_command: Arc<dyn ImageRefCommand>,
image_ref_query: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>,
) -> anyhow::Result<Option<(Arc<dyn EventHandler>, Arc<dyn PeriodicJob>)>> {
let config = match ConversionConfig::from_env()? {
Some(c) => c,
None => return Ok(None),
};
let format = config.format;
let handler = Arc::new(ImageConversionHandler::new(
Arc::clone(&image_storage),
image_ref_command,
format,
)) as Arc<dyn EventHandler>;
let job = Arc::new(ConversionBackfillJob::new(
image_ref_query,
Arc::clone(&event_publisher),
)) as Arc<dyn PeriodicJob>;
Ok(Some((handler, job)))
}

View File

@@ -9,6 +9,5 @@ xlsx = ["dep:calamine"]
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true }
csv = { workspace = true } csv = { workspace = true }
calamine = { version = "0.26", optional = true } calamine = { version = "0.26", optional = true }

View File

@@ -10,9 +10,7 @@ domain = { workspace = true }
event-payload = { workspace = true } event-payload = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }

View File

@@ -8,6 +8,8 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::MovieDiscovered { .. } => "movie.discovered", DomainEvent::MovieDiscovered { .. } => "movie.discovered",
DomainEvent::MovieDeleted { .. } => "movie.deleted", DomainEvent::MovieDeleted { .. } => "movie.deleted",
DomainEvent::UserUpdated { .. } => "user.updated", DomainEvent::UserUpdated { .. } => "user.updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
DomainEvent::ImageStored { .. } => "image.stored",
}; };
format!("{prefix}.{suffix}") format!("{prefix}.{suffix}")
} }

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventHandler, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient}, ports::{EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient},
value_objects::{ExternalMetadataId, MovieId, PosterPath}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
@@ -13,6 +13,7 @@ pub struct PosterSyncHandler {
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
image_storage: Arc<dyn ImageStorage>, image_storage: Arc<dyn ImageStorage>,
event_publisher: Arc<dyn EventPublisher>,
max_retries: u32, max_retries: u32,
} }
@@ -22,9 +23,10 @@ impl PosterSyncHandler {
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
image_storage: Arc<dyn ImageStorage>, image_storage: Arc<dyn ImageStorage>,
event_publisher: Arc<dyn EventPublisher>,
max_retries: u32, max_retries: u32,
) -> Self { ) -> Self {
Self { movie_repository, metadata_client, poster_fetcher, image_storage, max_retries } Self { movie_repository, metadata_client, poster_fetcher, image_storage, event_publisher, max_retries }
} }
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> {
@@ -47,6 +49,12 @@ impl PosterSyncHandler {
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?; let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?;
if let Err(e) = self.event_publisher
.publish(&DomainEvent::ImageStored { key: stored_path.clone() })
.await
{
tracing::warn!("failed to emit ImageStored for {stored_path}: {e}");
}
let poster_path = PosterPath::new(stored_path)?; let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path); movie.update_poster(poster_path);

View File

@@ -9,10 +9,7 @@ domain = { workspace = true }
event-payload = { workspace = true } event-payload = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -20,5 +20,3 @@ tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,54 @@
CREATE TABLE IF NOT EXISTS movie_profiles (
movie_id TEXT PRIMARY KEY NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id BIGINT NOT NULL,
imdb_id TEXT,
overview TEXT,
tagline TEXT,
runtime_minutes INTEGER,
budget_usd BIGINT,
revenue_usd BIGINT,
vote_average DOUBLE PRECISION,
vote_count INTEGER,
original_language TEXT,
collection_name TEXT,
enriched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS movie_genres (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (movie_id, tmdb_id)
);
CREATE TABLE IF NOT EXISTS movie_keywords (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (movie_id, tmdb_id)
);
CREATE TABLE IF NOT EXISTS movie_cast (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_person_id BIGINT NOT NULL,
name TEXT NOT NULL,
character TEXT NOT NULL,
billing_order INTEGER NOT NULL,
profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id)
);
CREATE TABLE IF NOT EXISTS movie_crew (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_person_id BIGINT NOT NULL,
name TEXT NOT NULL,
job TEXT NOT NULL,
department TEXT NOT NULL,
profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id, job)
);
CREATE INDEX IF NOT EXISTS idx_movie_cast_person ON movie_cast (tmdb_person_id);
CREATE INDEX IF NOT EXISTS idx_movie_crew_person ON movie_crew (tmdb_person_id);
CREATE INDEX IF NOT EXISTS idx_movie_genres_name ON movie_genres (name);
CREATE INDEX IF NOT EXISTS idx_movie_keywords_name ON movie_keywords (name);

View File

@@ -0,0 +1,52 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
use sqlx::PgPool;
use std::sync::Arc;
pub struct PostgresImageRefAdapter {
pool: PgPool,
}
impl PostgresImageRefAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
pub fn create_image_ref(pool: PgPool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(PostgresImageRefAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>)
}
#[async_trait]
impl ImageRefCommand for PostgresImageRefAdapter {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
#[async_trait]
impl ImageRefQuery for PostgresImageRefAdapter {
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
UNION
SELECT poster_path FROM movies WHERE poster_path IS NOT NULL",
)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|(k,)| k).collect())
}
}

View File

@@ -12,9 +12,11 @@ use domain::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
mod image_ref;
mod import_profile; mod import_profile;
mod import_session; mod import_session;
mod models; mod models;
mod profile;
mod users; mod users;
use models::{ use models::{
@@ -22,8 +24,10 @@ use models::{
UserTotalsRow, datetime_to_str, UserTotalsRow, datetime_to_str,
}; };
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
pub use import_profile::PostgresImportProfileRepository; pub use import_profile::PostgresImportProfileRepository;
pub use import_session::PostgresImportSessionRepository; pub use import_session::PostgresImportSessionRepository;
pub use profile::PostgresMovieProfileRepository;
pub use users::PostgresUserRepository; pub use users::PostgresUserRepository;
fn format_year_month(ym: &str) -> String { fn format_year_month(ym: &str) -> String {
@@ -373,6 +377,52 @@ impl MovieRepository for PostgresRepository {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \
ORDER BY title ASC \
LIMIT $2 OFFSET $3",
)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)",
)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]
@@ -865,6 +915,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::UserRepository>, std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>, std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>, std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
)> { )> {
use anyhow::Context; use anyhow::Context;
@@ -880,6 +931,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
Ok(( Ok((
pool.clone(), pool.clone(),
@@ -890,5 +942,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc::new(PostgresUserRepository::new(pool)) as _, std::sync::Arc::new(PostgresUserRepository::new(pool)) as _,
import_session_repo as _, import_session_repo as _,
import_profile_repo as _, import_profile_repo as _,
movie_profile_repo as _,
)) ))
} }

View File

@@ -0,0 +1,236 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::MovieProfileRepository,
value_objects::MovieId,
};
use sqlx::{PgPool, Row};
pub struct PostgresMovieProfileRepository {
pool: PgPool,
}
impl PostgresMovieProfileRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl MovieProfileRepository for PostgresMovieProfileRepository {
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
let movie_id = p.movie_id.value().to_string();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
sqlx::query(
r#"INSERT INTO movie_profiles
(movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes,
budget_usd, revenue_usd, vote_average, vote_count,
original_language, collection_name, enriched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT(movie_id) DO UPDATE SET
tmdb_id=EXCLUDED.tmdb_id, imdb_id=EXCLUDED.imdb_id,
overview=EXCLUDED.overview, tagline=EXCLUDED.tagline,
runtime_minutes=EXCLUDED.runtime_minutes,
budget_usd=EXCLUDED.budget_usd, revenue_usd=EXCLUDED.revenue_usd,
vote_average=EXCLUDED.vote_average, vote_count=EXCLUDED.vote_count,
original_language=EXCLUDED.original_language,
collection_name=EXCLUDED.collection_name,
enriched_at=EXCLUDED.enriched_at"#,
)
.bind(&movie_id)
.bind(p.tmdb_id as i64)
.bind(&p.imdb_id)
.bind(&p.overview)
.bind(&p.tagline)
.bind(p.runtime_minutes.map(|v| v as i32))
.bind(p.budget_usd)
.bind(p.revenue_usd)
.bind(p.vote_average)
.bind(p.vote_count.map(|v| v as i32))
.bind(&p.original_language)
.bind(&p.collection_name)
.bind(p.enriched_at)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for g in &p.genres {
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for k in &p.keywords {
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for c in &p.cast {
sqlx::query(
"INSERT INTO movie_cast \
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
)
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name)
.bind(&c.character).bind(c.billing_order as i32).bind(&c.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for cr in &p.crew {
sqlx::query(
"INSERT INTO movie_crew \
(movie_id, tmdb_person_id, name, job, department, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
)
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name)
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
tx.commit().await.map_err(Self::map_err)
}
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
let movie_id = id.value().to_string();
let row = sqlx::query(
"SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd,
revenue_usd, vote_average, vote_count, original_language,
collection_name, enriched_at
FROM movie_profiles WHERE movie_id = $1",
)
.bind(&movie_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let row = match row {
Some(r) => r,
None => return Ok(None),
};
let enriched_at: DateTime<Utc> = row.try_get("enriched_at")
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
name: r.try_get("name").unwrap_or_default(),
})
.collect();
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
name: r.try_get("name").unwrap_or_default(),
})
.collect();
let cast = sqlx::query(
"SELECT tmdb_person_id, name, character, billing_order, profile_path \
FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
billing_order: r.try_get::<i32, _>("billing_order").unwrap_or(0) as u32,
profile_path: r.try_get("profile_path").ok(),
})
.collect();
let crew = sqlx::query(
"SELECT tmdb_person_id, name, job, department, profile_path \
FROM movie_crew WHERE movie_id = $1",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i32>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i32>, _>("vote_count").ok().flatten().map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
genres,
keywords,
cast,
crew,
enriched_at,
}))
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
let threshold = Utc::now() - chrono::Duration::days(30);
let rows = sqlx::query(
r#"SELECT m.id, m.external_metadata_id
FROM movies m
LEFT JOIN movie_profiles p ON p.movie_id = m.id
WHERE m.external_metadata_id IS NOT NULL
AND (p.movie_id IS NULL OR p.enriched_at < $1)
ORDER BY p.enriched_at ASC NULLS FIRST"#,
)
.bind(threshold)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows
.into_iter()
.filter_map(|r| {
let ext_id: Option<String> = r.try_get("external_metadata_id").ok()?;
let ext_id = ext_id?;
let id_str: String = r.try_get("id").ok()?;
let movie_id = id_str.parse::<uuid::Uuid>().ok().map(MovieId::from_uuid)?;
Some((movie_id, ext_id))
})
.collect())
}
}

View File

@@ -5,6 +5,5 @@ edition = "2024"
[dependencies] [dependencies]
rss-feed = { package = "rss", version = "2" } rss-feed = { package = "rss", version = "2" }
chrono = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }

View File

@@ -9,10 +9,7 @@ domain = { workspace = true }
event-payload = { workspace = true } event-payload = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,54 @@
CREATE TABLE IF NOT EXISTS movie_profiles (
movie_id TEXT PRIMARY KEY NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
imdb_id TEXT,
overview TEXT,
tagline TEXT,
runtime_minutes INTEGER,
budget_usd INTEGER,
revenue_usd INTEGER,
vote_average REAL,
vote_count INTEGER,
original_language TEXT,
collection_name TEXT,
enriched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS movie_genres (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (movie_id, tmdb_id)
);
CREATE TABLE IF NOT EXISTS movie_keywords (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (movie_id, tmdb_id)
);
CREATE TABLE IF NOT EXISTS movie_cast (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_person_id INTEGER NOT NULL,
name TEXT NOT NULL,
character TEXT NOT NULL,
billing_order INTEGER NOT NULL,
profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id)
);
CREATE TABLE IF NOT EXISTS movie_crew (
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
tmdb_person_id INTEGER NOT NULL,
name TEXT NOT NULL,
job TEXT NOT NULL,
department TEXT NOT NULL,
profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id, job)
);
CREATE INDEX IF NOT EXISTS idx_movie_cast_person ON movie_cast (tmdb_person_id);
CREATE INDEX IF NOT EXISTS idx_movie_crew_person ON movie_crew (tmdb_person_id);
CREATE INDEX IF NOT EXISTS idx_movie_genres_name ON movie_genres (name);
CREATE INDEX IF NOT EXISTS idx_movie_keywords_name ON movie_keywords (name);

View File

@@ -0,0 +1,159 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
use sqlx::SqlitePool;
use std::sync::Arc;
pub struct SqliteImageRefAdapter {
pool: SqlitePool,
}
impl SqliteImageRefAdapter {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(SqliteImageRefAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>)
}
#[async_trait]
impl ImageRefCommand for SqliteImageRefAdapter {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
#[async_trait]
impl ImageRefQuery for SqliteImageRefAdapter {
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
UNION
SELECT poster_path FROM movies WHERE poster_path IS NOT NULL",
)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|(k,)| k).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn setup(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'standard',
bio TEXT,
avatar_path TEXT
)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE IF NOT EXISTS movies (
id TEXT PRIMARY KEY,
external_metadata_id TEXT,
title TEXT NOT NULL,
release_year INTEGER,
director TEXT,
poster_path TEXT
)",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn list_keys_returns_both_avatar_and_poster_paths() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool).await.unwrap();
let adapter = SqliteImageRefAdapter::new(pool);
let mut keys = adapter.list_keys().await.unwrap();
keys.sort();
assert_eq!(keys, vec!["avatars/u1", "posters/m1"]);
}
#[tokio::test]
async fn list_keys_excludes_nulls() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)")
.execute(&pool).await.unwrap();
let adapter = SqliteImageRefAdapter::new(pool);
assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new());
}
#[tokio::test]
async fn swap_updates_avatar_path() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.execute(&pool).await.unwrap();
let adapter = SqliteImageRefAdapter::new(pool.clone());
adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'")
.fetch_one(&pool).await.unwrap();
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
}
#[tokio::test]
async fn swap_updates_poster_path() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool).await.unwrap();
let adapter = SqliteImageRefAdapter::new(pool.clone());
adapter.swap("posters/m1", "posters/m1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'")
.fetch_one(&pool).await.unwrap();
assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
}
#[tokio::test]
async fn swap_noop_when_key_not_found() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await;
let adapter = SqliteImageRefAdapter::new(pool);
adapter.swap("missing/key", "missing/key.avif").await.unwrap();
}
}

View File

@@ -12,10 +12,12 @@ use domain::{
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
mod image_ref;
mod import_profile; mod import_profile;
mod import_session; mod import_session;
mod migrations; mod migrations;
mod models; mod models;
mod profile;
mod users; mod users;
use models::{ use models::{
@@ -23,8 +25,10 @@ use models::{
UserTotalsRow, datetime_to_str, UserTotalsRow, datetime_to_str,
}; };
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
pub use import_profile::SqliteImportProfileRepository; pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository; pub use import_session::SqliteImportSessionRepository;
pub use profile::SqliteMovieProfileRepository;
pub use users::SqliteUserRepository; pub use users::SqliteUserRepository;
fn format_year_month(ym: &str) -> String { fn format_year_month(ym: &str) -> String {
@@ -381,6 +385,54 @@ impl MovieRepository for SqliteMovieRepository {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE (? IS NULL OR LOWER(title) LIKE ?) \
ORDER BY title ASC \
LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)",
)
.bind(&pattern)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]
@@ -854,6 +906,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::UserRepository>, std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>, std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>, std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
)> { )> {
use std::str::FromStr; use std::str::FromStr;
use anyhow::Context; use anyhow::Context;
@@ -876,6 +929,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())); let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())); let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone()));
Ok(( Ok((
pool.clone(), pool.clone(),
@@ -886,6 +940,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc::new(SqliteUserRepository::new(pool)) as _, std::sync::Arc::new(SqliteUserRepository::new(pool)) as _,
import_session_repo as _, import_session_repo as _,
import_profile_repo as _, import_profile_repo as _,
movie_profile_repo as _,
)) ))
} }

View File

@@ -0,0 +1,240 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::MovieProfileRepository,
value_objects::MovieId,
};
use sqlx::{Row, SqlitePool};
pub struct SqliteMovieProfileRepository {
pool: SqlitePool,
}
impl SqliteMovieProfileRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl MovieProfileRepository for SqliteMovieProfileRepository {
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
let movie_id = p.movie_id.value().to_string();
let enriched_at = p.enriched_at.to_rfc3339();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
sqlx::query(
r#"INSERT INTO movie_profiles
(movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes,
budget_usd, revenue_usd, vote_average, vote_count,
original_language, collection_name, enriched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(movie_id) DO UPDATE SET
tmdb_id=excluded.tmdb_id, imdb_id=excluded.imdb_id,
overview=excluded.overview, tagline=excluded.tagline,
runtime_minutes=excluded.runtime_minutes,
budget_usd=excluded.budget_usd, revenue_usd=excluded.revenue_usd,
vote_average=excluded.vote_average, vote_count=excluded.vote_count,
original_language=excluded.original_language,
collection_name=excluded.collection_name,
enriched_at=excluded.enriched_at"#,
)
.bind(&movie_id)
.bind(p.tmdb_id as i64)
.bind(&p.imdb_id)
.bind(&p.overview)
.bind(&p.tagline)
.bind(p.runtime_minutes.map(|v| v as i64))
.bind(p.budget_usd)
.bind(p.revenue_usd)
.bind(p.vote_average)
.bind(p.vote_count.map(|v| v as i64))
.bind(&p.original_language)
.bind(&p.collection_name)
.bind(&enriched_at)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for g in &p.genres {
sqlx::query("INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)")
.bind(&movie_id).bind(g.tmdb_id as i64).bind(&g.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for k in &p.keywords {
sqlx::query("INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)")
.bind(&movie_id).bind(k.tmdb_id as i64).bind(&k.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for c in &p.cast {
sqlx::query(
"INSERT OR IGNORE INTO movie_cast \
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
VALUES (?,?,?,?,?,?)",
)
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name)
.bind(&c.character).bind(c.billing_order as i64).bind(&c.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
for cr in &p.crew {
sqlx::query(
"INSERT OR IGNORE INTO movie_crew \
(movie_id, tmdb_person_id, name, job, department, profile_path) \
VALUES (?,?,?,?,?,?)",
)
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name)
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
}
tx.commit().await.map_err(Self::map_err)
}
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
let movie_id = id.value().to_string();
let row = sqlx::query(
"SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd,
revenue_usd, vote_average, vote_count, original_language,
collection_name, enriched_at
FROM movie_profiles WHERE movie_id = ?",
)
.bind(&movie_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let row = match row {
Some(r) => r,
None => return Ok(None),
};
let enriched_at_str: String = row.try_get("enriched_at")
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let enriched_at: DateTime<Utc> = enriched_at_str
.parse()
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
name: r.try_get("name").unwrap_or_default(),
})
.collect();
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
name: r.try_get("name").unwrap_or_default(),
})
.collect();
let cast = sqlx::query(
"SELECT tmdb_person_id, name, character, billing_order, profile_path \
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
billing_order: r.try_get::<i64, _>("billing_order").unwrap_or(0) as u32,
profile_path: r.try_get("profile_path").ok(),
})
.collect();
let crew = sqlx::query(
"SELECT tmdb_person_id, name, job, department, profile_path \
FROM movie_crew WHERE movie_id = ?",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i64>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i64>, _>("vote_count").ok().flatten().map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
genres,
keywords,
cast,
crew,
enriched_at,
}))
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
let threshold = (Utc::now() - chrono::Duration::days(30)).to_rfc3339();
let rows = sqlx::query(
r#"SELECT m.id, m.external_metadata_id
FROM movies m
LEFT JOIN movie_profiles p ON p.movie_id = m.id
WHERE m.external_metadata_id IS NOT NULL
AND (p.movie_id IS NULL OR p.enriched_at < ?)
ORDER BY p.enriched_at ASC"#,
)
.bind(&threshold)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows
.into_iter()
.filter_map(|r| {
let ext_id: Option<String> = r.try_get("external_metadata_id").ok()?;
let ext_id = ext_id?;
let id_str: String = r.try_get("id").ok()?;
let movie_id = id_str.parse::<uuid::Uuid>().ok().map(MovieId::from_uuid)?;
Some((movie_id, ext_id))
})
.collect())
}
}

View File

@@ -6,7 +6,6 @@ edition = "2024"
[dependencies] [dependencies]
askama = { version = "0.16.0" } askama = { version = "0.16.0" }
serde = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -0,0 +1,14 @@
[package]
name = "tmdb-enrichment"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }

View File

@@ -0,0 +1,211 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository},
value_objects::MovieId,
};
use serde::Deserialize;
// ── TMDb enrichment client ───────────────────────────────────────────────────
pub struct TmdbEnrichmentClient {
api_key: String,
http: reqwest::Client,
}
impl TmdbEnrichmentClient {
pub fn from_env() -> Result<Self, DomainError> {
let api_key = std::env::var("TMDB_API_KEY").map_err(|_| {
DomainError::InfrastructureError("TMDB_API_KEY is not set".into())
})?;
Ok(Self { api_key, http: reqwest::Client::new() })
}
fn base(&self, path: &str) -> String {
format!("https://api.themoviedb.org/3{}", path)
}
async fn get<T: for<'de> Deserialize<'de>>(&self, url: &str, extra: &[(&str, &str)]) -> Result<T, DomainError> {
let mut req = self.http.get(url).query(&[("api_key", self.api_key.as_str())]);
for (k, v) in extra {
req = req.query(&[(k, v)]);
}
req.send().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.json::<T>().await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
if let Some(numeric) = external_id.strip_prefix("tmdb:") {
return numeric.parse::<u64>()
.map_err(|_| DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}")));
}
// Assume IMDb ID (tt…) — use /find
#[derive(Deserialize)]
struct FindResult { id: u64 }
#[derive(Deserialize)]
struct FindResponse { movie_results: Vec<FindResult> }
let url = self.base(&format!("/find/{}", external_id));
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
resp.movie_results
.into_iter()
.next()
.map(|r| r.id)
.ok_or_else(|| DomainError::NotFound(format!("TMDb: no movie for {external_id}")))
}
}
#[async_trait]
impl MovieEnrichmentClient for TmdbEnrichmentClient {
async fn fetch_profile(&self, movie_id: MovieId, external_metadata_id: &str) -> Result<MovieProfile, DomainError> {
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
#[derive(Deserialize)]
struct GenreDto { id: u32, name: String }
#[derive(Deserialize)]
struct CollectionDto { name: String }
#[derive(Deserialize)]
struct CastDto {
id: u64,
name: String,
character: String,
order: u32,
profile_path: Option<String>,
}
#[derive(Deserialize)]
struct CrewDto {
id: u64,
name: String,
job: String,
department: String,
profile_path: Option<String>,
}
#[derive(Deserialize)]
struct Credits { cast: Vec<CastDto>, crew: Vec<CrewDto> }
#[derive(Deserialize)]
struct KeywordDto { id: u32, name: String }
#[derive(Deserialize)]
struct Keywords { keywords: Vec<KeywordDto> }
#[derive(Deserialize)]
struct Details {
imdb_id: Option<String>,
overview: Option<String>,
tagline: Option<String>,
runtime: Option<u32>,
budget: Option<i64>,
revenue: Option<i64>,
vote_average: Option<f64>,
vote_count: Option<u32>,
original_language: Option<String>,
genres: Vec<GenreDto>,
belongs_to_collection: Option<CollectionDto>,
credits: Credits,
keywords: Keywords,
}
let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self.get(&url, &[("append_to_response", "credits,keywords")]).await?;
Ok(MovieProfile {
movie_id,
tmdb_id,
imdb_id: d.imdb_id.filter(|s| !s.is_empty()),
overview: d.overview.filter(|s| !s.is_empty()),
tagline: d.tagline.filter(|s| !s.is_empty()),
runtime_minutes: d.runtime,
budget_usd: d.budget.filter(|&v| v > 0),
revenue_usd: d.revenue.filter(|&v| v > 0),
vote_average: d.vote_average,
vote_count: d.vote_count,
original_language: d.original_language,
collection_name: d.belongs_to_collection.map(|c| c.name),
genres: d.genres.into_iter().map(|g| Genre { tmdb_id: g.id, name: g.name }).collect(),
keywords: d.keywords.keywords.into_iter()
.map(|k| Keyword { tmdb_id: k.id, name: k.name })
.collect(),
cast: d.credits.cast.into_iter().map(|c| CastMember {
tmdb_person_id: c.id,
name: c.name,
character: c.character,
billing_order: c.order,
profile_path: c.profile_path,
}).collect(),
crew: d.credits.crew.into_iter().map(|c| CrewMember {
tmdb_person_id: c.id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
}).collect(),
enriched_at: Utc::now(),
})
}
}
// ── Enrichment event handler ─────────────────────────────────────────────────
pub struct EnrichmentHandler {
pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
pub profile_repo: Arc<dyn MovieProfileRepository>,
}
#[async_trait]
impl EventHandler for EnrichmentHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let (movie_id, external_metadata_id) = match event {
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
(movie_id.clone(), external_metadata_id.clone())
}
_ => return Ok(()),
};
// Skip if profile is fresh (checked by the repo's list_stale, but guard here too)
if let Ok(Some(existing)) = self.profile_repo.get_by_movie_id(&movie_id).await {
let age = Utc::now() - existing.enriched_at;
if age.num_days() < 30 {
tracing::debug!(
movie_id = %movie_id.value(),
"skipping enrichment — profile is {} days old",
age.num_days()
);
return Ok(());
}
}
tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie");
match self.enrichment_client.fetch_profile(movie_id.clone(), &external_metadata_id).await {
Ok(profile) => {
self.profile_repo.upsert(&profile).await?;
tracing::info!(
movie_id = %movie_id.value(),
genres = profile.genres.len(),
cast = profile.cast.len(),
crew = profile.crew.len(),
"enrichment stored"
);
}
Err(DomainError::NotFound(msg)) => {
tracing::warn!(movie_id = %movie_id.value(), "TMDb lookup found nothing: {msg}");
}
Err(e) => return Err(e),
}
Ok(())
}
}

View File

@@ -0,0 +1,9 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }

View File

@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginResponse {
pub token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}

View File

@@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}

View File

@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::movies::{MovieDto, ReviewDto};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LogReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub external_metadata_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_release_year: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ActivityFeedQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FeedEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
pub user_email: String,
pub user_display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActivityFeedResponse {
pub items: Vec<FeedEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ExportQueryParams {
/// Output format: `csv` (default) or `json`
#[serde(default = "default_export_format")]
pub format: String,
}
fn default_export_format() -> String {
"csv".to_string()
}

View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SessionCreatedResponse {
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SessionStateResponse {
pub session_id: String,
pub columns: Vec<String>,
pub has_mappings: bool,
pub row_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApiFieldMapping {
/// Column name in the source file
pub source_column: String,
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
pub domain_field: String,
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
pub rating_scale: Option<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApplyMappingRequest {
pub mappings: Vec<ApiFieldMapping>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ConfirmRequest {
/// Indices (0-based) of rows from the mapping preview to import
pub confirmed_indices: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SaveProfileRequest {
/// Session UUID whose current field_mappings to save
pub session_id: String,
/// Human-readable profile name (e.g. "Letterboxd")
pub name: String,
}

View File

@@ -0,0 +1,15 @@
pub mod auth;
pub mod common;
pub mod diary;
pub mod import;
pub mod movies;
pub mod social;
pub mod users;
pub use auth::*;
pub use common::*;
pub use diary::*;
pub use import::*;
pub use movies::*;
pub use social::*;
pub use users::*;

View File

@@ -0,0 +1,129 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Movie list ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct MoviesQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
/// Optional title filter (case-insensitive substring match)
pub search: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MoviesResponse {
pub items: Vec<MovieDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Movie profile (enrichment) ────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GenreDto {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct KeywordDto {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CastMemberDto {
pub tmdb_person_id: u64,
pub name: String,
pub character: String,
pub billing_order: u32,
pub profile_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CrewMemberDto {
pub tmdb_person_id: u64,
pub name: String,
pub job: String,
pub department: String,
pub profile_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieProfileResponse {
pub tmdb_id: u64,
pub imdb_id: Option<String>,
pub overview: Option<String>,
pub tagline: Option<String>,
pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>,
pub revenue_usd: Option<i64>,
pub vote_average: Option<f64>,
pub vote_count: Option<u32>,
pub original_language: Option<String>,
pub collection_name: Option<String>,
pub genres: Vec<GenreDto>,
pub keywords: Vec<KeywordDto>,
pub cast: Vec<CastMemberDto>,
pub crew: Vec<CrewMemberDto>,
pub enriched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
pub trend: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}

View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FollowRequest {
pub handle: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActorUrlRequest {
pub actor_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BlockedDomainResponse {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AddBlockedDomainRequest {
pub domain: String,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BlockedActorResponse {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}

View File

@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::diary::{DiaryEntryDto, DiaryResponse};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSummaryDto {
pub id: Uuid,
pub email: String,
pub total_movies: i64,
pub avg_rating: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UsersResponse {
pub users: Vec<UserSummaryDto>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserStatsDto {
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub favorite_director: Option<String>,
pub most_active_month: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MonthActivityDto {
pub year_month: String,
pub month_label: String,
pub count: i64,
pub entries: Vec<DiaryEntryDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MonthlyRatingDto {
pub year_month: String,
pub month_label: String,
pub avg_rating: f64,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DirectorStatDto {
pub director: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
pub username: String,
pub stats: UserStatsDto,
pub following_count: usize,
pub followers_count: usize,
/// Populated for view=recent and view=ratings
pub entries: Option<DiaryResponse>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}

View File

@@ -1,5 +1,5 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::models::{ExportFormat, FieldMapping, FileFormat, UserRole}; use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid; use uuid::Uuid;
pub struct LogReviewCommand { pub struct LogReviewCommand {
@@ -21,11 +21,6 @@ pub struct SyncPosterCommand {
pub external_metadata_id: String, pub external_metadata_id: String,
} }
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RegisterCommand { pub struct RegisterCommand {
pub email: String, pub email: String,
pub username: String, pub username: String,
@@ -38,11 +33,6 @@ pub struct DeleteReviewCommand {
pub requesting_user_id: Uuid, pub requesting_user_id: Uuid,
} }
pub struct ExportCommand {
pub user_id: Uuid,
pub format: ExportFormat,
}
// FileFormat is now in domain::models — no longer defined here // FileFormat is now in domain::models — no longer defined here
pub struct CreateImportSessionCommand { pub struct CreateImportSessionCommand {
@@ -79,3 +69,10 @@ pub struct DeleteImportProfileCommand {
pub user_id: Uuid, pub user_id: Uuid,
pub profile_id: Uuid, pub profile_id: Uuid,
} }
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}

View File

@@ -4,7 +4,7 @@ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImageStorage, ImageStorage,
ImportProfileRepository, ImportSessionRepository, ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
ReviewRepository, StatsRepository, UserRepository, ReviewRepository, StatsRepository, UserRepository,
}; };
@@ -27,5 +27,6 @@ pub struct AppContext {
pub user_repository: Arc<dyn UserRepository>, pub user_repository: Arc<dyn UserRepository>,
pub import_session_repository: Arc<dyn ImportSessionRepository>, pub import_session_repository: Arc<dyn ImportSessionRepository>,
pub import_profile_repository: Arc<dyn ImportProfileRepository>, pub import_profile_repository: Arc<dyn ImportProfileRepository>,
pub movie_profile_repository: Arc<dyn MovieProfileRepository>,
pub config: AppConfig, pub config: AppConfig,
} }

View File

@@ -0,0 +1,60 @@
use std::time::Duration;
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
use crate::context::AppContext;
pub struct ImportSessionCleanupJob {
ctx: AppContext,
}
impl ImportSessionCleanupJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for ImportSessionCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let n = crate::use_cases::cleanup_expired_import_sessions::execute(&self.ctx).await?;
tracing::info!("import session cleanup: removed {} expired sessions", n);
Ok(())
}
}
pub struct EnrichmentStalenessJob {
ctx: AppContext,
}
impl EnrichmentStalenessJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for EnrichmentStalenessJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let stale = self.ctx.movie_profile_repository.list_stale().await?;
if stale.is_empty() {
return Ok(());
}
tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale {
let event = DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id };
self.ctx.event_publisher.publish(&event).await?;
}
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
pub mod commands; pub mod commands;
pub mod jobs;
pub mod worker; pub mod worker;
pub mod config; pub mod config;
pub mod context; pub mod context;

View File

@@ -226,9 +226,8 @@ mod tests {
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected") panic!("unexpected")
} }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
}
} }
#[async_trait] #[async_trait]
@@ -249,12 +248,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
} async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
} }
#[async_trait] #[async_trait]
@@ -275,12 +271,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()]) Ok(vec![self.0.clone()])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
} async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
} }
struct MetaReturnsMovie(Movie); struct MetaReturnsMovie(Movie);

View File

@@ -1,6 +1,16 @@
use domain::models::SortDirection; use domain::models::{ExportFormat, SortDirection};
use uuid::Uuid; use uuid::Uuid;
pub struct LoginQuery {
pub email: String,
pub password: String,
}
pub struct ExportQuery {
pub user_id: Uuid,
pub format: ExportFormat,
}
pub struct GetDiaryQuery { pub struct GetDiaryQuery {
pub limit: Option<u32>, pub limit: Option<u32>,
pub offset: Option<u32>, pub offset: Option<u32>,
@@ -70,3 +80,9 @@ pub struct GetMovieSocialPageQuery {
pub limit: u32, pub limit: u32,
pub offset: u32, pub offset: u32,
} }
pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
}

View File

@@ -1,13 +1,13 @@
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
use crate::{commands::ExportCommand, context::AppContext}; use crate::{context::AppContext, queries::ExportQuery};
pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result<Vec<u8>, DomainError> { pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
let entries = ctx let entries = ctx
.diary_repository .diary_repository
.get_user_history(&UserId::from_uuid(cmd.user_id)) .get_user_history(&UserId::from_uuid(query.user_id))
.await?; .await?;
ctx.diary_exporter ctx.diary_exporter
.serialize_entries(&entries, cmd.format) .serialize_entries(&entries, query.format)
.await .await
} }

View File

@@ -0,0 +1,14 @@
use domain::{
errors::DomainError,
models::collections::{PageParams, Paginated},
models::Movie,
};
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<Movie>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
ctx.movie_repository
.list_movies(&page, query.search.as_deref())
.await
}

View File

@@ -50,6 +50,14 @@ async fn publish_events(
} }
} }
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.value().to_string(),
};
ctx.event_publisher.publish(&enrichment_event).await?;
}
ctx.event_publisher.publish(&review_event).await?; ctx.event_publisher.publish(&review_event).await?;
Ok(()) Ok(())
} }

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email}; use domain::{errors::DomainError, value_objects::Email};
use crate::{commands::LoginCommand, context::AppContext}; use crate::{context::AppContext, queries::LoginQuery};
pub struct LoginResult { pub struct LoginResult {
pub token: String, pub token: String,
@@ -12,8 +12,8 @@ pub struct LoginResult {
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> { pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
let email = Email::new(cmd.email)?; let email = Email::new(query.email)?;
let user = ctx let user = ctx
.user_repository .user_repository
.find_by_email(&email) .find_by_email(&email)
@@ -22,7 +22,7 @@ pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult,
let valid = ctx let valid = ctx
.password_hasher .password_hasher
.verify(&cmd.password, user.password_hash()) .verify(&query.password, user.password_hash())
.await?; .await?;
if !valid { if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".into())); return Err(DomainError::Unauthorized("Invalid credentials".into()));

View File

@@ -11,6 +11,7 @@ pub mod export_diary;
pub mod get_activity_feed; pub mod get_activity_feed;
pub mod get_diary; pub mod get_diary;
pub mod get_movie_social_page; pub mod get_movie_social_page;
pub mod get_movies;
pub mod get_review_history; pub mod get_review_history;
pub mod get_user_profile; pub mod get_user_profile;
pub mod get_users; pub mod get_users;

View File

@@ -1,5 +1,6 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, PosterPath}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
@@ -39,6 +40,14 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
.image_storage .image_storage
.store(&movie_id.value().to_string(), &image_bytes) .store(&movie_id.value().to_string(), &image_bytes)
.await?; .await?;
if let Err(e) = ctx.event_publisher
.publish(&DomainEvent::ImageStored { key: stored_path.clone() })
.await
{
tracing::warn!("failed to emit ImageStored for {stored_path}: {e}");
}
let poster_path = PosterPath::new(stored_path)?; let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path); movie.update_poster(poster_path);

View File

@@ -4,14 +4,7 @@ use domain::{
value_objects::UserId, value_objects::UserId,
}; };
use crate::context::AppContext; use crate::{commands::UpdateProfileCommand, context::AppContext};
pub struct UpdateProfileCommand {
pub user_id: uuid::Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> { pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
@@ -34,6 +27,14 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
} }
let key = format!("avatars/{}", user_id.value()); let key = format!("avatars/{}", user_id.value());
let stored = ctx.image_storage.store(&key, &bytes).await?; let stored = ctx.image_storage.store(&key, &bytes).await?;
if let Err(e) = ctx.event_publisher
.publish(&DomainEvent::ImageStored { key: stored.clone() })
.await
{
tracing::warn!("failed to emit ImageStored for {stored}: {e}");
}
Some(stored) Some(stored)
} else { } else {
user.avatar_path().map(|s| s.to_string()) user.avatar_path().map(|s| s.to_string())

View File

@@ -96,6 +96,8 @@ mod tests {
DomainEvent::ReviewDeleted { .. } => "review_deleted", DomainEvent::ReviewDeleted { .. } => "review_deleted",
DomainEvent::MovieDeleted { .. } => "movie_deleted", DomainEvent::MovieDeleted { .. } => "movie_deleted",
DomainEvent::UserUpdated { .. } => "user_updated", DomainEvent::UserUpdated { .. } => "user_updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested",
DomainEvent::ImageStored { .. } => "image_stored",
}; };
self.calls.lock().unwrap().push(label); self.calls.lock().unwrap().push(label);
Ok(()) Ok(())

View File

@@ -1,13 +0,0 @@
[package]
name = "doc"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { workspace = true }
tracing = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras"] }
utoipa-scalar = { version = "0.3.0", features = [
"axum",
], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }

View File

@@ -1,16 +0,0 @@
use axum::Router;
use utoipa::openapi::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use utoipa_swagger_ui::SwaggerUi;
pub trait ApiDocExt {
fn with_api_doc(self, spec: OpenApi) -> Self;
}
impl ApiDocExt for Router {
fn with_api_doc(self, spec: OpenApi) -> Self {
tracing::info!("API docs at /docs (Swagger) and /scalar");
self.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
.merge(Scalar::with_url("/scalar", spec))
}
}

View File

@@ -7,7 +7,6 @@ edition = "2024"
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
futures = { workspace = true } futures = { workspace = true }

View File

@@ -37,6 +37,13 @@ pub enum DomainEvent {
review_id: ReviewId, review_id: ReviewId,
user_id: UserId, user_id: UserId,
}, },
MovieEnrichmentRequested {
movie_id: MovieId,
external_metadata_id: String,
},
ImageStored {
key: String,
},
} }
#[async_trait] #[async_trait]

View File

@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
@@ -490,3 +490,56 @@ mod tests {
assert_eq!(user.avatar_path(), None); assert_eq!(user.avatar_path(), None);
} }
} }
// ── Movie enrichment ───────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct Genre {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct Keyword {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct CastMember {
pub tmdb_person_id: u64,
pub name: String,
pub character: String,
pub billing_order: u32,
pub profile_path: Option<String>,
}
#[derive(Clone, Debug)]
pub struct CrewMember {
pub tmdb_person_id: u64,
pub name: String,
pub job: String,
pub department: String,
pub profile_path: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MovieProfile {
pub movie_id: MovieId,
pub tmdb_id: u64,
pub imdb_id: Option<String>,
pub overview: Option<String>,
pub tagline: Option<String>,
pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>,
pub revenue_usd: Option<i64>,
pub vote_average: Option<f64>,
pub vote_count: Option<u32>,
pub original_language: Option<String>,
pub collection_name: Option<String>,
pub genres: Vec<Genre>,
pub keywords: Vec<Keyword>,
pub cast: Vec<CastMember>,
pub crew: Vec<CrewMember>,
pub enriched_at: DateTime<Utc>,
}

View File

@@ -6,9 +6,9 @@ use crate::{
events::{DomainEvent, EventEnvelope}, events::{DomainEvent, EventEnvelope},
models::{ models::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieStats, ParsedFile, FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
Review, ReviewHistory, User, UserStats, UserSummary, UserTrends, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated}, collections::{self, PageParams, Paginated},
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -83,6 +83,11 @@ pub trait MovieRepository: Send + Sync {
) -> Result<Vec<Movie>, DomainError>; ) -> Result<Vec<Movie>, DomainError>;
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
async fn list_movies(
&self,
page: &collections::PageParams,
search: Option<&str>,
) -> Result<collections::Paginated<Movie>, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -217,6 +222,31 @@ pub trait EventHandler: Send + Sync {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
} }
#[async_trait]
pub trait PeriodicJob: Send + Sync {
fn interval(&self) -> std::time::Duration;
async fn run(&self) -> Result<(), DomainError>;
}
#[async_trait]
pub trait MovieProfileRepository: Send + Sync {
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
/// (enriched_at older than 30 days).
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
}
#[async_trait]
pub trait MovieEnrichmentClient: Send + Sync {
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
async fn fetch_profile(
&self,
movie_id: MovieId,
external_metadata_id: &str,
) -> Result<MovieProfile, DomainError>;
}
#[async_trait] #[async_trait]
pub trait ImportSessionRepository: Send + Sync { pub trait ImportSessionRepository: Send + Sync {
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
@@ -234,3 +264,13 @@ pub trait ImportProfileRepository: Send + Sync {
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError>; async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError>;
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>;
} }
#[async_trait]
pub trait ImageRefCommand: Send + Sync {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>;
}
#[async_trait]
pub trait ImageRefQuery: Send + Sync {
async fn list_keys(&self) -> Result<Vec<String>, DomainError>;
}

View File

@@ -33,7 +33,6 @@ axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -42,6 +41,7 @@ uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
api-types = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
auth = { workspace = true } auth = { workspace = true }
@@ -52,10 +52,11 @@ template-askama = { workspace = true }
nats = { workspace = true, optional = true } nats = { workspace = true, optional = true }
rss = { workspace = true } rss = { workspace = true }
export = { workspace = true } export = { workspace = true }
doc = { workspace = true }
importer = { workspace = true } importer = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
# Optional — database backends # Optional — database backends
sqlite = { workspace = true, optional = true } sqlite = { workspace = true, optional = true }

View File

@@ -177,6 +177,9 @@ mod tests {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!() panic!()
} }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl ReviewRepository for Panic { impl ReviewRepository for Panic {
@@ -358,6 +361,12 @@ mod tests {
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl domain::ports::MovieProfileRepository for Panic {
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
}
#[async_trait::async_trait]
impl domain::ports::DiaryExporter for Panic { impl domain::ports::DiaryExporter for Panic {
async fn serialize_entries( async fn serialize_entries(
&self, &self,
@@ -483,6 +492,7 @@ mod tests {
user_repository: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _,
import_session_repository: Arc::clone(&repo) as _, import_session_repository: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _, import_profile_repository: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _,
auth_service, auth_service,
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,

View File

@@ -1,11 +1,13 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use application::{commands::LogReviewCommand, queries::GetDiaryQuery}; use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
use domain::{errors::DomainError, models::SortDirection}; use domain::{errors::DomainError, models::SortDirection};
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error> use api_types::{DiaryQueryParams, LogReviewRequest};
pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
T: std::str::FromStr, T: std::str::FromStr,
@@ -18,15 +20,6 @@ where
} }
} }
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LogReviewForm { pub struct LogReviewForm {
#[serde(default, deserialize_with = "empty_string_as_none")] #[serde(default, deserialize_with = "empty_string_as_none")]
@@ -67,7 +60,7 @@ pub struct ErrorQuery {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(serde::Deserialize, Default)] #[derive(Deserialize, Default)]
pub struct FeedQueryParams { pub struct FeedQueryParams {
#[serde(default)] #[serde(default)]
pub filter: String, pub filter: String,
@@ -87,74 +80,60 @@ pub struct DeleteRedirectForm {
pub csrf_token: String, pub csrf_token: String,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct LogReviewRequest { pub struct FollowForm {
pub external_metadata_id: Option<String>, pub handle: String,
pub manual_title: Option<String>, #[serde(rename = "_csrf", default)]
pub manual_release_year: Option<u16>, pub csrf_token: String,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct MovieDto { pub struct UnfollowForm {
pub id: Uuid, pub actor_url: String,
pub title: String, #[serde(rename = "_csrf", default)]
pub release_year: u16, pub csrf_token: String,
pub director: Option<String>,
pub poster_path: Option<String>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct ReviewDto { pub struct FollowerActionForm {
pub id: Uuid, pub actor_url: String,
pub rating: u8, #[serde(rename = "_csrf", default)]
pub comment: Option<String>, pub csrf_token: String,
pub watched_at: String,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct DiaryEntryDto { pub struct BlockDomainForm {
pub movie: MovieDto, pub domain: String,
pub review: ReviewDto, #[serde(default)]
pub reason: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct DiaryResponse { pub struct RemoveDomainForm {
pub items: Vec<DiaryEntryDto>, pub domain: String,
pub total_count: u64, #[serde(rename = "_csrf", default)]
pub limit: u32, pub csrf_token: String,
pub offset: u32,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize)]
pub struct ReviewHistoryResponse { pub struct ActorUrlForm {
pub movie: MovieDto, pub actor_url: String,
pub viewings: Vec<ReviewDto>, #[serde(rename = "_csrf", default)]
pub trend: String, pub csrf_token: String,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, Default)]
pub struct LoginRequest { pub struct ProfileQueryParams {
pub email: String, pub view: Option<String>,
pub password: String, pub limit: Option<u32>,
} pub offset: Option<u32>,
pub error: Option<String>,
#[derive(Serialize, utoipa::ToSchema)] #[serde(default)]
pub struct LoginResponse { pub sort_by: String,
pub token: String, #[serde(default)]
pub user_id: Uuid, pub search: String,
pub email: String,
pub expires_at: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
} }
pub struct LogReviewData { pub struct LogReviewData {
@@ -239,8 +218,7 @@ impl LogReviewData {
} }
} }
impl From<DiaryQueryParams> for GetDiaryQuery { pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
fn from(p: DiaryQueryParams) -> Self {
GetDiaryQuery { GetDiaryQuery {
limit: p.limit, limit: p.limit,
offset: p.offset, offset: p.offset,
@@ -255,266 +233,6 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
user_id: None, user_id: None,
} }
} }
}
#[derive(Deserialize)]
pub struct FollowForm {
pub handle: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UnfollowForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct FollowerActionForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct BlockDomainForm {
pub domain: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct RemoveDomainForm {
pub domain: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ActorUrlForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(serde::Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub search: String,
}
// ── Activity feed ─────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ActivityFeedQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct FeedEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
pub user_email: String,
pub user_display_name: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ActivityFeedResponse {
pub items: Vec<FeedEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Users ──────────────────────────────────────────────────────────────────────
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserSummaryDto {
pub id: Uuid,
pub email: String,
pub total_movies: i64,
pub avg_rating: Option<f64>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UsersResponse {
pub users: Vec<UserSummaryDto>,
}
// ── User profile ───────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserStatsDto {
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub favorite_director: Option<String>,
pub most_active_month: Option<String>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MonthActivityDto {
pub year_month: String,
pub month_label: String,
pub count: i64,
pub entries: Vec<DiaryEntryDto>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MonthlyRatingDto {
pub year_month: String,
pub month_label: String,
pub avg_rating: f64,
pub count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DirectorStatDto {
pub director: String,
pub count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
pub username: String,
pub stats: UserStatsDto,
pub following_count: usize,
pub followers_count: usize,
/// Populated for view=recent and view=ratings
pub entries: Option<DiaryResponse>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct FollowRequest {
pub handle: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ActorUrlRequest {
pub actor_url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
#[derive(serde::Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ExportQueryParams {
/// Output format: `csv` (default) or `json`
#[serde(default = "default_export_format")]
pub format: String,
}
fn default_export_format() -> String {
"csv".to_string()
}
#[derive(serde::Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct BlockedDomainResponse {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct AddBlockedDomainRequest {
pub domain: String,
pub reason: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct BlockedActorResponse {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -602,7 +320,7 @@ mod tests {
offset: None, offset: None,
movie_id: None, movie_id: None,
}; };
let query = GetDiaryQuery::from(params); let query = to_diary_query(params);
assert!(matches!( assert!(matches!(
query.sort_by, query.sort_by,
Some(domain::models::SortDirection::Ascending) Some(domain::models::SortDirection::Ascending)
@@ -617,7 +335,7 @@ mod tests {
offset: None, offset: None,
movie_id: None, movie_id: None,
}; };
let query = GetDiaryQuery::from(params); let query = to_diary_query(params);
assert!(matches!( assert!(matches!(
query.sort_by, query.sort_by,
Some(domain::models::SortDirection::Descending) Some(domain::models::SortDirection::Descending)

View File

@@ -10,15 +10,15 @@ use std::str::FromStr;
use application::{ use application::{
commands::{ commands::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand, DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
}, },
queries::{ queries::{
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery, ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetUsersQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
}, },
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_review_history, get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile, register as register_uc, sync_poster, update_profile,
}, },
@@ -31,19 +31,23 @@ use domain::{
}; };
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, CastMemberDto, CrewMemberDto, DiaryEntryDto,
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::{ use crate::{
dtos::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
},
errors::ApiError, errors::ApiError,
extractors::AuthenticatedUser, extractors::AuthenticatedUser,
forms::{to_diary_query, LogReviewData},
state::AppState, state::AppState,
}; };
@@ -60,7 +64,7 @@ pub async fn get_diary(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>, Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> { ) -> Result<Json<DiaryResponse>, ApiError> {
let page = get_diary::execute(&state.app_ctx, params.into()).await?; let page = get_diary::execute(&state.app_ctx, to_diary_query(params)).await?;
Ok(Json(DiaryResponse { Ok(Json(DiaryResponse {
items: page.items.iter().map(entry_to_dto).collect(), items: page.items.iter().map(entry_to_dto).collect(),
@@ -70,6 +74,35 @@ pub async fn get_diary(
})) }))
} }
#[utoipa::path(
get, path = "/api/v1/movies",
params(MoviesQueryParams),
responses(
(status = 200, body = MoviesResponse),
)
)]
pub async fn list_movies(
State(state): State<AppState>,
Query(params): Query<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
&state.app_ctx,
GetMoviesQuery {
limit: params.limit,
offset: params.offset,
search: params.search,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page.items.iter().map(movie_to_dto).collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/movies/{id}/history", get, path = "/api/v1/movies/{id}/history",
params(("id" = Uuid, Path, description = "Movie ID")), params(("id" = Uuid, Path, description = "Movie ID")),
@@ -175,7 +208,7 @@ pub async fn login(
) -> Result<Json<LoginResponse>, ApiError> { ) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute( let result = login_uc::execute(
&state.app_ctx, &state.app_ctx,
LoginCommand { LoginQuery {
email: req.email, email: req.email,
password: req.password, password: req.password,
}, },
@@ -290,6 +323,52 @@ pub async fn get_movie_detail(
})) }))
} }
#[utoipa::path(
get, path = "/api/v1/movies/{id}/profile",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieProfileResponse),
(status = 404, description = "No profile found for this movie"),
)
)]
pub async fn get_movie_profile(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
) -> impl IntoResponse {
let id = domain::value_objects::MovieId::from_uuid(movie_id);
match state.app_ctx.movie_profile_repository.get_by_movie_id(&id).await {
Ok(Some(p)) => Json(MovieProfileResponse {
tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id,
overview: p.overview,
tagline: p.tagline,
runtime_minutes: p.runtime_minutes,
budget_usd: p.budget_usd,
revenue_usd: p.revenue_usd,
vote_average: p.vote_average,
vote_count: p.vote_count,
original_language: p.original_language,
collection_name: p.collection_name,
genres: p.genres.into_iter().map(|g| GenreDto { tmdb_id: g.tmdb_id, name: g.name }).collect(),
keywords: p.keywords.into_iter().map(|k| KeywordDto { tmdb_id: k.tmdb_id, name: k.name }).collect(),
cast: p.cast.into_iter().map(|c| CastMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, character: c.character,
billing_order: c.billing_order, profile_path: c.profile_path,
}).collect(),
crew: p.crew.into_iter().map(|c| CrewMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, job: c.job,
department: c.department, profile_path: c.profile_path,
}).collect(),
enriched_at: p.enriched_at.to_rfc3339(),
}).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_movie_profile: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/profile", get, path = "/api/v1/profile",
responses( responses(
@@ -365,7 +444,7 @@ pub async fn update_profile_handler(
} }
} }
let cmd = update_profile::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
bio, bio,
avatar_bytes, avatar_bytes,
@@ -415,7 +494,7 @@ fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/admin/blocked-domains", get, path = "/api/v1/admin/blocked-domains",
responses( responses(
(status = 200, body = Vec<crate::dtos::BlockedDomainResponse>), (status = 200, body = Vec<BlockedDomainResponse>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"), (status = 403, description = "Forbidden — admin only"),
), ),
@@ -427,9 +506,9 @@ pub async fn get_blocked_domains_admin(
) -> impl IntoResponse { ) -> impl IntoResponse {
match state.ap_service.get_blocked_domains().await { match state.ap_service.get_blocked_domains().await {
Ok(domains) => { Ok(domains) => {
let response: Vec<crate::dtos::BlockedDomainResponse> = domains let response: Vec<BlockedDomainResponse> = domains
.into_iter() .into_iter()
.map(|d| crate::dtos::BlockedDomainResponse { .map(|d| BlockedDomainResponse {
domain: d.domain, domain: d.domain,
reason: d.reason, reason: d.reason,
blocked_at: d.blocked_at, blocked_at: d.blocked_at,
@@ -444,7 +523,7 @@ pub async fn get_blocked_domains_admin(
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/admin/blocked-domains", post, path = "/api/v1/admin/blocked-domains",
request_body = crate::dtos::AddBlockedDomainRequest, request_body = AddBlockedDomainRequest,
responses( responses(
(status = 201, description = "Domain blocked"), (status = 201, description = "Domain blocked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -455,7 +534,7 @@ pub async fn get_blocked_domains_admin(
pub async fn add_blocked_domain_admin( pub async fn add_blocked_domain_admin(
State(state): State<AppState>, State(state): State<AppState>,
_admin: crate::extractors::AdminUser, _admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<crate::dtos::AddBlockedDomainRequest>, axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await { match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
Ok(()) => StatusCode::CREATED.into_response(), Ok(()) => StatusCode::CREATED.into_response(),
@@ -531,7 +610,7 @@ pub async fn unblock_actor_api(
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/social/blocked", get, path = "/api/v1/social/blocked",
responses( responses(
(status = 200, body = Vec<crate::dtos::BlockedActorResponse>), (status = 200, body = Vec<BlockedActorResponse>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
@@ -542,9 +621,9 @@ pub async fn get_blocked_actors_api(
) -> impl IntoResponse { ) -> impl IntoResponse {
match state.ap_service.get_blocked_actors(user.0.value()).await { match state.ap_service.get_blocked_actors(user.0.value()).await {
Ok(actors) => { Ok(actors) => {
let response: Vec<crate::dtos::BlockedActorResponse> = actors let response: Vec<BlockedActorResponse> = actors
.into_iter() .into_iter()
.map(|a| crate::dtos::BlockedActorResponse { .map(|a| BlockedActorResponse {
url: a.url, url: a.url,
handle: a.handle, handle: a.handle,
display_name: a.display_name, display_name: a.display_name,
@@ -986,11 +1065,11 @@ pub async fn export_diary(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"), ExportFormat::Json => ("application/json", "diary.json"),
}; };
let cmd = ExportCommand { let query = ExportQuery {
user_id: user.0.value(), user_id: user.0.value(),
format, format,
}; };
match export_diary_uc::execute(&state.app_ctx, cmd).await { match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => ( Ok(bytes) => (
StatusCode::OK, StatusCode::OK,
[ [

View File

@@ -15,12 +15,12 @@ use application::ports::{
FollowersPageData, FollowingPageData, FollowersPageData, FollowingPageData,
}; };
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, RegisterCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
ports::{ ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, ProfileSettingsPageData, RegisterPageData, RemoteActorView,
}, },
queries::GetMovieSocialPageQuery,
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc, update_profile, login as login_uc, register as register_uc, update_profile,
@@ -30,12 +30,10 @@ use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use crate::dtos::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm}; use crate::forms::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
dtos::{ forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm,
},
extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser}, extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
state::AppState, state::AppState,
}; };
@@ -135,7 +133,7 @@ pub async fn post_login(
} }
match login_uc::execute( match login_uc::execute(
&state.app_ctx, &state.app_ctx,
LoginCommand { LoginQuery {
email: form.email, email: form.email,
password: form.password, password: form.password,
}, },
@@ -217,7 +215,7 @@ pub async fn post_register(
.await .await
{ {
Ok(_) => { Ok(_) => {
match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await { match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => { Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age); let cookie = set_cookie_header(&result.token, max_age);
@@ -280,7 +278,7 @@ pub async fn post_delete_review(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>, Path(review_id): Path<Uuid>,
Form(form): Form<crate::dtos::DeleteRedirectForm>, Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) { if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
@@ -311,7 +309,7 @@ pub async fn post_delete_review(
pub async fn get_export( pub async fn get_export(
State(state): State<AppState>, State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<crate::dtos::ExportQueryParams>, Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let format = match params.format.as_str() { let format = match params.format.as_str() {
"csv" => ExportFormat::Csv, "csv" => ExportFormat::Csv,
@@ -322,11 +320,11 @@ pub async fn get_export(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"), ExportFormat::Json => ("application/json", "diary.json"),
}; };
let cmd = ExportCommand { let query = ExportQuery {
user_id: user_id.value(), user_id: user_id.value(),
format, format,
}; };
match export_diary_uc::execute(&state.app_ctx, cmd).await { match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => ( Ok(bytes) => (
StatusCode::OK, StatusCode::OK,
[ [
@@ -499,12 +497,29 @@ pub async fn get_users_list(
} }
} }
pub async fn get_user_by_username(
State(state): State<AppState>,
Path(username): Path<String>,
) -> impl IntoResponse {
let uname = match domain::value_objects::Username::new(username) {
Ok(u) => u,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
match state.app_ctx.user_repository.find_by_username(&uname).await {
Ok(Some(user)) => {
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
.into_response()
}
_ => StatusCode::NOT_FOUND.into_response(),
}
}
pub async fn get_user_profile( pub async fn get_user_profile(
OptionalCookieUser(user_id): OptionalCookieUser, OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>, Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Query(params): Query<crate::dtos::ProfileQueryParams>, Query(params): Query<crate::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json // Content negotiation: AP clients request application/activity+json
@@ -800,7 +815,7 @@ pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>, Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>, Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if user_id.value() != profile_user_uuid { if user_id.value() != profile_user_uuid {
@@ -850,7 +865,7 @@ pub async fn get_followers_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>, Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>, Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if user_id.value() != profile_user_uuid { if user_id.value() != profile_user_uuid {
@@ -935,7 +950,7 @@ pub async fn get_movie_detail(
OptionalCookieUser(user_id): OptionalCookieUser, OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>, Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<crate::dtos::PaginationQueryParams>, Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await; let ctx = build_page_context(&state, user_id, csrf.0).await;
@@ -1215,7 +1230,7 @@ pub async fn post_profile_settings(
} }
} }
let cmd = update_profile::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
bio, bio,
avatar_bytes, avatar_bytes,

View File

@@ -4,7 +4,11 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Redirect}, response::{Html, IntoResponse, Redirect},
}; };
use serde::{Deserialize, Serialize}; use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use application::{ use application::{
@@ -465,13 +469,6 @@ pub async fn get_import_done(
// ── REST API handlers ────────────────────────────────────────────────────── // ── REST API handlers ──────────────────────────────────────────────────────
#[derive(Serialize, utoipa::ToSchema)]
pub struct SessionCreatedResponse {
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
}
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/import/sessions", post, path = "/api/v1/import/sessions",
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
@@ -544,14 +541,6 @@ pub async fn api_post_session(
} }
} }
#[derive(Serialize, utoipa::ToSchema)]
pub struct SessionStateResponse {
pub session_id: String,
pub columns: Vec<String>,
pub has_mappings: bool,
pub row_count: usize,
}
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/import/sessions/{id}", get, path = "/api/v1/import/sessions/{id}",
params(("id" = String, Path, description = "Import session UUID")), params(("id" = String, Path, description = "Import session UUID")),
@@ -607,23 +596,6 @@ pub async fn api_get_session(
} }
} }
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ApiFieldMapping {
/// Column name in the source file
pub source_column: String,
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
pub domain_field: String,
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
pub rating_scale: Option<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ApplyMappingRequest {
pub mappings: Vec<ApiFieldMapping>,
}
#[utoipa::path( #[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/mapping", put, path = "/api/v1/import/sessions/{id}/mapping",
params(("id" = String, Path, description = "Import session UUID")), params(("id" = String, Path, description = "Import session UUID")),
@@ -692,12 +664,6 @@ pub async fn api_put_mapping(
} }
} }
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ConfirmRequest {
/// Indices (0-based) of rows from the mapping preview to import
pub confirmed_indices: Vec<usize>,
}
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/import/sessions/{id}/confirm", post, path = "/api/v1/import/sessions/{id}/confirm",
params(("id" = String, Path, description = "Import session UUID")), params(("id" = String, Path, description = "Import session UUID")),
@@ -776,14 +742,6 @@ pub async fn api_get_profiles(
} }
} }
#[derive(Deserialize, utoipa::ToSchema)]
pub struct SaveProfileRequest {
/// Session UUID whose current field_mappings to save
pub session_id: String,
/// Human-readable profile name (e.g. "Letterboxd")
pub name: String,
}
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/import/profiles", post, path = "/api/v1/import/profiles",
request_body = SaveProfileRequest, request_body = SaveProfileRequest,

View File

@@ -1,5 +1,5 @@
pub mod csrf; pub mod csrf;
pub mod dtos; pub mod forms;
pub mod errors; pub mod errors;
pub mod extractors; pub mod extractors;
pub mod handlers; pub mod handlers;

View File

@@ -11,9 +11,7 @@ use importer::ImporterDocumentParser;
use rss::RssAdapter; use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
use doc::ApiDocExt; use presentation::{openapi, routes, state::AppState};
use presentation::{openapi::ApiDoc, routes, state::AppState};
use utoipa::OpenApi as _;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository}; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
@@ -29,7 +27,7 @@ async fn main() -> anyhow::Result<()> {
.await .await
.context("Failed to wire dependencies")?; .context("Failed to wire dependencies")?;
let app = routes::build_router(state, ap_router).with_api_doc(ApiDoc::openapi()); let app = openapi::serve(routes::build_router(state, ap_router));
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
@@ -51,17 +49,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let poster_fetcher = poster_fetcher::create()?; let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?; let image_storage = image_storage::create()?;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, db_pool) =
match backend.as_str() { match backend.as_str() {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?; let (pool, m, r, d, s, u, is, ip, mp) = postgres::wire(&database_url).await?;
(m, r, d, s, u, is, ip, DbPool::Postgres(pool)) (m, r, d, s, u, is, ip, mp, DbPool::Postgres(pool))
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
_ => { _ => {
let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?; let (pool, m, r, d, s, u, is, ip, mp) = sqlite::wire(&database_url).await?;
(m, r, d, s, u, is, ip, DbPool::Sqlite(pool)) (m, r, d, s, u, is, ip, mp, DbPool::Sqlite(pool))
} }
#[cfg(not(feature = "sqlite"))] #[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
@@ -163,6 +161,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
user_repository, user_repository,
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>, import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>, import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
movie_profile_repository,
config: app_config, config: app_config,
}; };
@@ -187,6 +186,7 @@ enum DbPool {
Postgres(sqlx::PgPool), Postgres(sqlx::PgPool),
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum EventBusBackend { enum EventBusBackend {
Db, Db,

View File

@@ -1,188 +0,0 @@
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use crate::dtos::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse,
SocialReviewDto, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::handlers::import::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
#[cfg(feature = "federation")]
use crate::dtos::{
ActorListResponse, ActorUrlRequest, BlockedActorResponse, BlockedDomainResponse,
AddBlockedDomainRequest, FollowRequest, RemoteActorDto,
};
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
#[cfg(not(feature = "federation"))]
#[derive(OpenApi)]
#[openapi(
info(
title = "Movies Diary API",
version = "1.0.0",
description = "REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token."
),
paths(
crate::handlers::api::get_diary,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::sync_poster,
crate::handlers::api::login,
crate::handlers::api::register,
crate::handlers::api::export_diary,
crate::handlers::api::get_activity_feed,
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
MovieDto,
ReviewDto,
LogReviewRequest,
LoginRequest,
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
ProfileResponse,
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
#[cfg(feature = "federation")]
#[derive(OpenApi)]
#[openapi(
info(
title = "Movies Diary API",
version = "1.0.0",
description = "REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token."
),
paths(
crate::handlers::api::get_diary,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::sync_poster,
crate::handlers::api::login,
crate::handlers::api::register,
crate::handlers::api::export_diary,
crate::handlers::api::get_activity_feed,
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::api::get_following,
crate::handlers::api::get_followers,
crate::handlers::api::get_pending_followers,
crate::handlers::api::follow,
crate::handlers::api::unfollow,
crate::handlers::api::accept_follower,
crate::handlers::api::reject_follower,
crate::handlers::api::remove_follower,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
crate::handlers::api::get_blocked_domains_admin,
crate::handlers::api::add_blocked_domain_admin,
crate::handlers::api::remove_blocked_domain_admin,
crate::handlers::api::block_actor_api,
crate::handlers::api::unblock_actor_api,
crate::handlers::api::get_blocked_actors_api,
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
MovieDto,
ReviewDto,
LogReviewRequest,
LoginRequest,
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActorListResponse,
RemoteActorDto,
FollowRequest,
ActorUrlRequest,
ProfileResponse,
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;

View File

@@ -0,0 +1,12 @@
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::login,
crate::handlers::api::register,
),
components(schemas(LoginRequest, LoginResponse, RegisterRequest)),
)]
pub struct AuthDoc;

View File

@@ -0,0 +1,22 @@
use api_types::{ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_diary,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::export_diary,
crate::handlers::api::get_activity_feed,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
ReviewDto,
LogReviewRequest,
ActivityFeedResponse,
FeedEntryDto,
)),
)]
pub struct DiaryDoc;

View File

@@ -0,0 +1,27 @@
use api_types::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
),
components(schemas(
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
)]
pub struct ImportDoc;

View File

@@ -0,0 +1,51 @@
mod auth;
mod diary;
mod import;
mod movies;
mod social;
mod users;
use axum::Router;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use utoipa_scalar::{Scalar, Servable};
use utoipa_swagger_ui::SwaggerUi;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
fn build() -> utoipa::openapi::OpenApi {
let mut api = auth::AuthDoc::openapi();
api.info = utoipa::openapi::InfoBuilder::new()
.title("Movies Diary API")
.version("1.0.0")
.description(Some("REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token."))
.build();
api.merge(diary::DiaryDoc::openapi());
api.merge(movies::MoviesDoc::openapi());
api.merge(users::UsersDoc::openapi());
api.merge(import::ImportDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);
api
}
pub fn serve(router: Router) -> Router {
tracing::info!("API docs at /docs (Swagger) and /scalar");
let spec = build();
router
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
.merge(Scalar::with_url("/scalar", spec))
}

View File

@@ -0,0 +1,37 @@
use api_types::{
CastMemberDto, CrewMemberDto, DirectorStatDto, GenreDto, KeywordDto, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserTrendsDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::list_movies,
crate::handlers::api::get_movie_detail,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_profile,
crate::handlers::api::sync_poster,
),
components(schemas(
MoviesResponse,
MovieDto,
MovieDetailResponse,
MovieStatsDto,
MovieProfileResponse,
GenreDto,
KeywordDto,
CastMemberDto,
CrewMemberDto,
ReviewHistoryResponse,
SocialFeedResponse,
SocialReviewDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
)),
)]
pub struct MoviesDoc;

View File

@@ -0,0 +1,38 @@
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
#[cfg(feature = "federation")]
use utoipa::OpenApi;
#[cfg(feature = "federation")]
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_following,
crate::handlers::api::get_followers,
crate::handlers::api::get_pending_followers,
crate::handlers::api::follow,
crate::handlers::api::unfollow,
crate::handlers::api::accept_follower,
crate::handlers::api::reject_follower,
crate::handlers::api::remove_follower,
crate::handlers::api::get_blocked_domains_admin,
crate::handlers::api::add_blocked_domain_admin,
crate::handlers::api::remove_blocked_domain_admin,
crate::handlers::api::block_actor_api,
crate::handlers::api::unblock_actor_api,
crate::handlers::api::get_blocked_actors_api,
),
components(schemas(
ActorListResponse,
RemoteActorDto,
FollowRequest,
ActorUrlRequest,
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
)),
)]
pub struct SocialDoc;

View File

@@ -0,0 +1,20 @@
use api_types::{ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
),
components(schemas(
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
ProfileResponse,
)),
)]
pub struct UsersDoc;

View File

@@ -60,6 +60,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
let base = Router::new() let base = Router::new()
.route("/", routing::get(handlers::html::get_activity_feed)) .route("/", routing::get(handlers::html::get_activity_feed))
.route("/users", routing::get(handlers::html::get_users_list)) .route("/users", routing::get(handlers::html::get_users_list))
.route("/u/{username}", routing::get(handlers::html::get_user_by_username))
.route( .route(
"/users/{id}", "/users/{id}",
routing::get(handlers::html::get_user_profile), routing::get(handlers::html::get_user_profile),
@@ -176,10 +177,15 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/movies/{id}/history", "/movies/{id}/history",
routing::get(handlers::api::get_review_history), routing::get(handlers::api::get_review_history),
) )
.route("/movies", routing::get(handlers::api::list_movies))
.route( .route(
"/movies/{id}", "/movies/{id}",
routing::get(handlers::api::get_movie_detail), routing::get(handlers::api::get_movie_detail),
) )
.route(
"/movies/{id}/profile",
routing::get(handlers::api::get_movie_profile),
)
.route("/reviews", routing::post(handlers::api::post_review)) .route("/reviews", routing::post(handlers::api::post_review))
.route( .route(
"/reviews/{id}", "/reviews/{id}",

View File

@@ -147,6 +147,14 @@ impl domain::ports::DocumentParser for PanicDocumentParser {
} }
struct PanicImportProfile; struct PanicImportProfile;
struct PanicMovieProfile;
#[async_trait]
impl domain::ports::MovieProfileRepository for PanicMovieProfile {
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
}
#[async_trait] #[async_trait]
impl domain::ports::ImportProfileRepository for PanicImportProfile { impl domain::ports::ImportProfileRepository for PanicImportProfile {
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() }
@@ -198,6 +206,7 @@ async fn test_app() -> Router {
user_repository: Arc::new(NobodyUserRepo), user_repository: Arc::new(NobodyUserRepo),
import_session_repository: Arc::new(PanicImportSession), import_session_repository: Arc::new(PanicImportSession),
import_profile_repository: Arc::new(PanicImportProfile), import_profile_repository: Arc::new(PanicImportProfile),
movie_profile_repository: Arc::new(PanicMovieProfile),
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,
base_url: "http://localhost:3000".to_string(), base_url: "http://localhost:3000".to_string(),

View File

@@ -24,6 +24,7 @@ apple-native-keyring-store = { version = "1.0.0", optional = true, features = [
zbus-secret-service-keyring-store = { version = "1.0.0", optional = true } zbus-secret-service-keyring-store = { version = "1.0.0", optional = true }
windows-native-keyring-store = { version = "1.0.0", optional = true } windows-native-keyring-store = { version = "1.0.0", optional = true }
api-types = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -32,5 +33,3 @@ anyhow = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -1,4 +1,4 @@
use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
use crate::config::Config; use crate::config::Config;
use uuid::Uuid; use uuid::Uuid;
@@ -995,7 +995,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::client::{DiaryEntryDto, MovieDto, ReviewDto}; use api_types::{DiaryEntryDto, MovieDto, ReviewDto};
use uuid::Uuid; use uuid::Uuid;
fn setup_app() -> App { fn setup_app() -> App {
@@ -1038,6 +1038,7 @@ mod tests {
title: "The Matrix".into(), title: "The Matrix".into(),
release_year: 1999, release_year: 1999,
director: None, director: None,
poster_path: None,
}, },
review: ReviewDto { review: ReviewDto {
id: Uuid::new_v4(), id: Uuid::new_v4(),

View File

@@ -1,92 +1,9 @@
use serde::{Deserialize, Serialize}; use api_types::{
ActorListResponse, ActorUrlRequest, DiaryResponse, FollowRequest, LogReviewRequest,
LoginRequest, LoginResponse, ReviewHistoryResponse,
};
use uuid::Uuid; use uuid::Uuid;
// ── DTOs (mirror backend dtos.rs exactly) ────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginResponse {
pub token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub external_metadata_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_release_year: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
pub trend: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FollowRequest {
pub handle: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActorUrlRequest {
pub actor_url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
// ── Error ───────────────────────────────────────────────────────────────────── // ── Error ─────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]

View File

@@ -17,23 +17,18 @@ domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
futures = { workspace = true }
dotenvy = { workspace = true } dotenvy = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
auth = { workspace = true } auth = { workspace = true }
metadata = { workspace = true } metadata = { workspace = true }
poster-fetcher = { workspace = true } poster-fetcher = { workspace = true }
image-storage = { workspace = true } image-storage = { workspace = true }
poster-sync = { workspace = true } poster-sync = { workspace = true }
export = { workspace = true } export = { workspace = true }
tmdb-enrichment = { workspace = true }
importer = { workspace = true } importer = { workspace = true }
image-converter = { workspace = true }
nats = { workspace = true, optional = true } nats = { workspace = true, optional = true }
sqlx = { workspace = true } sqlx = { workspace = true }

53
crates/worker/src/db.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository,
ImportSessionRepository, MovieProfileRepository, MovieRepository, ReviewRepository,
StatsRepository, UserRepository,
};
pub enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
pub struct Repos {
pub movie: Arc<dyn MovieRepository>,
pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryRepository>,
pub stats: Arc<dyn StatsRepository>,
pub user: Arc<dyn UserRepository>,
pub import_session: Arc<dyn ImportSessionRepository>,
pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
}
pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> {
match backend {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp) =
postgres::wire(database_url).await.context("PostgreSQL connection failed")?;
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
image_ref_command, image_ref_query }, DbPool::Postgres(pool)))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp) =
sqlite::wire(database_url).await.context("SQLite connection failed")?;
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
image_ref_command, image_ref_query }, DbPool::Sqlite(pool)))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"),
}
}

View File

@@ -0,0 +1,58 @@
use std::sync::Arc;
use anyhow::Context;
use domain::ports::{EventConsumer, EventPublisher};
use crate::db::DbPool;
#[derive(Clone, Copy)]
pub enum EventBusBackend {
Db,
#[cfg(feature = "nats")]
Nats,
}
impl EventBusBackend {
pub fn from_env() -> anyhow::Result<Self> {
match std::env::var("EVENT_BUS_BACKEND")
.unwrap_or_else(|_| "db".to_string())
.as_str()
{
"db" => Ok(Self::Db),
#[cfg(feature = "nats")]
"nats" => Ok(Self::Nats),
#[cfg(not(feature = "nats"))]
"nats" => anyhow::bail!(
"EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in"
),
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
}
}
}
pub async fn create(
db_pool: &DbPool,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
match EventBusBackend::from_env()? {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => {
Ok(postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?)
}
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => {
Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?)
}
}
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
Ok(nats::create_channel(cfg).await?)
}
}
}

View File

@@ -1,3 +1,6 @@
mod db;
mod event_bus;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
@@ -6,7 +9,7 @@ use export::ExportAdapter;
use importer::ImporterDocumentParser; use importer::ImporterDocumentParser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use domain::ports::{DiaryExporter, DocumentParser, EventHandler}; use domain::ports::{DiaryExporter, DocumentParser, EventHandler, PeriodicJob};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -25,95 +28,103 @@ async fn main() -> anyhow::Result<()> {
let poster_fetcher = poster_fetcher::create()?; let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?; let image_storage = image_storage::create()?;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = let (repos, db_pool) = db::connect(&database_url, &backend).await?;
match backend.as_str() { let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?;
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?;
(m, r, d, s, u, is, ip, DbPool::Postgres(pool))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?;
(m, r, d, s, u, is, ip, DbPool::Sqlite(pool))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"),
};
let (event_publisher_arc, consumer_arc): ( let image_ref_command = Arc::clone(&repos.image_ref_command);
Arc<dyn domain::ports::EventPublisher>, let image_ref_query = Arc::clone(&repos.image_ref_query);
Arc<dyn domain::ports::EventConsumer>,
) = match EventBusBackend::from_env()? {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?,
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?,
}
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
nats::create_channel(cfg).await?
}
};
// Clone what federation handler needs before ctx and app_config are consumed. // Clone refs federation handler needs before ctx consumes them.
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = ( let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = (
Arc::clone(&movie_repository), Arc::clone(&repos.movie),
Arc::clone(&review_repository), Arc::clone(&repos.review),
Arc::clone(&diary_repository), Arc::clone(&repos.diary),
Arc::clone(&user_repository), Arc::clone(&repos.user),
app_config.base_url.clone(), app_config.base_url.clone(),
app_config.allow_registration, app_config.allow_registration,
); );
let ctx = AppContext { let ctx = AppContext {
movie_repository, movie_repository: repos.movie,
review_repository, review_repository: repos.review,
diary_repository, diary_repository: repos.diary,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>, document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
stats_repository, stats_repository: repos.stats,
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,
image_storage, image_storage,
event_publisher: event_publisher_arc, event_publisher: event_publisher_arc,
auth_service, auth_service,
password_hasher, password_hasher,
user_repository, user_repository: repos.user,
import_session_repository, import_session_repository: repos.import_session,
import_profile_repository, import_profile_repository: repos.import_profile,
movie_profile_repository: repos.movie_profile,
config: app_config, config: app_config,
}; };
// Spawn periodic import session cleanup (hourly) // ── Enrichment ────────────────────────────────────────────────────────────
{ // Both the event handler and the staleness job are gated on TMDB_API_KEY.
let cleanup_ctx = ctx.clone(); // Without a key, no MovieEnrichmentRequested events are produced or handled.
let (enrichment_handler, enrichment_job): (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>) =
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
Ok(client) => {
tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
enrichment_client: Arc::new(client),
profile_repo: Arc::clone(&ctx.movie_profile_repository),
}) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>;
(Some(handler), Some(job))
}
Err(e) => {
tracing::warn!("TMDb enrichment disabled: {e}");
(None, None)
}
};
// ── Image conversion ──────────────────────────────────────────────────────
let conversion = image_converter::build(
Arc::clone(&ctx.image_storage),
image_ref_command,
image_ref_query,
Arc::clone(&ctx.event_publisher),
)?;
// ── Periodic jobs ─────────────────────────────────────────────────────────
let mut periodic_jobs: Vec<Arc<dyn PeriodicJob>> = vec![
Arc::new(application::jobs::ImportSessionCleanupJob::new(ctx.clone())),
];
if let Some(job) = enrichment_job { periodic_jobs.push(job); }
if let Some((_, ref conv_job)) = conversion { periodic_jobs.push(Arc::clone(conv_job)); }
for job in periodic_jobs {
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); let mut tick = tokio::time::interval(job.interval());
loop { loop {
interval.tick().await; tick.tick().await;
match application::use_cases::cleanup_expired_import_sessions::execute(&cleanup_ctx).await { if let Err(e) = job.run().await {
Ok(n) => tracing::info!("import session cleanup: removed {} expired sessions", n), tracing::error!("periodic job failed: {e}");
Err(e) => tracing::error!("import session cleanup failed: {:?}", e),
} }
} }
}); });
} }
// ── Event handlers ────────────────────────────────────────────────────────
let handlers: Vec<Arc<dyn EventHandler>> = { let handlers: Vec<Arc<dyn EventHandler>> = {
let poster = Arc::new(poster_sync::PosterSyncHandler::new( let poster = Arc::new(poster_sync::PosterSyncHandler::new(
Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.movie_repository),
Arc::clone(&ctx.metadata_client), Arc::clone(&ctx.metadata_client),
Arc::clone(&ctx.poster_fetcher), Arc::clone(&ctx.poster_fetcher),
Arc::clone(&ctx.image_storage), Arc::clone(&ctx.image_storage),
Arc::clone(&ctx.event_publisher),
3, 3,
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
@@ -122,15 +133,20 @@ async fn main() -> anyhow::Result<()> {
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
#[cfg(not(feature = "federation"))] #[cfg(not(feature = "federation"))]
{ vec![poster, cleanup] } {
let mut h = vec![poster, cleanup];
if let Some(e) = enrichment_handler { h.push(e); }
if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); }
h
}
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
{ {
let (federation_repo, _social_query, review_store) = match &db_pool { let (federation_repo, _social_query, review_store) = match &db_pool {
#[cfg(feature = "sqlite-federation")] #[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")] #[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
}; };
let ap = activitypub::wire( let ap = activitypub::wire(
@@ -145,12 +161,16 @@ async fn main() -> anyhow::Result<()> {
).await?.event_handler; ).await?.event_handler;
tracing::info!("federation event handler registered"); tracing::info!("federation event handler registered");
vec![poster, cleanup, ap] let mut h = vec![poster, cleanup, ap];
if let Some(e) = enrichment_handler { h.push(e); }
if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); }
h
} }
}; };
let worker = WorkerService::new(consumer_arc, handlers); // ── Run ───────────────────────────────────────────────────────────────────
let worker = WorkerService::new(consumer_arc, handlers);
tracing::info!("worker started"); tracing::info!("worker started");
worker.run().await; worker.run().await;
tracing::info!("worker stopped"); tracing::info!("worker stopped");
@@ -158,36 +178,6 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
#[derive(Clone, Copy)]
enum EventBusBackend {
Db,
#[cfg(feature = "nats")]
Nats,
}
impl EventBusBackend {
fn from_env() -> anyhow::Result<Self> {
match std::env::var("EVENT_BUS_BACKEND")
.unwrap_or_else(|_| "db".to_string())
.as_str()
{
"db" => Ok(Self::Db),
#[cfg(feature = "nats")]
"nats" => Ok(Self::Nats),
#[cfg(not(feature = "nats"))]
"nats" => anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in"),
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
}
}
}
fn init_tracing() { fn init_tracing() {
let filter = std::env::var("RUST_LOG") let filter = std::env::var("RUST_LOG")
.unwrap_or_else(|_| "worker=info,application=info".to_string()); .unwrap_or_else(|_| "worker=info,application=info".to_string());
@@ -196,4 +186,3 @@ fn init_tracing() {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
} }