Compare commits
9 Commits
2d6121239f
...
722b09e400
| Author | SHA1 | Date | |
|---|---|---|---|
| 722b09e400 | |||
| cea414fe60 | |||
| 696e3e170c | |||
| 4269eca582 | |||
| fb81aa10c1 | |||
| 78c2d9b1d3 | |||
| 38d13fbff1 | |||
| c696a3b780 | |||
| 99ce81efe5 |
@@ -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
559
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -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" }
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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 0–5 rating
|
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 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
|
||||||
doc — OpenAPI spec assembly and Swagger UI / Scalar serving
|
tui — terminal 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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
19
crates/adapters/image-converter/Cargo.toml
Normal file
19
crates/adapters/image-converter/Cargo.toml
Normal 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 }
|
||||||
140
crates/adapters/image-converter/src/backfill.rs
Normal file
140
crates/adapters/image-converter/src/backfill.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/adapters/image-converter/src/config.rs
Normal file
90
crates/adapters/image-converter/src/config.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
221
crates/adapters/image-converter/src/handler.rs
Normal file
221
crates/adapters/image-converter/src/handler.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
37
crates/adapters/image-converter/src/lib.rs
Normal file
37
crates/adapters/image-converter/src/lib.rs
Normal 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)))
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
54
crates/adapters/postgres/migrations/0014_movie_profiles.sql
Normal file
54
crates/adapters/postgres/migrations/0014_movie_profiles.sql
Normal 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);
|
||||||
52
crates/adapters/postgres/src/image_ref.rs
Normal file
52
crates/adapters/postgres/src/image_ref.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 _,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
236
crates/adapters/postgres/src/profile.rs
Normal file
236
crates/adapters/postgres/src/profile.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
54
crates/adapters/sqlite/migrations/0014_movie_profiles.sql
Normal file
54
crates/adapters/sqlite/migrations/0014_movie_profiles.sql
Normal 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);
|
||||||
159
crates/adapters/sqlite/src/image_ref.rs
Normal file
159
crates/adapters/sqlite/src/image_ref.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 _,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
240
crates/adapters/sqlite/src/profile.rs
Normal file
240
crates/adapters/sqlite/src/profile.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
14
crates/adapters/tmdb-enrichment/Cargo.toml
Normal file
14
crates/adapters/tmdb-enrichment/Cargo.toml
Normal 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 }
|
||||||
211
crates/adapters/tmdb-enrichment/src/lib.rs
Normal file
211
crates/adapters/tmdb-enrichment/src/lib.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/api-types/Cargo.toml
Normal file
9
crates/api-types/Cargo.toml
Normal 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"] }
|
||||||
23
crates/api-types/src/auth.rs
Normal file
23
crates/api-types/src/auth.rs
Normal 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,
|
||||||
|
}
|
||||||
7
crates/api-types/src/common.rs
Normal file
7
crates/api-types/src/common.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct PaginationQueryParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
78
crates/api-types/src/diary.rs
Normal file
78
crates/api-types/src/diary.rs
Normal 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()
|
||||||
|
}
|
||||||
47
crates/api-types/src/import.rs
Normal file
47
crates/api-types/src/import.rs
Normal 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,
|
||||||
|
}
|
||||||
15
crates/api-types/src/lib.rs
Normal file
15
crates/api-types/src/lib.rs
Normal 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::*;
|
||||||
129
crates/api-types/src/movies.rs
Normal file
129
crates/api-types/src/movies.rs
Normal 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,
|
||||||
|
}
|
||||||
44
crates/api-types/src/social.rs
Normal file
44
crates/api-types/src/social.rs
Normal 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>,
|
||||||
|
}
|
||||||
85
crates/api-types/src/users.rs
Normal file
85
crates/api-types/src/users.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
60
crates/application/src/jobs.rs
Normal file
60
crates/application/src/jobs.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
14
crates/application/src/use_cases/get_movies.rs
Normal file
14
crates/application/src/use_cases/get_movies.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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"] }
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
12
crates/presentation/src/openapi/auth.rs
Normal file
12
crates/presentation/src/openapi/auth.rs
Normal 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;
|
||||||
22
crates/presentation/src/openapi/diary.rs
Normal file
22
crates/presentation/src/openapi/diary.rs
Normal 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;
|
||||||
27
crates/presentation/src/openapi/import.rs
Normal file
27
crates/presentation/src/openapi/import.rs
Normal 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;
|
||||||
51
crates/presentation/src/openapi/mod.rs
Normal file
51
crates/presentation/src/openapi/mod.rs
Normal 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))
|
||||||
|
}
|
||||||
37
crates/presentation/src/openapi/movies.rs
Normal file
37
crates/presentation/src/openapi/movies.rs
Normal 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;
|
||||||
38
crates/presentation/src/openapi/social.rs
Normal file
38
crates/presentation/src/openapi/social.rs
Normal 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;
|
||||||
20
crates/presentation/src/openapi/users.rs
Normal file
20
crates/presentation/src/openapi/users.rs
Normal 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;
|
||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
53
crates/worker/src/db.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/worker/src/event_bus.rs
Normal file
58
crates/worker/src/event_bus.rs
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user