Compare commits
142 Commits
master
...
6cab2166a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cab2166a7 | |||
| 5bf750e0fc | |||
| 3d905fc39b | |||
| 35fffeef67 | |||
| d668d54cc3 | |||
| 0d32de993b | |||
| 8d78babda0 | |||
| cf99332dc4 | |||
| 0b1824d874 | |||
| e445a5eaf4 | |||
| a714c53a07 | |||
| ae7ff757d0 | |||
| 6323322d97 | |||
| fe55d694f9 | |||
| dda8b32e6a | |||
| d75db17ece | |||
| 642d7ef3d0 | |||
| 5df26b69a0 | |||
| c3af91ea7b | |||
| 4425c72f36 | |||
| e7e5d9935d | |||
| 6d2b415075 | |||
| 87cb36f9fe | |||
| bacf7b7252 | |||
| c4b6d52196 | |||
| ffb323dbbf | |||
| 0a18992a73 | |||
| b0ce316c30 | |||
| 0d3c2c937d | |||
| 86909ecede | |||
| 482575aed0 | |||
| 377ee1d176 | |||
| 9cda5b4681 | |||
| 74fb893751 | |||
| 32b114cecd | |||
| bd571b3b51 | |||
| f226071cbd | |||
| aaa9cb0a1e | |||
| 31dba4ca95 | |||
| 1b3f906312 | |||
| 465d233ae9 | |||
| 1f03889b30 | |||
| 4159e60148 | |||
| d66a89059d | |||
| b3c243257d | |||
| e1f2442e77 | |||
| 7a66661932 | |||
| b30a6a102b | |||
| 38a3aa6bbf | |||
| 3135a15cb3 | |||
| d083f8ae3d | |||
| 874c406d4a | |||
| 78e1f4ef72 | |||
| cf74b06b4a | |||
| 317898d51b | |||
| 790bb6fbb5 | |||
| 658df38788 | |||
| cff0f854fa | |||
| 66ade70273 | |||
| cbd2ac5b3e | |||
| 0433cd4d9b | |||
| b5a8ea2395 | |||
| 49b79799c1 | |||
| f4aba551a2 | |||
| 91df35dbd3 | |||
| 623f90e43f | |||
| e28f628c80 | |||
| 60c25d4c24 | |||
| 22aafe99be | |||
| 0ff22cca5f | |||
| ccc39e27e4 | |||
| 76319756f4 | |||
| 7703227970 | |||
| b9933bb48d | |||
| 0c48708ce6 | |||
| a2a889bced | |||
| a4846f3bea | |||
| 27be840faa | |||
| 965fc0eda8 | |||
| d700b85337 | |||
| ffbab75910 | |||
| dda7c40f7f | |||
| 1b827b1bdd | |||
| 1ee6873a60 | |||
| 7352b533ff | |||
| 85e254fee2 | |||
| fa8221322d | |||
| 38da37de55 | |||
| f3dedbad8a | |||
| d468ce131f | |||
| d034af9e9c | |||
| 59d308f41b | |||
| bbb2ee00d6 | |||
| 5dd9aac68d | |||
| 6dcc4c8317 | |||
| e31d99a240 | |||
| 41fec1efa5 | |||
| 160c08d1c4 | |||
| 7aa6d7bf4d | |||
| 144f2f8e0c | |||
| cff64f7a6b | |||
| 5baff54cb9 | |||
| f94d2db8b1 | |||
| 48875a6e86 | |||
| 9387ae705b | |||
| 9871e21bc0 | |||
| fa8efbaa23 | |||
| d769a5b55c | |||
| 8e1fb1a974 | |||
| 6145b873f5 | |||
| cc668ae44d | |||
| e5097c22dd | |||
| 450468ef3d | |||
| 6e7c6467a7 | |||
| 7f815f8207 | |||
| 5df89200d4 | |||
| eb273dc277 | |||
| 5689db0ad7 | |||
| 5c70b8b8be | |||
| 4c547df04e | |||
| 602df8df22 | |||
| 5b69a3a7c0 | |||
| a38f78d261 | |||
| 17f90726e8 | |||
| 563f33212e | |||
| 8e5ac9f433 | |||
| f790fa2a0f | |||
| edcf3c1170 | |||
| 1985d2c57f | |||
| f0b3d8ad90 | |||
| da72ab1446 | |||
| 93c65cd155 | |||
| ba42d3d445 | |||
| 819332522a | |||
| 79a06e6844 | |||
| 97a496553a | |||
| 5a58625265 | |||
| 6d9ac07dfc | |||
| b6a7cf9417 | |||
| c4b39c9410 | |||
| f60cc368b6 | |||
| 65bab7fd44 |
@@ -1,5 +1,2 @@
|
|||||||
[env]
|
[env]
|
||||||
SQLX_OFFLINE = "true"
|
SQLX_OFFLINE = "true"
|
||||||
|
|
||||||
[registries.gitea]
|
|
||||||
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
|
||||||
|
|||||||
@@ -7,7 +7,3 @@ target/
|
|||||||
# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
|
# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
|
||||||
docs/
|
docs/
|
||||||
dev.db
|
dev.db
|
||||||
spa/node_modules/
|
|
||||||
spa/dist/
|
|
||||||
spa/.env
|
|
||||||
spa/.env.local
|
|
||||||
|
|||||||
71
.env.example
71
.env.example
@@ -1,55 +1,42 @@
|
|||||||
# ── Required ──────────────────────────────────────────────────
|
# Database backend — "sqlite" (default) or "postgres"
|
||||||
|
DATABASE_BACKEND=sqlite
|
||||||
|
|
||||||
# Database (SQLite — file auto-created on first run)
|
# Option A: SQLite (default, zero external dependencies)
|
||||||
DATABASE_URL=sqlite:///data/movies.db
|
DATABASE_URL=sqlite://movies.db
|
||||||
|
|
||||||
|
# Option B: PostgreSQL
|
||||||
|
# DATABASE_BACKEND=postgres
|
||||||
|
# DATABASE_URL=postgres://user:password@localhost:5432/movies_diary
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
JWT_SECRET=change-me-to-a-random-string
|
JWT_SECRET=change-me
|
||||||
|
JWT_TTL_SECONDS=86400
|
||||||
|
|
||||||
# Movie metadata — one of these is required (TMDB preferred)
|
# OMDb/TMDB metadata
|
||||||
# TMDB_API_KEY=your-tmdb-key
|
OMDB_API_KEY=your-key
|
||||||
OMDB_API_KEY=your-omdb-key
|
TMDB_API_KEY=your-key
|
||||||
|
|
||||||
# ── Recommended ──────────────────────────────────────────────
|
# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B:
|
||||||
|
|
||||||
# Public URL (used for ActivityPub federation and canonical links)
|
# Option A: local filesystem (zero external dependencies)
|
||||||
BASE_URL=https://yourdomain.example.com
|
POSTER_STORAGE_BACKEND=local
|
||||||
|
POSTER_STORAGE_PATH=./posters
|
||||||
|
|
||||||
# Enable sign-ups (default: false — set true so you can register)
|
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||||
ALLOW_REGISTRATION=true
|
# POSTER_STORAGE_BACKEND=s3
|
||||||
|
|
||||||
# ── Image Storage (defaults to local filesystem) ─────────────
|
|
||||||
|
|
||||||
# IMAGE_STORAGE_BACKEND=local # default
|
|
||||||
# IMAGE_STORAGE_PATH=./images # default
|
|
||||||
|
|
||||||
# S3-compatible alternative (MinIO, AWS S3, etc.):
|
|
||||||
# IMAGE_STORAGE_BACKEND=s3
|
|
||||||
# MINIO_ENDPOINT=http://localhost:9000
|
# MINIO_ENDPOINT=http://localhost:9000
|
||||||
# MINIO_BUCKET=posters
|
# MINIO_BUCKET=posters
|
||||||
# MINIO_REGION=minio
|
# MINIO_REGION=minio
|
||||||
# MINIO_ACCESS_KEY_ID=minioadmin
|
# MINIO_ACCESS_KEY_ID=minioadmin
|
||||||
# MINIO_SECRET_ACCESS_KEY=minioadmin
|
# MINIO_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
|
||||||
# ── Optional ─────────────────────────────────────────────────
|
# Optional
|
||||||
|
HOST=0.0.0.0
|
||||||
# TMDb enrichment (cast, crew, genres — separate from search metadata above)
|
PORT=3000
|
||||||
# TMDB_API_KEY=your-tmdb-key
|
BASE_URL=http://localhost:3000
|
||||||
|
SECURE_COOKIES=false
|
||||||
# Image conversion (converts uploaded posters to AVIF or WebP)
|
ALLOW_REGISTRATION=false
|
||||||
# IMAGE_CONVERSION_ENABLED=false
|
RATE_LIMIT=20
|
||||||
# IMAGE_CONVERSION_FORMAT=avif
|
POSTER_FETCH_TIMEOUT_SECONDS=30
|
||||||
|
EVENT_CHANNEL_BUFFER=128
|
||||||
# Server
|
RUST_LOG=presentation=debug,tower_http=debug
|
||||||
# HOST=0.0.0.0
|
|
||||||
# PORT=3000
|
|
||||||
# RATE_LIMIT=60
|
|
||||||
# SECURE_COOKIES=true
|
|
||||||
# RUST_LOG=presentation=info,tower_http=info,worker=info
|
|
||||||
|
|
||||||
# CORS (for SPA development only)
|
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
|
||||||
|
|
||||||
# Event bus — "db" (default, uses same database) or "nats"
|
|
||||||
# EVENT_BUS_BACKEND=db
|
|
||||||
# NATS_URL=nats://localhost:4222
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
name: Check / Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- name: Cache cargo
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: ${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: fmt
|
|
||||||
run: cargo fmt --all -- --check
|
|
||||||
|
|
||||||
- name: clippy
|
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
run: cargo test
|
|
||||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -1,81 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
name: Check / Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- name: Cache cargo
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: ${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: fmt
|
|
||||||
run: cargo fmt --all -- --check
|
|
||||||
|
|
||||||
- name: clippy
|
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
run: cargo test
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Build & Push Docker Image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: ci
|
|
||||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
|
||||||
|
|
||||||
- name: Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha,prefix=
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: FEATURES=sqlite,sqlite-federation
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,7 +14,3 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/
|
docs/
|
||||||
!docs/adr/
|
|
||||||
|
|
||||||
imgs/
|
|
||||||
.sqlx/
|
|
||||||
98
.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json
generated
Normal file
98
.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
|
||||||
|
}
|
||||||
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM reviews",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
|
||||||
|
}
|
||||||
12
.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json
generated
Normal file
12
.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
|
||||||
|
}
|
||||||
98
.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json
generated
Normal file
98
.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9"
|
||||||
|
}
|
||||||
98
.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json
generated
Normal file
98
.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
|
||||||
|
}
|
||||||
38
.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json
generated
Normal file
38
.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json
generated
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_movies!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Float"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb"
|
||||||
|
}
|
||||||
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403"
|
||||||
|
}
|
||||||
44
.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json
generated
Normal file
44
.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4"
|
||||||
|
}
|
||||||
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1"
|
||||||
|
}
|
||||||
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f"
|
||||||
|
}
|
||||||
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal file
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "month",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75"
|
||||||
|
}
|
||||||
44
.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json
generated
Normal file
44
.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099"
|
||||||
|
}
|
||||||
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011"
|
||||||
|
}
|
||||||
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa"
|
||||||
|
}
|
||||||
62
.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json
generated
Normal file
62
.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357"
|
||||||
|
}
|
||||||
98
.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json
generated
Normal file
98
.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
|
||||||
|
}
|
||||||
26
.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json
generated
Normal file
26
.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "total!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Float"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
|
||||||
|
}
|
||||||
98
.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json
generated
Normal file
98
.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
|
||||||
|
}
|
||||||
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal file
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "director!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e"
|
||||||
|
}
|
||||||
62
.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json
generated
Normal file
62
.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
|
||||||
|
}
|
||||||
44
.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json
generated
Normal file
44
.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f"
|
||||||
|
}
|
||||||
12
.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json
generated
Normal file
12
.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a"
|
||||||
|
}
|
||||||
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal file
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2"
|
||||||
|
}
|
||||||
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM movies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
|
||||||
|
}
|
||||||
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM reviews WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
|
||||||
|
}
|
||||||
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal file
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "month!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating!: f64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317"
|
||||||
|
}
|
||||||
65
CONTEXT.md
65
CONTEXT.md
@@ -1,65 +0,0 @@
|
|||||||
# Movies Diary
|
|
||||||
|
|
||||||
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
|
|
||||||
|
|
||||||
## Language
|
|
||||||
|
|
||||||
**Movie**:
|
|
||||||
A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it.
|
|
||||||
_Avoid_: Film entry, title record
|
|
||||||
|
|
||||||
**Person**:
|
|
||||||
Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only.
|
|
||||||
_Avoid_: Celebrity, artist, talent
|
|
||||||
|
|
||||||
**Review**:
|
|
||||||
A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
|
|
||||||
_Avoid_: Diary entry, watch, log entry
|
|
||||||
|
|
||||||
**Rating**:
|
|
||||||
A 1–5 whole-star score given to a movie in a Review. No half-stars, no zero.
|
|
||||||
_Avoid_: Score, grade, stars (as a noun for the value itself)
|
|
||||||
|
|
||||||
**WatchMedium**:
|
|
||||||
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
|
|
||||||
_Avoid_: Source, format, venue, platform
|
|
||||||
|
|
||||||
**Watchlist**:
|
|
||||||
A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed.
|
|
||||||
_Avoid_: Queue, backlog, to-watch list
|
|
||||||
|
|
||||||
**Goal**:
|
|
||||||
A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future.
|
|
||||||
_Avoid_: Challenge, resolution, target
|
|
||||||
|
|
||||||
**WrapUp**:
|
|
||||||
A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review."
|
|
||||||
_Avoid_: Stats page, recap, summary
|
|
||||||
|
|
||||||
**User**:
|
|
||||||
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
|
|
||||||
_Avoid_: Account, member, profile (as a synonym for the whole User)
|
|
||||||
|
|
||||||
**SocialIdentity**:
|
|
||||||
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
|
|
||||||
_Avoid_: Actor, participant, social user
|
|
||||||
|
|
||||||
**Follow**:
|
|
||||||
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
|
|
||||||
_Avoid_: Subscribe, connect, friend
|
|
||||||
|
|
||||||
**Feed**:
|
|
||||||
A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app.
|
|
||||||
_Avoid_: Timeline, activity stream, home
|
|
||||||
|
|
||||||
**WatchEvent**:
|
|
||||||
An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it.
|
|
||||||
_Avoid_: Playback event, webhook event, auto-import
|
|
||||||
|
|
||||||
**Import**:
|
|
||||||
Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch.
|
|
||||||
_Avoid_: Upload, migration, sync
|
|
||||||
|
|
||||||
**ImportProfile**:
|
|
||||||
A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users.
|
|
||||||
_Avoid_: Template, mapping preset, import config
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
Thanks for your interest in Movies Diary! This is a personal project but contributions are welcome — bug fixes, new features, docs improvements, or picking up the deprecated TUI.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
1. Fork and clone the repo
|
|
||||||
2. Copy `.env.example` to `.env` and fill in at least `JWT_SECRET` and `OMDB_API_KEY`
|
|
||||||
3. Install Rust (stable, 2024 edition) and Bun (for the SPA)
|
|
||||||
4. Run the backend and worker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p presentation # HTTP server on :3000
|
|
||||||
cargo run -p worker # event worker (separate terminal)
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Run the SPA dev server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd spa && bun install && bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Before submitting a PR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make # runs fmt-check + clippy + test
|
|
||||||
```
|
|
||||||
|
|
||||||
Or individually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo fmt --check
|
|
||||||
cargo clippy -- -D warnings
|
|
||||||
cargo test
|
|
||||||
cd spa && bunx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
All four must pass. PRs with clippy warnings or failing tests won't be merged.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram.
|
|
||||||
|
|
||||||
**Key rules:**
|
|
||||||
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
|
|
||||||
- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`)
|
|
||||||
- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc<dyn Trait>` params to keep adapter dependencies explicit
|
|
||||||
|
|
||||||
```
|
|
||||||
domain → pure types, traits (ports), zero deps
|
|
||||||
application → use cases, orchestration
|
|
||||||
presentation → Axum handlers, routes, OpenAPI
|
|
||||||
worker → event consumer, background jobs
|
|
||||||
adapters/* → implements domain ports (sqlite, postgres, AP, etc.)
|
|
||||||
spa/ → React SPA (TanStack Router + shadcn/ui)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a new feature
|
|
||||||
|
|
||||||
1. **Domain first** — models in `domain/src/models/`, ports in `ports.rs`, events in `events.rs`
|
|
||||||
2. **Adapters** — implement ports in both `sqlite` and `postgres` adapters, add migration
|
|
||||||
3. **Application** — use cases in `application/src/<domain>/`, wire into `context.rs`
|
|
||||||
4. **API types** — DTOs in `api-types/src/`
|
|
||||||
5. **Presentation** — handler file in `handlers/<domain>.rs`, routes in `routes.rs`
|
|
||||||
6. **SPA** — API client in `spa/src/lib/api/`, hook in `spa/src/hooks/`, components
|
|
||||||
7. **Classic HTML** — Askama template + CSS in `static/style.css`
|
|
||||||
|
|
||||||
### Database adapters
|
|
||||||
|
|
||||||
Both SQLite and PostgreSQL are supported. If you add a migration or repository, implement it for both. The postgres adapter uses `$1, $2` params and `TIMESTAMPTZ`; SQLite uses `?` and text datetimes.
|
|
||||||
|
|
||||||
### Federation (ActivityPub)
|
|
||||||
|
|
||||||
Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `activitypub/src/event_handler.rs`, and create an AP object + inbound handler.
|
|
||||||
|
|
||||||
## Code style
|
|
||||||
|
|
||||||
- No comments unless the *why* is non-obvious
|
|
||||||
- Concise commit messages
|
|
||||||
- One feature per PR — don't bundle unrelated changes
|
|
||||||
- Follow existing patterns (check a similar feature for reference)
|
|
||||||
|
|
||||||
## Areas seeking help
|
|
||||||
|
|
||||||
- **TUI** (`crates/tui`) — deprecated, needs a maintainer to bring it up to feature parity
|
|
||||||
- **Tests** — the domain and application crates have 400+ unit tests; integration tests for the presentation layer are welcome
|
|
||||||
- **Docs** — API usage examples, deployment guides
|
|
||||||
1582
Cargo.lock
generated
1582
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
57
Cargo.toml
57
Cargo.toml
@@ -4,43 +4,26 @@ members = [
|
|||||||
"crates/adapters/event-publisher",
|
"crates/adapters/event-publisher",
|
||||||
"crates/adapters/metadata",
|
"crates/adapters/metadata",
|
||||||
"crates/adapters/poster-fetcher",
|
"crates/adapters/poster-fetcher",
|
||||||
"crates/adapters/object-storage",
|
"crates/adapters/poster-storage",
|
||||||
"crates/adapters/poster-sync",
|
|
||||||
"crates/adapters/rss",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/sqlite-federation",
|
"crates/adapters/sqlite-federation",
|
||||||
"crates/adapters/postgres-federation",
|
"crates/adapters/postgres-federation",
|
||||||
"crates/adapters/sqlite-event-queue",
|
|
||||||
"crates/adapters/postgres-event-queue",
|
|
||||||
"crates/adapters/template-askama",
|
"crates/adapters/template-askama",
|
||||||
"crates/adapters/activitypub",
|
"crates/adapters/activitypub",
|
||||||
|
"crates/adapters/activitypub-base",
|
||||||
"crates/adapters/export",
|
"crates/adapters/export",
|
||||||
"crates/adapters/event-payload",
|
|
||||||
"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/worker",
|
"crates/doc",
|
||||||
"crates/adapters/importer",
|
|
||||||
"crates/adapters/jellyfin",
|
|
||||||
"crates/adapters/plex",
|
|
||||||
"crates/adapters/sqlite-search",
|
|
||||||
"crates/adapters/postgres-search",
|
|
||||||
"crates/adapters/adapter-common",
|
|
||||||
"crates/infra-wiring",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
bytes = "1"
|
|
||||||
futures = "0.3"
|
|
||||||
async-stream = "0.3"
|
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -55,25 +38,19 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"runtime-tokio-rustls",
|
"runtime-tokio-rustls",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"macros",
|
||||||
] }
|
] }
|
||||||
rand = "0.9"
|
|
||||||
reqwest = { version = "0.13", features = ["json", "query"] }
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
sha2 = "0.10"
|
|
||||||
hex = "0.4"
|
|
||||||
object_store = { version = "0.11", features = ["aws"] }
|
object_store = { version = "0.11", features = ["aws"] }
|
||||||
axum = { version = "0.8.8", features = ["macros", "multipart"] }
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
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" }
|
||||||
metadata = { path = "crates/adapters/metadata" }
|
metadata = { path = "crates/adapters/metadata" }
|
||||||
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
||||||
object-storage = { path = "crates/adapters/object-storage" }
|
poster-storage = { path = "crates/adapters/poster-storage" }
|
||||||
poster-sync = { path = "crates/adapters/poster-sync" }
|
|
||||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||||
rss = { path = "crates/adapters/rss" }
|
rss = { path = "crates/adapters/rss" }
|
||||||
export = { path = "crates/adapters/export" }
|
export = { path = "crates/adapters/export" }
|
||||||
@@ -83,21 +60,5 @@ postgres = { path = "crates/adapters/postgres" }
|
|||||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
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" }
|
||||||
event-payload = { path = "crates/adapters/event-payload" }
|
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
||||||
nats = { path = "crates/adapters/nats" }
|
doc = { path = "crates/doc" }
|
||||||
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
|
|
||||||
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
|
|
||||||
importer = { path = "crates/adapters/importer" }
|
|
||||||
jellyfin = { path = "crates/adapters/jellyfin" }
|
|
||||||
plex = { path = "crates/adapters/plex" }
|
|
||||||
image-converter = { path = "crates/adapters/image-converter" }
|
|
||||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
|
||||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
|
||||||
adapter-common = { path = "crates/adapters/adapter-common" }
|
|
||||||
infra-wiring = { path = "crates/infra-wiring" }
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
debug = 1 # line tables only — still debuggable, much faster linking
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
|
||||||
|
|||||||
54
Dockerfile
54
Dockerfile
@@ -1,11 +1,3 @@
|
|||||||
# ----- spa -----
|
|
||||||
FROM node:22-slim AS spa-builder
|
|
||||||
WORKDIR /spa
|
|
||||||
COPY spa/package.json spa/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY spa/ .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ----- build -----
|
# ----- build -----
|
||||||
FROM rust:slim-bookworm AS builder
|
FROM rust:slim-bookworm AS builder
|
||||||
|
|
||||||
@@ -14,81 +6,55 @@ WORKDIR /build
|
|||||||
# Cache dependency compilation separately from source
|
# Cache dependency compilation separately from source
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY .cargo ./.cargo
|
COPY .cargo ./.cargo
|
||||||
|
COPY .sqlx ./.sqlx
|
||||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||||
|
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
|
||||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||||
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
|
||||||
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
|
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
|
||||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
|
||||||
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
|
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
|
||||||
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
|
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
|
||||||
COPY crates/adapters/object-storage/Cargo.toml crates/adapters/object-storage/Cargo.toml
|
COPY crates/adapters/poster-storage/Cargo.toml crates/adapters/poster-storage/Cargo.toml
|
||||||
COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml
|
|
||||||
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
|
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
|
||||||
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml
|
|
||||||
COPY crates/adapters/jellyfin/Cargo.toml crates/adapters/jellyfin/Cargo.toml
|
|
||||||
COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.toml
|
|
||||||
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
||||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||||
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
|
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
|
||||||
COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml
|
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
|
||||||
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/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/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
|
|
||||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
|
||||||
COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml
|
|
||||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
|
||||||
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/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)
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
|
|
||||||
# All sqlx queries use the runtime API (no query! macros), so no database
|
# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache.
|
||||||
# or .sqlx cache is needed at compile time.
|
# No live database needed at compile time.
|
||||||
#
|
#
|
||||||
# To build with PostgreSQL backend instead:
|
# To build with PostgreSQL backend instead:
|
||||||
# --build-arg FEATURES=postgres,postgres-federation
|
# --build-arg FEATURES=postgres,postgres-federation
|
||||||
# To add NATS support (EVENT_BUS_BACKEND=nats):
|
|
||||||
# --build-arg FEATURES=sqlite,sqlite-federation,nats
|
|
||||||
ARG FEATURES=sqlite,sqlite-federation
|
ARG FEATURES=sqlite,sqlite-federation
|
||||||
RUN cargo build --release -p presentation -p worker --no-default-features --features "${FEATURES}"
|
RUN cargo build --release -p presentation --no-default-features --features "${FEATURES}"
|
||||||
|
|
||||||
# ----- runtime -----
|
# ----- runtime -----
|
||||||
FROM debian:bookworm-slim
|
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 \
|
|
||||||
libwebp7 \
|
|
||||||
fonts-dejavu-core \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /build/target/release/presentation ./presentation
|
COPY --from=builder /build/target/release/presentation ./presentation
|
||||||
COPY --from=builder /build/target/release/worker ./worker
|
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
COPY --from=spa-builder /spa/dist ./spa/dist
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
37
Makefile
37
Makefile
@@ -1,37 +0,0 @@
|
|||||||
.DEFAULT_GOAL := check
|
|
||||||
|
|
||||||
# Run the full local check suite — same order as CI would.
|
|
||||||
check: fmt-check clippy test check-appcontext
|
|
||||||
@echo "✅ All checks passed"
|
|
||||||
|
|
||||||
# Enforce that no application use case imports AppContext (god-object guard).
|
|
||||||
check-appcontext:
|
|
||||||
@if grep -rn "AppContext" crates/application/src --include="*.rs" | grep -q .; then \
|
|
||||||
echo "❌ AppContext found in application crate:"; \
|
|
||||||
grep -rn "AppContext" crates/application/src --include="*.rs"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "✅ No AppContext in application crate"
|
|
||||||
|
|
||||||
# Apply rustfmt to all files.
|
|
||||||
fmt:
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# Check formatting without modifying files (CI-safe).
|
|
||||||
fmt-check:
|
|
||||||
cargo fmt --check
|
|
||||||
|
|
||||||
# Run Clippy and treat warnings as errors.
|
|
||||||
clippy:
|
|
||||||
cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
# Run the test suite.
|
|
||||||
test:
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Apply fmt + clippy auto-fixes in one shot.
|
|
||||||
fix:
|
|
||||||
cargo fmt
|
|
||||||
cargo clippy --fix --allow-dirty --allow-staged
|
|
||||||
|
|
||||||
.PHONY: check fmt fmt-check clippy test fix
|
|
||||||
308
README.md
308
README.md
@@ -1,130 +1,44 @@
|
|||||||
# Movies Diary
|
# Movies Diary
|
||||||
|
|
||||||
A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite or PostgreSQL.
|
A self-hosted, server-side rendered movie logging system with a full REST API. Built in Rust — no JavaScript in the HTML interface, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site or as a backend for third-party clients.
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://www.rust-lang.org/)
|
|
||||||
[](https://hub.docker.com/)
|
|
||||||
[](https://activitypub.rocks/)
|
|
||||||
[](https://www.sqlite.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Quick Start](#quick-start)
|
|
||||||
- [Features](#features)
|
|
||||||
- [Screenshots](#screenshots)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Prerequisites](#prerequisites)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Run](#run)
|
|
||||||
- [API](#api)
|
|
||||||
- [SPA](#spa)
|
|
||||||
- [Development](#development)
|
|
||||||
- [Test](#test)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [Media Server Integration](#media-server-integration)
|
|
||||||
- [Annual Wrap-Up](#annual-wrap-up)
|
|
||||||
- [Contributing](#contributing)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
The fastest way to run Movies Diary is via Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY) in .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:3000`. The HTTP server and background worker start together; data is persisted in a Docker volume.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
||||||
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
- 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`
|
|
||||||
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
|
|
||||||
- People as first-class entities — browse by person via `GET /api/v1/people/{id}` and full credit history via `GET /api/v1/people/{id}/credits`; index populated automatically during TMDb enrichment
|
|
||||||
- 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 movie poster image attachment, `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox (reviews, watchlist entries, goals), boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable); account migration via `Move` activity; account deletion broadcasts `Delete` actor to followers
|
- ActivityPub federation — follow/unfollow remote users on any compatible server, accept/reject/remove followers, pending follow request management
|
||||||
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` / `Undo Block` activities, delivery filter excludes blocked actors and blocked-domain inboxes
|
- CSV and JSON diary export (full round-trip: exported files can be re-imported via the TUI bulk import)
|
||||||
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
|
|
||||||
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
|
|
||||||
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
|
|
||||||
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
|
|
||||||
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
|
|
||||||
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
|
|
||||||
- CSV and JSON diary export
|
|
||||||
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
|
||||||
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
||||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||||
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
|
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
|
||||||
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
|
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
|
||||||
- Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
|
- Terminal UI client (`crates/tui`) for logging reviews, bulk CSV import, and diary browsing
|
||||||
- Terminal UI client (`crates/tui`, deprecated) for logging reviews, bulk CSV import, and diary browsing
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
> SPA at `/app/` — React + TanStack Router + shadcn/ui
|
|
||||||
|
|
||||||
| Feed | Movie | Person |
|
|
||||||
|------|-------|--------|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| Profile | Wrap-Up | Wrap-Up card |
|
|
||||||
|---------|---------|--------------|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
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) + HtmlPageContext; used by presentation, tui, and template adapters
|
domain — pure types and trait definitions, no external deps
|
||||||
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
|
application — use cases / business logic orchestration
|
||||||
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
|
presentation — Axum HTTP router, wires all adapters together
|
||||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
|
|
||||||
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)
|
|
||||||
adapters/
|
adapters/
|
||||||
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
|
auth — JWT issuance and validation (Argon2 passwords)
|
||||||
auth — JWT issuance and validation (Argon2 passwords)
|
sqlite — SQLite repository via sqlx
|
||||||
sqlite — SQLite repository + connection factory
|
metadata — OMDb HTTP client
|
||||||
postgres — PostgreSQL repository + connection factory
|
poster-fetcher — downloads poster images
|
||||||
metadata — TMDB / OMDb HTTP client
|
poster-storage — uploads posters to local filesystem or S3-compatible storage
|
||||||
poster-fetcher — downloads poster images
|
template-askama — Askama HTML rendering
|
||||||
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
rss — RSS/Atom feed generation
|
||||||
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
|
export — CSV and JSON diary serialization
|
||||||
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
|
event-publisher — async event channel for background poster sync
|
||||||
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
|
activitypub — ActivityPub federation (follow, inbox/outbox, actor)
|
||||||
template-askama — Askama HTML rendering
|
activitypub-base — core ActivityPub types and repository traits
|
||||||
rss — RSS/Atom feed generation
|
doc — OpenAPI spec assembly and Swagger UI / Scalar serving
|
||||||
export — CSV and JSON diary serialization
|
tui — terminal UI client (ratatui)
|
||||||
importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import
|
|
||||||
jellyfin — Jellyfin webhook payload parser (MediaServerParser adapter)
|
|
||||||
plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass)
|
|
||||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
|
||||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
|
||||||
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
|
||||||
event-publisher — in-memory event channel (used in tests)
|
|
||||||
nats — NATS Core / JetStream event publisher and consumer
|
|
||||||
event-publisher — in-memory event channel (used in tests)
|
|
||||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
|
||||||
sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand
|
|
||||||
postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand
|
|
||||||
sqlite-federation — SQLite-backed federation repository
|
|
||||||
postgres-federation — PostgreSQL-backed federation repository
|
|
||||||
tui — terminal UI client (ratatui); shares api-types with presentation for typed API access
|
|
||||||
spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -134,50 +48,53 @@ spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served a
|
|||||||
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
|
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
|
||||||
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
|
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
|
||||||
|
|
||||||
## Configuration
|
## Environment Variables
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
|
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values.
|
||||||
|
|
||||||
| Variable | Default | Required | Description |
|
```env
|
||||||
|---|---|---|---|
|
# Database
|
||||||
| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string |
|
DATABASE_URL=sqlite://movies.db
|
||||||
| `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string |
|
|
||||||
| `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata |
|
|
||||||
| `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment |
|
|
||||||
| `BASE_URL` | — | Yes | Public URL of your instance (used for ActivityPub actor URLs) |
|
|
||||||
| `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` |
|
|
||||||
| `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage |
|
|
||||||
| `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) |
|
|
||||||
| `MINIO_BUCKET` | — | S3 only | Bucket name |
|
|
||||||
| `MINIO_REGION` | — | S3 only | Region (e.g. `minio`) |
|
|
||||||
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
|
|
||||||
| `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
|
|
||||||
| `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP |
|
|
||||||
| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` |
|
|
||||||
| `HOST` | `0.0.0.0` | No | Bind address |
|
|
||||||
| `PORT` | `3000` | No | HTTP port |
|
|
||||||
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
|
|
||||||
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
|
|
||||||
| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
|
|
||||||
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
|
|
||||||
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
|
|
||||||
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
|
|
||||||
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
|
|
||||||
|
|
||||||
The `worker` binary must run alongside `presentation` to process events:
|
# Authentication
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
|
||||||
```bash
|
# OMDb metadata
|
||||||
cargo run -p worker
|
OMDB_API_KEY=your-key
|
||||||
|
|
||||||
|
# Public base URL (used for ActivityPub actor URLs and canonical links)
|
||||||
|
BASE_URL=https://yourdomain.example.com
|
||||||
|
|
||||||
|
# Poster storage — pick one backend:
|
||||||
|
|
||||||
|
# Option A: local filesystem (zero deps)
|
||||||
|
POSTER_STORAGE_BACKEND=local
|
||||||
|
POSTER_STORAGE_PATH=./posters
|
||||||
|
|
||||||
|
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||||
|
# POSTER_STORAGE_BACKEND=s3
|
||||||
|
# MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
# MINIO_BUCKET=posters
|
||||||
|
# MINIO_REGION=minio
|
||||||
|
# MINIO_ACCESS_KEY_ID=minioadmin
|
||||||
|
# MINIO_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
RATE_LIMIT=60 # requests per minute per IP (default: 60)
|
||||||
|
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
|
||||||
|
SECURE_COOKIES=true # set when serving over HTTPS
|
||||||
|
RUST_LOG=presentation=info,tower_http=info
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p presentation # HTTP server (0.0.0.0:3000)
|
cargo run -p presentation
|
||||||
cargo run -p worker # event worker (poster sync, in a separate terminal)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The worker polls the event queue and must run alongside the presentation to process background tasks like poster fetching. Both processes share the same database.
|
Server listens on `0.0.0.0:3000` by default.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -188,122 +105,33 @@ Interactive API documentation is available at runtime:
|
|||||||
- **Swagger UI** — `http://localhost:3000/docs`
|
- **Swagger UI** — `http://localhost:3000/docs`
|
||||||
- **Scalar** — `http://localhost:3000/scalar`
|
- **Scalar** — `http://localhost:3000/scalar`
|
||||||
|
|
||||||
An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go.
|
## Terminal UI
|
||||||
|
|
||||||
## SPA
|
|
||||||
|
|
||||||
The single-page app lives in `spa/` and is served at `/app/` by the backend. For local development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd spa && bun install && bun run dev # http://localhost:5173/app/
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `CORS_ORIGINS=http://localhost:5173` in the backend `.env` to allow cross-origin API calls during development.
|
|
||||||
|
|
||||||
For production, `bun run build` outputs to `spa/dist/` which the backend serves statically (included in Docker image automatically).
|
|
||||||
|
|
||||||
## Terminal UI (deprecated)
|
|
||||||
|
|
||||||
> **Note:** The TUI was an experiment with ratatui and is no longer actively maintained. It may not support newer features (goals, watchlist, federation, etc.). Contributions welcome — if you'd like to maintain it, open a PR.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p tui
|
cargo run -p tui
|
||||||
```
|
```
|
||||||
|
|
||||||
Supports review logging, bulk CSV import, and diary browsing.
|
Supports review logging, bulk CSV import (column order matches the export format), and diary browsing with review history.
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
A `Makefile` wraps the most common dev commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make # default: fmt-check + clippy + test (same order as CI)
|
|
||||||
make fix # auto-apply fmt + clippy fixes
|
|
||||||
make fmt # apply rustfmt
|
|
||||||
make clippy # clippy with -D warnings
|
|
||||||
make test # cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
|
cargo test
|
||||||
cargo test -p application # business logic tests only — no database required
|
|
||||||
cargo test -p domain # domain model + value object tests
|
|
||||||
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
### Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env — set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY)
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds and starts the HTTP server (port 3000) and event worker. Data is persisted in a Docker volume.
|
|
||||||
|
|
||||||
### Manual docker run
|
|
||||||
|
|
||||||
The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t movies-diary .
|
docker build -t movies-diary .
|
||||||
|
docker run -p 3000:3000 \
|
||||||
docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary
|
-e DATABASE_URL=sqlite:///data/movies.db \
|
||||||
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
|
-e JWT_SECRET=change-me \
|
||||||
|
-e OMDB_API_KEY=your-key \
|
||||||
|
-e BASE_URL=https://yourdomain.example.com \
|
||||||
|
-v $(pwd)/data:/data \
|
||||||
|
movies-diary
|
||||||
```
|
```
|
||||||
|
|
||||||
Build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation`
|
|
||||||
|
|
||||||
## Media Server Integration
|
|
||||||
|
|
||||||
Auto-log movies you finish watching. Go to `/settings/integrations` to generate a webhook token, then configure your media server.
|
|
||||||
|
|
||||||
### Jellyfin
|
|
||||||
|
|
||||||
1. Install the **Webhook** plugin (Dashboard > Plugins > Catalog)
|
|
||||||
2. Add a **Generic** destination:
|
|
||||||
- **URL**: `https://yourdomain.example.com/api/v1/webhooks/jellyfin`
|
|
||||||
- **Header**: `Authorization` = `Bearer <your-token>`
|
|
||||||
- **Send All Properties**: enabled
|
|
||||||
- **Notification Type**: Playback Stop only
|
|
||||||
- **Item Type**: Movies only
|
|
||||||
|
|
||||||
### Plex (requires Plex Pass)
|
|
||||||
|
|
||||||
1. Go to Settings > Webhooks in your Plex server
|
|
||||||
2. Add webhook URL: `https://yourdomain.example.com/api/v1/webhooks/plex`
|
|
||||||
3. Plex does not support custom headers natively — pass the token as a query param: `https://yourdomain.example.com/api/v1/webhooks/plex?token=<your-token>`
|
|
||||||
|
|
||||||
Movies you finish watching appear in your watch queue at `/watch-queue` — rate and confirm to add to your diary.
|
|
||||||
|
|
||||||
## Annual Wrap-Up
|
|
||||||
|
|
||||||
Generate a year-in-review summary for any user — top directors, actors, genres, rating distribution, total watch time, rewatch stats, and more. Available as a shareable HTML page.
|
|
||||||
|
|
||||||
**Generate via API** (admin only):
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/v1/wrapups/generate \
|
|
||||||
-H "Authorization: Bearer <admin-token>" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "<uuid>", "start_date": "2025-01-01", "end_date": "2026-01-01"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit `user_id` for a global instance wrap-up. The worker computes stats in the background — poll `GET /api/v1/wrapups/{id}` for status.
|
|
||||||
|
|
||||||
**View:** `http://localhost:3000/wrapups/{user_id}/2025` (public, no login required)
|
|
||||||
|
|
||||||
**Auto-generate:** The worker runs a daily job in January that generates wrap-ups for all users with reviews in the previous year.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, architecture overview, and PR guidelines.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License. See [LICENSE](LICENSE).
|
MIT License. See [LICENSE](LICENSE).
|
||||||
|
|||||||
153
architecture.mmd
153
architecture.mmd
@@ -1,153 +0,0 @@
|
|||||||
---
|
|
||||||
title: Movies Diary — Hexagonal Architecture
|
|
||||||
---
|
|
||||||
graph TB
|
|
||||||
subgraph Binaries["Binaries (Composition Root)"]
|
|
||||||
WEB["presentation<br/><i>Axum web server</i><br/>Routes, Handlers, Mappers"]
|
|
||||||
WORKER["worker<br/><i>Event consumer</i><br/>Concurrent dispatch, graceful shutdown"]
|
|
||||||
TUI["tui<br/><i>Terminal UI</i>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Application["Application Layer"]
|
|
||||||
direction TB
|
|
||||||
CTX["AppContext<br/><i>Repositories + Services</i>"]
|
|
||||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
|
||||||
subgraph UseCases["Use Cases"]
|
|
||||||
UC_AUTH["auth<br/>login, register"]
|
|
||||||
UC_DIARY["diary<br/>log_review, edit_review,<br/>get_diary, get_activity_feed,<br/>export"]
|
|
||||||
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
|
|
||||||
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
|
|
||||||
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
|
|
||||||
UC_WATCHLIST["watchlist<br/>add, remove, get"]
|
|
||||||
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
|
|
||||||
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
|
|
||||||
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
|
||||||
UC_SEARCH["search<br/>execute"]
|
|
||||||
UC_PERSON["person<br/>get, get_credits"]
|
|
||||||
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
|
|
||||||
end
|
|
||||||
subgraph EventHandlers["Event Handlers"]
|
|
||||||
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
|
|
||||||
EH_PERSON["PersonEnrichmentHandler<br/><i>on PersonEnrichmentRequested</i>"]
|
|
||||||
EH_DISCOVER["MovieDiscoveryIndexer"]
|
|
||||||
EH_CLEANUP["SearchCleanupHandler"]
|
|
||||||
EH_REINDEX["SearchReindexHandler"]
|
|
||||||
EH_WRAPUP["WrapUpEventHandler"]
|
|
||||||
EH_AP["ActivityPubEventHandler<br/><i>reviews, watchlist, goals,<br/>UserDeleted, UserAccountMoved</i>"]
|
|
||||||
end
|
|
||||||
subgraph Jobs["Periodic Jobs"]
|
|
||||||
JOB_IMPORT["ImportSessionCleanup"]
|
|
||||||
JOB_WATCH["WatchEventCleanup"]
|
|
||||||
JOB_STALE["EnrichmentStaleness"]
|
|
||||||
JOB_WRAPGEN["WrapUpAutoGenerate"]
|
|
||||||
JOB_WRAPCLEAN["WrapUpCleanup"]
|
|
||||||
JOB_SESSION["RefreshSessionCleanup"]
|
|
||||||
JOB_DEDUP["MovieDeduplication<br/><i>merge duplicate movie<br/>records (daily)</i>"]
|
|
||||||
end
|
|
||||||
WORKER_SVC["WorkerService<br/><i>Semaphore(8), JoinSet,<br/>shutdown signal</i>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Domain["Domain Layer (0 dependencies)"]
|
|
||||||
direction TB
|
|
||||||
subgraph Models["Models"]
|
|
||||||
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
|
||||||
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
|
|
||||||
M_USER["User, UserSummary"]
|
|
||||||
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
|
||||||
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
|
||||||
M_GOAL["Goal, GoalWithProgress,<br/>UserSettings, RemoteGoalEntry"]
|
|
||||||
M_WRAPUP["WrapUpReport,<br/>MovieRef, PersonStat"]
|
|
||||||
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
|
||||||
end
|
|
||||||
subgraph Ports["Port Traits (Interfaces)"]
|
|
||||||
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
|
|
||||||
P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
|
|
||||||
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
|
|
||||||
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
|
||||||
end
|
|
||||||
subgraph DomainServices["Services (pure, no I/O)"]
|
|
||||||
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
|
||||||
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
|
||||||
end
|
|
||||||
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
|
|
||||||
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph ApiTypes["api-types"]
|
|
||||||
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>HtmlPageContext, ...</i>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph InfraWiring["infra-wiring"]
|
|
||||||
IW["DbPool, EventBusBackend,<br/>AppConfig<br/><i>Shared infra types</i>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Adapters["Adapters (implement Port Traits)"]
|
|
||||||
direction TB
|
|
||||||
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
|
|
||||||
subgraph Storage["Storage"]
|
|
||||||
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
|
|
||||||
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
|
|
||||||
A_SQLITE_SEARCH["sqlite-search<br/><i>FTS5</i>"]
|
|
||||||
A_PG_SEARCH["postgres-search<br/><i>tsvector/GIN</i>"]
|
|
||||||
A_OBJ["object-storage<br/><i>S3 / filesystem</i>"]
|
|
||||||
end
|
|
||||||
subgraph Messaging["Messaging"]
|
|
||||||
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
|
||||||
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
|
|
||||||
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
|
|
||||||
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
|
|
||||||
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
|
||||||
end
|
|
||||||
subgraph External["External Services"]
|
|
||||||
A_METADATA["metadata<br/><i>TMDB search/details</i>"]
|
|
||||||
A_TMDB["tmdb-enrichment<br/><i>Credits, keywords, cast</i>"]
|
|
||||||
A_POSTER["poster-fetcher<br/><i>TMDB image download</i>"]
|
|
||||||
A_AUTH["auth<br/><i>JWT tokens</i>"]
|
|
||||||
end
|
|
||||||
subgraph Federation["Federation (feature-gated)"]
|
|
||||||
A_AP["activitypub<br/><i>k_ap library</i>"]
|
|
||||||
A_SQLITE_FED["sqlite-federation"]
|
|
||||||
A_PG_FED["postgres-federation"]
|
|
||||||
end
|
|
||||||
subgraph Media["Media Processing"]
|
|
||||||
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
|
|
||||||
A_POSTER_SYNC["poster-sync"]
|
|
||||||
end
|
|
||||||
subgraph Presentation["Presentation Helpers"]
|
|
||||||
A_TEMPLATE["template-askama<br/><i>HTML templates</i>"]
|
|
||||||
A_RSS["rss<br/><i>Feed generation</i>"]
|
|
||||||
A_EXPORT["export<br/><i>CSV/JSON diary</i>"]
|
|
||||||
A_IMPORT["importer<br/><i>CSV/JSON/XLSX parser</i>"]
|
|
||||||
end
|
|
||||||
subgraph Webhooks["Webhook Parsers"]
|
|
||||||
A_JELLYFIN["jellyfin"]
|
|
||||||
A_PLEX["plex"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
%% Dependency arrows
|
|
||||||
WEB -->|"uses"| Application
|
|
||||||
WEB -->|"maps to"| ApiTypes
|
|
||||||
WORKER -->|"uses"| Application
|
|
||||||
|
|
||||||
Application -->|"depends on"| Domain
|
|
||||||
UC_WRAPUP -->|"delegates to"| DS_WRAPUP
|
|
||||||
|
|
||||||
Adapters -.->|"implements"| Ports
|
|
||||||
|
|
||||||
%% Key data flows
|
|
||||||
WEB ===|"HTTP"| DTO
|
|
||||||
WORKER ===|"Events"| EVENTS
|
|
||||||
|
|
||||||
classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff
|
|
||||||
classDef app fill:#16213e,stroke:#0f3460,color:#fff
|
|
||||||
classDef adapter fill:#0f3460,stroke:#533483,color:#fff
|
|
||||||
classDef binary fill:#533483,stroke:#e94560,color:#fff
|
|
||||||
classDef api fill:#2a2a4a,stroke:#e94560,color:#fff
|
|
||||||
|
|
||||||
class Domain domain
|
|
||||||
class DomainServices domain
|
|
||||||
class Application app
|
|
||||||
class Adapters adapter
|
|
||||||
class Binaries binary
|
|
||||||
class ApiTypes api
|
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nats"
|
name = "activitypub-base"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-nats = "0.48.0"
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
domain = { workspace = true }
|
|
||||||
event-payload = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
tracing = { 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 }
|
||||||
tokio = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
futures = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
|
||||||
|
activitypub_federation = "0.7.0-beta.11"
|
||||||
|
url = { version = "2", features = ["serde"] }
|
||||||
|
enum_delegate = "0.2"
|
||||||
353
crates/adapters/activitypub-base/src/activities.rs
Normal file
353
crates/adapters/activitypub-base/src/activities.rs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
fetch::object_id::ObjectId,
|
||||||
|
kinds::activity::{
|
||||||
|
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
|
||||||
|
},
|
||||||
|
traits::Activity,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::actors::DbActor;
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::repository::{FollowerStatus, FollowingStatus};
|
||||||
|
|
||||||
|
// --- Follow ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FollowActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: FollowType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: ObjectId<DbActor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for FollowActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let target_url = self.object.inner();
|
||||||
|
let target_domain = match (target_url.host_str(), target_url.port()) {
|
||||||
|
(Some(host), Some(port)) => format!("{}:{}", host, port),
|
||||||
|
(Some(host), None) => host.to_string(),
|
||||||
|
_ => {
|
||||||
|
return Err(Error::bad_request(anyhow::anyhow!(
|
||||||
|
"invalid follow target URL"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if target_domain != data.domain {
|
||||||
|
return Err(Error::bad_request(anyhow::anyhow!(
|
||||||
|
"follow target is not a local actor"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let _follower = self.actor.dereference(data).await?;
|
||||||
|
let local_actor = self.object.dereference(data).await?;
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.add_follower(
|
||||||
|
local_actor.user_id,
|
||||||
|
self.actor.inner().as_str(),
|
||||||
|
FollowerStatus::Pending,
|
||||||
|
self.id.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
follower = %self.actor.inner(),
|
||||||
|
local_user = %local_actor.user_id,
|
||||||
|
"follow request pending approval"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accept ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AcceptActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: AcceptType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: FollowActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for AcceptActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
|
||||||
|
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
|
||||||
|
data.federation_repo
|
||||||
|
.update_following_status(
|
||||||
|
local_user_id,
|
||||||
|
self.actor.inner().as_str(),
|
||||||
|
FollowingStatus::Accepted,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reject ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RejectActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: RejectType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: FollowActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for RejectActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
|
||||||
|
data.federation_repo
|
||||||
|
.remove_following(user_id, self.actor.inner().as_str())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tracing::info!(actor = %self.actor.inner(), "follow rejected");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UndoActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: UndoType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: FollowActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for UndoActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
|
||||||
|
data.federation_repo
|
||||||
|
.remove_follower(user_id, self.actor.inner().as_str())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
data.object_handler
|
||||||
|
.on_actor_removed(self.actor.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: CreateType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for CreateActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let ap_id = self.id.clone();
|
||||||
|
let actor_url = self.actor.inner().clone();
|
||||||
|
data.object_handler
|
||||||
|
.on_create(&ap_id, &actor_url, self.object)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
tracing::info!(actor = %actor_url, "received create activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: DeleteType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for DeleteActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let actor_url = self.actor.inner().clone();
|
||||||
|
data.object_handler
|
||||||
|
.on_delete(&self.object, &actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
tracing::info!(object = %self.object, "received delete activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateActivity {
|
||||||
|
pub(crate) id: Url,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub(crate) kind: UpdateType,
|
||||||
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
|
pub(crate) object: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Activity for UpdateActivity {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.actor.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||||
|
let ap_id = self.id.clone();
|
||||||
|
let actor_url = self.actor.inner().clone();
|
||||||
|
data.object_handler
|
||||||
|
.on_update(&ap_id, &actor_url, self.object)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
tracing::info!(actor = %actor_url, "received update activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inbox dispatch enum ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
#[enum_delegate::implement(Activity)]
|
||||||
|
pub enum InboxActivities {
|
||||||
|
#[serde(rename = "Follow")]
|
||||||
|
Follow(FollowActivity),
|
||||||
|
#[serde(rename = "Accept")]
|
||||||
|
Accept(AcceptActivity),
|
||||||
|
#[serde(rename = "Reject")]
|
||||||
|
Reject(RejectActivity),
|
||||||
|
#[serde(rename = "Undo")]
|
||||||
|
Undo(UndoActivity),
|
||||||
|
#[serde(rename = "Create")]
|
||||||
|
Create(CreateActivity),
|
||||||
|
#[serde(rename = "Delete")]
|
||||||
|
Delete(DeleteActivity),
|
||||||
|
#[serde(rename = "Update")]
|
||||||
|
Update(UpdateActivity),
|
||||||
|
}
|
||||||
21
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
21
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
||||||
|
};
|
||||||
|
use axum::extract::Path;
|
||||||
|
|
||||||
|
use crate::actors::{Person, get_local_actor};
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn actor_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
||||||
|
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
let db_actor = get_local_actor(uuid, &data).await?;
|
||||||
|
let person = db_actor.into_json(&data).await?;
|
||||||
|
|
||||||
|
Ok(FederationJson(WithContext::new_default(person)))
|
||||||
|
}
|
||||||
223
crates/adapters/activitypub-base/src/actors.rs
Normal file
223
crates/adapters/activitypub-base/src/actors.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
fetch::object_id::ObjectId,
|
||||||
|
http_signatures::generate_actor_keypair,
|
||||||
|
kinds::actor::PersonType,
|
||||||
|
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
||||||
|
traits::{Actor, Object},
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::repository::RemoteActor;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DbActor {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub public_key_pem: String,
|
||||||
|
pub private_key_pem: Option<String>,
|
||||||
|
pub inbox_url: Url,
|
||||||
|
pub outbox_url: Url,
|
||||||
|
pub followers_url: Url,
|
||||||
|
pub following_url: Url,
|
||||||
|
pub ap_id: Url,
|
||||||
|
pub last_refreshed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Person {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: PersonType,
|
||||||
|
id: ObjectId<DbActor>,
|
||||||
|
preferred_username: String,
|
||||||
|
inbox: Url,
|
||||||
|
outbox: Url,
|
||||||
|
followers: Url,
|
||||||
|
following: Url,
|
||||||
|
public_key: PublicKey,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_local_actor(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<DbActor, Error> {
|
||||||
|
let user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
||||||
|
|
||||||
|
let (public_key, private_key) = match data
|
||||||
|
.federation_repo
|
||||||
|
.get_local_actor_keypair(user_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(kp) => kp,
|
||||||
|
None => {
|
||||||
|
let kp = generate_actor_keypair()?;
|
||||||
|
data.federation_repo
|
||||||
|
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
||||||
|
.await?;
|
||||||
|
(kp.public_key, kp.private_key)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||||
|
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
|
||||||
|
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
|
||||||
|
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
|
||||||
|
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
|
||||||
|
|
||||||
|
Ok(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: user.username,
|
||||||
|
public_key_pem: public_key,
|
||||||
|
private_key_pem: Some(private_key),
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Object for DbActor {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Kind = Person;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.ap_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
||||||
|
Some(self.last_refreshed_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_from_id(
|
||||||
|
object_id: Url,
|
||||||
|
data: &Data<Self::DataType>,
|
||||||
|
) -> Result<Option<Self>, Self::Error> {
|
||||||
|
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let user = match data.user_repo.find_by_id(user_id).await {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keypair = data
|
||||||
|
.federation_repo
|
||||||
|
.get_local_actor_keypair(user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (public_key, private_key) = match keypair {
|
||||||
|
Some(kp) => (kp.0, Some(kp.1)),
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||||
|
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
|
||||||
|
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
|
||||||
|
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
|
||||||
|
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
|
||||||
|
|
||||||
|
Ok(Some(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: user.username,
|
||||||
|
public_key_pem: public_key,
|
||||||
|
private_key_pem: private_key,
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||||
|
let public_key = PublicKey {
|
||||||
|
id: format!("{}#main-key", &self.ap_id),
|
||||||
|
owner: self.ap_id.clone(),
|
||||||
|
public_key_pem: self.public_key_pem.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Person {
|
||||||
|
kind: Default::default(),
|
||||||
|
id: self.ap_id.clone().into(),
|
||||||
|
preferred_username: self.username.clone(),
|
||||||
|
inbox: self.inbox_url.clone(),
|
||||||
|
outbox: self.outbox_url.clone(),
|
||||||
|
followers: self.followers_url.clone(),
|
||||||
|
following: self.following_url.clone(),
|
||||||
|
public_key,
|
||||||
|
name: Some(self.username.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
json: &Self::Kind,
|
||||||
|
expected_domain: &Url,
|
||||||
|
_data: &Data<Self::DataType>,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
verify_domains_match(json.id.inner(), expected_domain)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||||
|
let actor = RemoteActor {
|
||||||
|
url: json.id.inner().to_string(),
|
||||||
|
handle: json.preferred_username.clone(),
|
||||||
|
inbox_url: json.inbox.to_string(),
|
||||||
|
shared_inbox_url: None,
|
||||||
|
display_name: json.name.clone(),
|
||||||
|
};
|
||||||
|
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||||
|
|
||||||
|
let url_str = json.id.inner().to_string();
|
||||||
|
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
||||||
|
let ap_id = json.id.inner().clone();
|
||||||
|
let inbox_url = json.inbox.clone();
|
||||||
|
let outbox_url = json.outbox.clone();
|
||||||
|
let followers_url = json.followers.clone();
|
||||||
|
let following_url = json.following.clone();
|
||||||
|
|
||||||
|
Ok(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: json.preferred_username.clone(),
|
||||||
|
public_key_pem: json.public_key.public_key_pem,
|
||||||
|
private_key_pem: None,
|
||||||
|
inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor for DbActor {
|
||||||
|
fn public_key_pem(&self) -> &str {
|
||||||
|
&self.public_key_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
fn private_key_pem(&self) -> Option<String> {
|
||||||
|
self.private_key_pem.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox(&self) -> Url {
|
||||||
|
self.inbox_url.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/adapters/activitypub-base/src/content.rs
Normal file
34
crates/adapters/activitypub-base/src/content.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApObjectHandler: Send + Sync {
|
||||||
|
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
||||||
|
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
||||||
|
async fn get_local_objects_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
||||||
|
|
||||||
|
/// Incoming Create activity — persist remote content.
|
||||||
|
async fn on_create(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Incoming Update activity — update existing remote content.
|
||||||
|
async fn on_update(
|
||||||
|
&self,
|
||||||
|
ap_id: &Url,
|
||||||
|
actor_url: &Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Incoming Delete activity — remove specific remote content.
|
||||||
|
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Actor unfollowed/was removed — clean up all their remote content.
|
||||||
|
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
||||||
|
}
|
||||||
38
crates/adapters/activitypub-base/src/data.rs
Normal file
38
crates/adapters/activitypub-base/src/data.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::content::ApObjectHandler;
|
||||||
|
use crate::repository::FederationRepository;
|
||||||
|
use crate::user::ApUserRepository;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FederationData {
|
||||||
|
pub(crate) federation_repo: Arc<dyn FederationRepository>,
|
||||||
|
pub(crate) user_repo: Arc<dyn ApUserRepository>,
|
||||||
|
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
|
||||||
|
pub(crate) base_url: String,
|
||||||
|
pub(crate) domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationData {
|
||||||
|
pub fn new(
|
||||||
|
federation_repo: Arc<dyn FederationRepository>,
|
||||||
|
user_repo: Arc<dyn ApUserRepository>,
|
||||||
|
object_handler: Arc<dyn ApObjectHandler>,
|
||||||
|
base_url: String,
|
||||||
|
) -> Self {
|
||||||
|
let domain = base_url
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
Self {
|
||||||
|
federation_repo,
|
||||||
|
user_repo,
|
||||||
|
object_handler,
|
||||||
|
base_url,
|
||||||
|
domain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/adapters/activitypub-base/src/error.rs
Normal file
48
crates/adapters/activitypub-base/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self(e.into(), StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
||||||
|
Self(e.into(), StatusCode::BAD_REQUEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Error {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
std::fmt::Display::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<T> for Error
|
||||||
|
where
|
||||||
|
T: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(t: T) -> Self {
|
||||||
|
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl axum::response::IntoResponse for Error {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = self.1;
|
||||||
|
if status.is_server_error() {
|
||||||
|
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
||||||
|
}
|
||||||
|
let body = if status.is_server_error() {
|
||||||
|
"internal server error".to_string()
|
||||||
|
} else {
|
||||||
|
self.0.to_string()
|
||||||
|
};
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
|
||||||
|
use activitypub_federation::error::Error as FedError;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PermissiveVerifier;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl UrlVerifier for PermissiveVerifier {
|
||||||
|
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
|
||||||
|
|
||||||
|
impl ApFederationConfig {
|
||||||
|
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
||||||
|
let config = if debug {
|
||||||
|
FederationConfig::builder()
|
||||||
|
.domain(&data.domain)
|
||||||
|
.app_data(data)
|
||||||
|
.debug(true)
|
||||||
|
.http_signature_compat(true)
|
||||||
|
.url_verifier(Box::new(PermissiveVerifier))
|
||||||
|
.build()
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
FederationConfig::builder()
|
||||||
|
.domain(&data.domain)
|
||||||
|
.app_data(data)
|
||||||
|
.debug(false)
|
||||||
|
.http_signature_compat(true)
|
||||||
|
.build()
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(Self(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_request_data(&self) -> Data<FederationData> {
|
||||||
|
self.0.to_request_data()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
||||||
|
FederationMiddleware::new(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
71
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
71
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||||
|
use axum::extract::Path;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::repository::FollowerStatus;
|
||||||
|
|
||||||
|
fn ordered_collection(id: String, total: usize, items: Vec<String>) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": id,
|
||||||
|
"totalItems": total,
|
||||||
|
"orderedItems": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn followers_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let followers = data
|
||||||
|
.federation_repo
|
||||||
|
.get_followers(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let items: Vec<String> = followers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||||
|
.map(|f| f.actor.url)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||||
|
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn following_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let following = data
|
||||||
|
.federation_repo
|
||||||
|
.get_following(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||||
|
|
||||||
|
let id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||||
|
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||||
|
}
|
||||||
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::inbox::{ActivityData, receive_activity},
|
||||||
|
config::Data,
|
||||||
|
protocol::context::WithContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::activities::InboxActivities;
|
||||||
|
use crate::actors::DbActor;
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn inbox_handler(
|
||||||
|
data: Data<FederationData>,
|
||||||
|
activity_data: ActivityData,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
25
crates/adapters/activitypub-base/src/lib.rs
Normal file
25
crates/adapters/activitypub-base/src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
pub mod activities;
|
||||||
|
pub mod actor_handler;
|
||||||
|
pub mod actors;
|
||||||
|
pub mod content;
|
||||||
|
pub mod data;
|
||||||
|
pub mod error;
|
||||||
|
pub mod federation;
|
||||||
|
pub mod followers_handler;
|
||||||
|
pub mod inbox;
|
||||||
|
pub mod outbox;
|
||||||
|
pub mod repository;
|
||||||
|
pub mod service;
|
||||||
|
pub(crate) mod urls;
|
||||||
|
pub mod user;
|
||||||
|
pub mod webfinger;
|
||||||
|
|
||||||
|
pub use content::ApObjectHandler;
|
||||||
|
pub use data::FederationData;
|
||||||
|
pub use error::Error;
|
||||||
|
pub use federation::ApFederationConfig;
|
||||||
|
pub use repository::{
|
||||||
|
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||||
|
};
|
||||||
|
pub use service::ActivityPubService;
|
||||||
|
pub use user::{ApUser, ApUserRepository};
|
||||||
48
crates/adapters/activitypub-base/src/outbox.rs
Normal file
48
crates/adapters/activitypub-base/src/outbox.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||||
|
use axum::extract::Path;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollection {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
total_items: u64,
|
||||||
|
ordered_items: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn outbox_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<OrderedCollection>, Error> {
|
||||||
|
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||||
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(uuid)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let objects = data
|
||||||
|
.object_handler
|
||||||
|
.get_local_objects_for_user(uuid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
|
||||||
|
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
||||||
|
|
||||||
|
Ok(FederationJson(OrderedCollection {
|
||||||
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
kind: "OrderedCollection".to_string(),
|
||||||
|
id: outbox_url,
|
||||||
|
total_items: objects.len() as u64,
|
||||||
|
ordered_items: vec![],
|
||||||
|
}))
|
||||||
|
}
|
||||||
91
crates/adapters/activitypub-base/src/repository.rs
Normal file
91
crates/adapters/activitypub-base/src/repository.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FollowerStatus {
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FollowingStatus {
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RemoteActor {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub inbox_url: String,
|
||||||
|
pub shared_inbox_url: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Follower {
|
||||||
|
pub actor: RemoteActor,
|
||||||
|
pub status: FollowerStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationRepository: Send + Sync {
|
||||||
|
async fn add_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follower_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||||
|
async fn update_follower_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn add_following(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor: RemoteActor,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||||
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||||
|
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||||
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
||||||
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
||||||
|
async fn get_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<(String, String)>>;
|
||||||
|
async fn save_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
public_key: String,
|
||||||
|
private_key: String,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||||
|
async fn update_following_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowingStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
}
|
||||||
620
crates/adapters/activitypub-base/src/service.rs
Normal file
620
crates/adapters/activitypub-base/src/service.rs
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use activitypub_federation::{
|
||||||
|
activity_sending::SendActivityTask,
|
||||||
|
fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},
|
||||||
|
protocol::context::WithContext,
|
||||||
|
traits::Actor,
|
||||||
|
};
|
||||||
|
use axum::{Router, routing::get, routing::post};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
|
||||||
|
actors::{DbActor, get_local_actor},
|
||||||
|
content::ApObjectHandler,
|
||||||
|
data::FederationData,
|
||||||
|
federation::ApFederationConfig,
|
||||||
|
followers_handler::{followers_handler, following_handler},
|
||||||
|
inbox::inbox_handler,
|
||||||
|
outbox::outbox_handler,
|
||||||
|
repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
|
||||||
|
urls::activity_url,
|
||||||
|
user::ApUserRepository,
|
||||||
|
webfinger::webfinger_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) async fn send_with_retry(
|
||||||
|
sends: Vec<SendActivityTask>,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
) -> Vec<anyhow::Error> {
|
||||||
|
let mut failures = vec![];
|
||||||
|
for send in sends {
|
||||||
|
let mut delay = std::time::Duration::from_secs(1);
|
||||||
|
for attempt in 1..=3u32 {
|
||||||
|
match send.clone().sign_and_send(data).await {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(e) if attempt < 3 => {
|
||||||
|
tracing::warn!(attempt, error = %e, "delivery failed, retrying");
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
delay *= 2;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(attempt, error = %e, "delivery failed permanently");
|
||||||
|
failures.push(anyhow::anyhow!(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActivityPubService {
|
||||||
|
federation_config: ApFederationConfig,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
pub async fn new(
|
||||||
|
repo: Arc<dyn FederationRepository>,
|
||||||
|
user_repo: Arc<dyn ApUserRepository>,
|
||||||
|
object_handler: Arc<dyn ApObjectHandler>,
|
||||||
|
base_url: String,
|
||||||
|
debug: bool,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone());
|
||||||
|
let federation_config = ApFederationConfig::new(data, debug).await?;
|
||||||
|
Ok(Self {
|
||||||
|
federation_config,
|
||||||
|
base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn federation_config(&self) -> &ApFederationConfig {
|
||||||
|
&self.federation_config
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_data(&self) -> activitypub_federation::config::Data<FederationData> {
|
||||||
|
self.federation_config.to_request_data()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
|
||||||
|
use activitypub_federation::traits::Object;
|
||||||
|
let uuid = uuid::Uuid::parse_str(user_id_str)?;
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let actor = get_local_actor(uuid, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let person = actor
|
||||||
|
.into_json(&data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
Ok(serde_json::to_string(&WithContext::new_default(person))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(&self) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||||
|
.route("/users/{id}/inbox", post(inbox_handler))
|
||||||
|
.route("/users/{id}/outbox", get(outbox_handler))
|
||||||
|
.route("/users/{id}/followers", get(followers_handler))
|
||||||
|
.route("/users/{id}/following", get(following_handler))
|
||||||
|
.layer(self.federation_config.middleware())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
|
||||||
|
let normalized = handle.trim_start_matches('@');
|
||||||
|
let parts: Vec<&str> = normalized.splitn(2, '@').collect();
|
||||||
|
if parts.len() == 2 && parts[1] == data.domain {
|
||||||
|
return self.follow_local(local_user_id, parts[0], &data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let follow_id_str = follow_id.to_string();
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: ObjectId::from(remote_actor.ap_id.clone()),
|
||||||
|
};
|
||||||
|
let follow_with_ctx = WithContext::new_default(follow);
|
||||||
|
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&follow_with_ctx,
|
||||||
|
&local_actor,
|
||||||
|
vec![remote_actor.inbox()],
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
count = failures.len(),
|
||||||
|
"some activity deliveries failed permanently"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote = RemoteActor {
|
||||||
|
url: remote_actor.ap_id.to_string(),
|
||||||
|
handle: remote_actor.username.clone(),
|
||||||
|
inbox_url: remote_actor.inbox_url.to_string(),
|
||||||
|
shared_inbox_url: None,
|
||||||
|
display_name: Some(remote_actor.username.clone()),
|
||||||
|
};
|
||||||
|
data.federation_repo
|
||||||
|
.add_following(local_user_id, remote, &follow_id_str)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor_url_str: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
|
||||||
|
if actor_url_str.starts_with(&self.base_url) {
|
||||||
|
return self
|
||||||
|
.unfollow_local(local_user_id, actor_url_str, &data)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote = data
|
||||||
|
.federation_repo
|
||||||
|
.get_remote_actor(actor_url_str)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
|
||||||
|
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let remote_ap_id = Url::parse(actor_url_str)?;
|
||||||
|
let inbox = Url::parse(&remote.inbox_url)?;
|
||||||
|
|
||||||
|
let follow_activity_id_str = data
|
||||||
|
.federation_repo
|
||||||
|
.get_follow_activity_id(local_user_id, actor_url_str)
|
||||||
|
.await?;
|
||||||
|
let follow_id = match follow_activity_id_str {
|
||||||
|
Some(id) => Url::parse(&id)?,
|
||||||
|
None => activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||||
|
};
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: ObjectId::from(remote_ap_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: undo_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(undo),
|
||||||
|
&local_actor,
|
||||||
|
vec![inbox],
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
count = failures.len(),
|
||||||
|
"some activity deliveries failed permanently"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.remove_following(local_user_id, actor_url_str)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
data.object_handler
|
||||||
|
.on_actor_removed(&Url::parse(actor_url_str)?)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let remote_actor = data
|
||||||
|
.federation_repo
|
||||||
|
.get_remote_actor(remote_actor_url)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
|
||||||
|
|
||||||
|
let follow_id_str = data
|
||||||
|
.federation_repo
|
||||||
|
.get_follower_follow_activity_id(local_user_id, remote_actor_url)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
|
||||||
|
})?;
|
||||||
|
let follow_id = Url::parse(&follow_id_str)?;
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(Url::parse(remote_actor_url)?),
|
||||||
|
object: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
};
|
||||||
|
let accept = AcceptActivity {
|
||||||
|
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(accept),
|
||||||
|
&local_actor,
|
||||||
|
vec![inbox.clone()],
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to deliver Accept activity, but follower is marked accepted locally"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let remote_actor = data
|
||||||
|
.federation_repo
|
||||||
|
.get_remote_actor(remote_actor_url)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
|
||||||
|
|
||||||
|
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(Url::parse(remote_actor_url)?),
|
||||||
|
object: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
};
|
||||||
|
let reject = RejectActivity {
|
||||||
|
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(reject),
|
||||||
|
&local_actor,
|
||||||
|
vec![inbox],
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
count = failures.len(),
|
||||||
|
"some activity deliveries failed permanently"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.remove_follower(local_user_id, remote_actor_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
data.federation_repo
|
||||||
|
.get_pending_followers(local_user_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_accepted_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||||
|
Ok(followers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||||
|
.map(|f| f.actor)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_accepted_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<usize> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||||
|
Ok(followers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||||
|
.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
data.federation_repo.get_following(local_user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
data.federation_repo.count_following(local_user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
data.federation_repo
|
||||||
|
.remove_follower(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a single object to all accepted followers as a Create activity.
|
||||||
|
/// Called by project-specific event handlers when new content is created.
|
||||||
|
pub async fn broadcast_to_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
ap_id: Url,
|
||||||
|
object: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||||
|
let accepted: Vec<_> = followers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if accepted.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: ap_id.clone(),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object,
|
||||||
|
};
|
||||||
|
let create_with_ctx = WithContext::new_default(create);
|
||||||
|
|
||||||
|
let inboxes: Vec<Url> = accepted
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sends =
|
||||||
|
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
count = failures.len(),
|
||||||
|
"some activity deliveries failed permanently"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn follow_local(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
target_username: &str,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let target = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_username(target_username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not found: {}", target_username))?;
|
||||||
|
|
||||||
|
if target.id == local_user_id {
|
||||||
|
return Err(anyhow::anyhow!("cannot follow yourself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
|
||||||
|
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
|
||||||
|
let target_inbox_url = format!("{}/inbox", target_actor_url);
|
||||||
|
let follow_id = activity_url(&self.base_url)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.add_follower(
|
||||||
|
target.id,
|
||||||
|
&follower_actor_url,
|
||||||
|
FollowerStatus::Accepted,
|
||||||
|
&follow_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let target_as_remote = RemoteActor {
|
||||||
|
url: target_actor_url.to_string(),
|
||||||
|
handle: format!("{}@{}", target.username, data.domain),
|
||||||
|
inbox_url: target_inbox_url,
|
||||||
|
shared_inbox_url: None,
|
||||||
|
display_name: Some(target.username),
|
||||||
|
};
|
||||||
|
data.federation_repo
|
||||||
|
.add_following(local_user_id, target_as_remote, &follow_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.update_following_status(
|
||||||
|
local_user_id,
|
||||||
|
&target_actor_url.to_string(),
|
||||||
|
FollowingStatus::Accepted,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfollow_local(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
target_actor_url: &str,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let target_url = Url::parse(target_actor_url)?;
|
||||||
|
let target_user_id = crate::urls::extract_user_id_from_url(&target_url)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
|
||||||
|
|
||||||
|
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
|
||||||
|
|
||||||
|
data.federation_repo
|
||||||
|
.remove_follower(target_user_id, &local_actor_url)
|
||||||
|
.await?;
|
||||||
|
data.federation_repo
|
||||||
|
.remove_following(local_user_id, target_actor_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) {
|
||||||
|
let config = self.federation_config.clone();
|
||||||
|
let base_url = self.base_url.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = ActivityPubService::run_backfill(
|
||||||
|
config,
|
||||||
|
base_url,
|
||||||
|
owner_user_id,
|
||||||
|
follower_inbox_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "backfill: task failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_backfill(
|
||||||
|
config: ApFederationConfig,
|
||||||
|
base_url: String,
|
||||||
|
owner_user_id: uuid::Uuid,
|
||||||
|
follower_inbox_url: String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
const BATCH_SIZE: usize = 20;
|
||||||
|
|
||||||
|
let data = config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(owner_user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let inbox = Url::parse(&follower_inbox_url)?;
|
||||||
|
|
||||||
|
let mut objects = data
|
||||||
|
.object_handler
|
||||||
|
.get_local_objects_for_user(owner_user_id)
|
||||||
|
.await?;
|
||||||
|
objects.reverse(); // oldest first → chronological feed
|
||||||
|
|
||||||
|
let total = objects.len();
|
||||||
|
let mut success_count = 0usize;
|
||||||
|
let mut failure_count = 0usize;
|
||||||
|
|
||||||
|
for chunk in objects.chunks(BATCH_SIZE) {
|
||||||
|
for (ap_id, object_json) in chunk {
|
||||||
|
// Use a stable Create activity ID derived from the object's ap_id
|
||||||
|
let create_id = Url::parse(&format!(
|
||||||
|
"{}/activities/create/{}",
|
||||||
|
base_url,
|
||||||
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: create_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: object_json.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(create),
|
||||||
|
&local_actor,
|
||||||
|
vec![inbox.clone()],
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if failures.is_empty() {
|
||||||
|
success_count += 1;
|
||||||
|
} else {
|
||||||
|
failure_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
user_id = %owner_user_id,
|
||||||
|
follower = %follower_inbox_url,
|
||||||
|
sent = success_count,
|
||||||
|
failed = failure_count,
|
||||||
|
total = total,
|
||||||
|
"backfill complete"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/adapters/activitypub-base/src/urls.rs
Normal file
20
crates/adapters/activitypub-base/src/urls.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
||||||
|
let path = url.path();
|
||||||
|
path.strip_prefix("/users/")
|
||||||
|
.and_then(|s| s.split('/').next())
|
||||||
|
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
|
||||||
|
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
||||||
|
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
||||||
|
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
||||||
|
.expect("base_url is always a valid URL prefix")
|
||||||
|
}
|
||||||
13
crates/adapters/activitypub-base/src/user.rs
Normal file
13
crates/adapters/activitypub-base/src/user.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApUser {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApUserRepository: Send + Sync {
|
||||||
|
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
|
||||||
|
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
|
||||||
|
}
|
||||||
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
http::header,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebfingerQuery {
|
||||||
|
resource: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webfinger_handler(
|
||||||
|
Query(query): Query<WebfingerQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<Response, Error> {
|
||||||
|
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||||
|
|
||||||
|
let user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_username(name)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
|
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
||||||
|
|
||||||
|
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
|
||||||
|
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
activitypub-base = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { 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 }
|
||||||
@@ -15,5 +15,5 @@ anyhow = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
activitypub_federation = "0.7.0-beta.11"
|
||||||
url = { version = "2", features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
ammonia = "4"
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
goal_handler::GoalObjectHandler, review_handler::ReviewObjectHandler,
|
|
||||||
watchlist_handler::WatchlistObjectHandler,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct CompositeObjectHandler {
|
|
||||||
pub review: Arc<ReviewObjectHandler>,
|
|
||||||
pub watchlist: Arc<WatchlistObjectHandler>,
|
|
||||||
pub goal: Arc<GoalObjectHandler>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApContentReader for CompositeObjectHandler {
|
|
||||||
async fn get_local_objects_page(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<LocalObject>> {
|
|
||||||
let fetch_limit = limit * 3;
|
|
||||||
let reviews = self
|
|
||||||
.review
|
|
||||||
.get_local_objects_page(user_id, before, fetch_limit)
|
|
||||||
.await?;
|
|
||||||
let watchlist = self
|
|
||||||
.watchlist
|
|
||||||
.get_local_objects_page(user_id, None, usize::MAX)
|
|
||||||
.await?;
|
|
||||||
let goals = self
|
|
||||||
.goal
|
|
||||||
.get_local_objects_page(user_id, None, usize::MAX)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut all: Vec<LocalObject> = Vec::new();
|
|
||||||
all.extend(reviews);
|
|
||||||
all.extend(watchlist);
|
|
||||||
all.extend(goals);
|
|
||||||
|
|
||||||
if let Some(before_ts) = before {
|
|
||||||
all.retain(|obj| obj.published_at < before_ts);
|
|
||||||
}
|
|
||||||
all.sort_by_key(|obj| std::cmp::Reverse(obj.published_at));
|
|
||||||
all.truncate(limit);
|
|
||||||
Ok(all)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
self.review.count_local_posts().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApObjectHandler for CompositeObjectHandler {
|
|
||||||
async fn on_create(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|
|
||||||
|| object.get("rating").is_some();
|
|
||||||
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|
|
||||||
|| (object.get("movieTitle").is_some()
|
|
||||||
&& object.get("rating").is_none()
|
|
||||||
&& object.get("review").is_none());
|
|
||||||
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
|
|
||||||
if is_review {
|
|
||||||
self.review.on_create(ap_id, actor_url, object).await
|
|
||||||
} else if is_goal {
|
|
||||||
self.goal.on_create(ap_id, actor_url, object).await
|
|
||||||
} else if is_watchlist {
|
|
||||||
self.watchlist.on_create(ap_id, actor_url, object).await
|
|
||||||
} else {
|
|
||||||
tracing::warn!(ap_id = %ap_id, "ignoring Create for unknown object type");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_update(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|
|
||||||
|| object.get("rating").is_some();
|
|
||||||
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
|
|
||||||
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true);
|
|
||||||
if is_review {
|
|
||||||
self.review.on_update(ap_id, actor_url, object).await
|
|
||||||
} else if is_goal {
|
|
||||||
self.goal.on_update(ap_id, actor_url, object).await
|
|
||||||
} else if is_watchlist {
|
|
||||||
self.watchlist.on_update(ap_id, actor_url, object).await
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.review.on_delete(ap_id, actor_url).await?;
|
|
||||||
self.watchlist.on_delete(ap_id, actor_url).await?;
|
|
||||||
self.goal.on_delete(ap_id, actor_url).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.review.on_actor_removed(actor_url).await?;
|
|
||||||
self.watchlist.on_actor_removed(actor_url).await?;
|
|
||||||
self.goal.on_actor_removed(actor_url).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +1,36 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Datelike;
|
|
||||||
use domain::ports::EventHandler;
|
use domain::ports::EventHandler;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{
|
ports::{MovieRepository, ReviewRepository},
|
||||||
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
value_objects::{ReviewId, UserId},
|
||||||
UserFederationSettingsQuery,
|
|
||||||
},
|
|
||||||
value_objects::{MovieId, ReviewId, UserId},
|
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::{ActivityPubService, ApVisibility};
|
use activitypub_base::ActivityPubService;
|
||||||
|
|
||||||
use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object};
|
use crate::objects::review_to_ap_object;
|
||||||
use crate::urls::{actor_url, goal_url, review_url};
|
use crate::urls::{actor_url, review_url};
|
||||||
|
|
||||||
pub struct ActivityPubEventHandler {
|
pub struct ActivityPubEventHandler {
|
||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
content_query: Arc<dyn LocalApContentQuery>,
|
movie_repository: Arc<dyn MovieRepository>,
|
||||||
review_repo: Arc<dyn ReviewRepository>,
|
review_repository: Arc<dyn ReviewRepository>,
|
||||||
movie_repo: Arc<dyn MovieQuery>,
|
|
||||||
goal_repo: Arc<dyn GoalQuery>,
|
|
||||||
stats_repo: Arc<dyn StatsRepository>,
|
|
||||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
|
||||||
base_url: String,
|
base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityPubEventHandler {
|
impl ActivityPubEventHandler {
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
content_query: Arc<dyn LocalApContentQuery>,
|
movie_repository: Arc<dyn MovieRepository>,
|
||||||
review_repo: Arc<dyn ReviewRepository>,
|
review_repository: Arc<dyn ReviewRepository>,
|
||||||
movie_repo: Arc<dyn MovieQuery>,
|
|
||||||
goal_repo: Arc<dyn GoalQuery>,
|
|
||||||
stats_repo: Arc<dyn StatsRepository>,
|
|
||||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
|
||||||
base_url: String,
|
base_url: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ap_service,
|
ap_service,
|
||||||
content_query,
|
movie_repository,
|
||||||
review_repo,
|
review_repository,
|
||||||
movie_repo,
|
|
||||||
goal_repo,
|
|
||||||
stats_repo,
|
|
||||||
federation_settings,
|
|
||||||
base_url,
|
base_url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,101 +46,6 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
.on_review_logged(user_id, review_id)
|
.on_review_logged(user_id, review_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||||
DomainEvent::ReviewUpdated {
|
|
||||||
review_id, user_id, ..
|
|
||||||
} => self
|
|
||||||
.on_review_updated(user_id, review_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::ReviewDeleted { review_id, user_id } => self
|
|
||||||
.on_review_deleted(user_id, review_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::UserUpdated { user_id } => self
|
|
||||||
.ap_service
|
|
||||||
.broadcast_actor_update(user_id.value())
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::WatchlistEntryAdded {
|
|
||||||
user_id,
|
|
||||||
movie_id,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
added_at,
|
|
||||||
} => self
|
|
||||||
.on_watchlist_added(
|
|
||||||
user_id,
|
|
||||||
movie_id,
|
|
||||||
movie_title,
|
|
||||||
*release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
added_at,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
|
|
||||||
.on_watchlist_removed(user_id, movie_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::FederationDeliveryRequested {
|
|
||||||
inbox_url,
|
|
||||||
activity_json,
|
|
||||||
signing_actor_id,
|
|
||||||
} => {
|
|
||||||
let inbox: url::Url = inbox_url
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
|
|
||||||
self.ap_service
|
|
||||||
.deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
DomainEvent::PosterSynced { movie_id } => self
|
|
||||||
.on_poster_synced(movie_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::GoalCreated {
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
..
|
|
||||||
} => self
|
|
||||||
.broadcast_goal(user_id, *year, *target_count, true)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::GoalUpdated {
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
..
|
|
||||||
} => self
|
|
||||||
.broadcast_goal(user_id, *year, *target_count, false)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::GoalDeleted { user_id, year, .. } => self
|
|
||||||
.on_goal_deleted(user_id, *year)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
|
||||||
DomainEvent::UserDeleted { user_id } => {
|
|
||||||
let ap_id = actor_url(&self.base_url, user_id.value());
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
DomainEvent::UserAccountMoved {
|
|
||||||
user_id,
|
|
||||||
new_actor_url,
|
|
||||||
} => {
|
|
||||||
let target = new_actor_url.parse::<url::Url>().map_err(|e| {
|
|
||||||
DomainError::InfrastructureError(format!("invalid new_actor_url: {e}"))
|
|
||||||
})?;
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_move(user_id.value(), target)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,16 +53,7 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
|
|
||||||
impl ActivityPubEventHandler {
|
impl ActivityPubEventHandler {
|
||||||
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||||
let flags = self
|
let review = match self.review_repository.get_review_by_id(review_id).await? {
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.reviews {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
@@ -183,7 +62,7 @@ impl ActivityPubEventHandler {
|
|||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.base_url, user_id.value());
|
||||||
|
|
||||||
let movie = self
|
let movie = self
|
||||||
.movie_repo
|
.movie_repository
|
||||||
.get_movie_by_id(review.movie_id())
|
.get_movie_by_id(review.movie_id())
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -196,327 +75,25 @@ impl ActivityPubEventHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| m.release_year().value())
|
.map(|m| m.release_year().value())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let obj = review_to_ap_object(
|
|
||||||
&review,
|
|
||||||
ReviewApInput {
|
|
||||||
ap_id: ap_id.clone(),
|
|
||||||
actor_url: actor,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id: movie
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.external_metadata_id())
|
|
||||||
.map(|id| id.value().to_string()),
|
|
||||||
poster_url: movie
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.poster_path())
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let json = serde_json::to_value(obj)?;
|
|
||||||
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let year = review.watched_at().year() as u16;
|
|
||||||
self.broadcast_goal_progress_update(user_id, year).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_review_updated(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
review_id: &ReviewId,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.reviews {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
|
||||||
Some(r) => r,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review_id);
|
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
|
||||||
|
|
||||||
let movie = self
|
|
||||||
.movie_repo
|
|
||||||
.get_movie_by_id(review.movie_id())
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let movie_title = movie
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| m.title().value().to_string())
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
|
||||||
let release_year = movie
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| m.release_year().value())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let obj = review_to_ap_object(
|
|
||||||
&review,
|
|
||||||
ReviewApInput {
|
|
||||||
ap_id,
|
|
||||||
actor_url: actor,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id: movie
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.external_metadata_id())
|
|
||||||
.map(|id| id.value().to_string()),
|
|
||||||
poster_url: movie
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.poster_path())
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let json = serde_json::to_value(obj)?;
|
|
||||||
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_review_deleted(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
review_id: &ReviewId,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let ap_id = review_url(&self.base_url, review_id);
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_watchlist_added(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
movie_id: &domain::value_objects::MovieId,
|
|
||||||
movie_title: &str,
|
|
||||||
release_year: u16,
|
|
||||||
external_metadata_id: &Option<String>,
|
|
||||||
added_at: &chrono::NaiveDateTime,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.watchlist {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::urls::watchlist_entry_url;
|
|
||||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
|
||||||
|
|
||||||
let poster_url = self
|
|
||||||
.movie_repo
|
|
||||||
.get_movie_by_id(movie_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|m| {
|
|
||||||
m.poster_path()
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
|
|
||||||
});
|
|
||||||
|
|
||||||
let added_at_utc =
|
|
||||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
|
||||||
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
|
|
||||||
ap_id: ap_id.clone(),
|
|
||||||
actor_url: actor,
|
|
||||||
movie_title: movie_title.to_string(),
|
|
||||||
release_year,
|
|
||||||
external_metadata_id: external_metadata_id.clone(),
|
|
||||||
poster_url,
|
|
||||||
added_at: added_at_utc,
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
});
|
|
||||||
let json = serde_json::to_value(obj)?;
|
|
||||||
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_watchlist_removed(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
movie_id: &domain::value_objects::MovieId,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
use crate::urls::watchlist_entry_url;
|
|
||||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_poster_synced(&self, movie_id: &MovieId) -> anyhow::Result<()> {
|
|
||||||
let entries = self
|
|
||||||
.content_query
|
|
||||||
.get_local_reviews_for_movie(movie_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
|
|
||||||
let movie = match movie {
|
|
||||||
Some(m) => m,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
let external_metadata_id = movie
|
|
||||||
.external_metadata_id()
|
|
||||||
.map(|id| id.value().to_string());
|
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.poster_path()
|
.as_ref()
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
.and_then(|m| m.poster_path())
|
||||||
|
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
|
||||||
|
|
||||||
for entry in entries {
|
let obj = review_to_ap_object(
|
||||||
let review = entry.review();
|
&review,
|
||||||
let user_id = review.user_id();
|
ap_id.clone(),
|
||||||
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.reviews {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review.id());
|
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
|
||||||
|
|
||||||
let obj = review_to_ap_object(
|
|
||||||
review,
|
|
||||||
ReviewApInput {
|
|
||||||
ap_id,
|
|
||||||
actor_url: actor,
|
|
||||||
movie_title: movie.title().value().to_string(),
|
|
||||||
release_year: movie.release_year().value(),
|
|
||||||
external_metadata_id: external_metadata_id.clone(),
|
|
||||||
poster_url: poster_url.clone(),
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let json = serde_json::to_value(obj)?;
|
|
||||||
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast_goal_progress_update(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
year: u16,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.goals {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some(goal) = self
|
|
||||||
.goal_repo
|
|
||||||
.find_by_user_and_year(user_id, year)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let current = self
|
|
||||||
.stats_repo
|
|
||||||
.count_reviews_in_year(user_id, year)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
|
||||||
let obj = goal_to_ap_object(
|
|
||||||
ap_id,
|
|
||||||
actor,
|
actor,
|
||||||
year,
|
movie_title,
|
||||||
goal.target_count(),
|
release_year,
|
||||||
current,
|
poster_url,
|
||||||
&self.base_url,
|
|
||||||
);
|
);
|
||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_to_followers(user_id.value(), ap_id, json)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast_goal(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
year: u16,
|
|
||||||
target_count: u32,
|
|
||||||
is_create: bool,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.goals {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let current = self
|
|
||||||
.stats_repo
|
|
||||||
.count_reviews_in_year(user_id, year)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
|
||||||
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
|
|
||||||
let json = serde_json::to_value(obj)?;
|
|
||||||
if is_create {
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
|
|
||||||
let flags = self
|
|
||||||
.federation_settings
|
|
||||||
.get_federation_flags(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.goals {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
|
||||||
self.ap_service
|
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::events::DomainEvent;
|
|
||||||
use domain::value_objects::UserId;
|
|
||||||
use k_ap::FederationEvent;
|
|
||||||
|
|
||||||
pub struct FederationEventBridge {
|
|
||||||
domain_publisher: Arc<dyn domain::ports::EventPublisher>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FederationEventBridge {
|
|
||||||
pub fn new(domain_publisher: Arc<dyn domain::ports::EventPublisher>) -> Self {
|
|
||||||
Self { domain_publisher }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl k_ap::EventPublisher for FederationEventBridge {
|
|
||||||
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
|
|
||||||
match event {
|
|
||||||
FederationEvent::BackfillRequested {
|
|
||||||
owner_user_id,
|
|
||||||
follower_inbox_url,
|
|
||||||
} => self
|
|
||||||
.domain_publisher
|
|
||||||
.publish(&DomainEvent::BackfillFollower {
|
|
||||||
owner_user_id: UserId::from_uuid(owner_user_id),
|
|
||||||
follower_inbox_url,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string())),
|
|
||||||
FederationEvent::DeliveryRequested {
|
|
||||||
inbox,
|
|
||||||
activity,
|
|
||||||
signing_actor_id,
|
|
||||||
} => self
|
|
||||||
.domain_publisher
|
|
||||||
.publish(&DomainEvent::FederationDeliveryRequested {
|
|
||||||
inbox_url: inbox.to_string(),
|
|
||||||
activity_json: activity,
|
|
||||||
signing_actor_id,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string())),
|
|
||||||
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
|
||||||
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
FederationEvent::OutboundFollowAccepted {
|
|
||||||
local_user_id,
|
|
||||||
remote_actor_url,
|
|
||||||
outbox_url,
|
|
||||||
} => {
|
|
||||||
let identity = domain::value_objects::SocialIdentity::Remote {
|
|
||||||
actor_url: remote_actor_url,
|
|
||||||
};
|
|
||||||
self.domain_publisher
|
|
||||||
.publish(&DomainEvent::FollowAccepted {
|
|
||||||
owner: UserId::from_uuid(local_user_id),
|
|
||||||
requester: identity,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(outbox) = outbox_url {
|
|
||||||
tracing::info!(outbox = %outbox, "importing remote outbox after follow accepted");
|
|
||||||
// Handled by FollowBackfillHandler reacting to FollowAccepted
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::DateTime;
|
|
||||||
use domain::{
|
|
||||||
models::RemoteGoalEntry,
|
|
||||||
ports::{GoalQuery, RemoteGoalRepository},
|
|
||||||
value_objects::UserId,
|
|
||||||
};
|
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::objects::{GoalObject, goal_to_ap_object};
|
|
||||||
use crate::urls::{actor_url, goal_url};
|
|
||||||
|
|
||||||
pub struct GoalObjectHandler {
|
|
||||||
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
|
|
||||||
pub goal_repo: Arc<dyn GoalQuery>,
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApContentReader for GoalObjectHandler {
|
|
||||||
async fn get_local_objects_page(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
_before: Option<DateTime<chrono::Utc>>,
|
|
||||||
_limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<LocalObject>> {
|
|
||||||
let uid = UserId::from_uuid(user_id);
|
|
||||||
let goals = self
|
|
||||||
.goal_repo
|
|
||||||
.list_for_user(&uid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let actor = actor_url(&self.base_url, user_id);
|
|
||||||
let follower_cc = format!("{}/followers", actor);
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for goal in goals {
|
|
||||||
let ap_id = goal_url(&self.base_url, user_id, goal.year());
|
|
||||||
let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc);
|
|
||||||
let obj = goal_to_ap_object(
|
|
||||||
ap_id.clone(),
|
|
||||||
actor.clone(),
|
|
||||||
goal.year(),
|
|
||||||
goal.target_count(),
|
|
||||||
0,
|
|
||||||
&self.base_url,
|
|
||||||
);
|
|
||||||
results.push(LocalObject {
|
|
||||||
ap_id,
|
|
||||||
object: serde_json::to_value(obj)?,
|
|
||||||
published_at: published,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![follower_cc.clone()],
|
|
||||||
bto: vec![],
|
|
||||||
bcc: vec![],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApObjectHandler for GoalObjectHandler {
|
|
||||||
async fn on_create(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let obj: GoalObject = match serde_json::from_value(object) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Create: {}", e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let entry = RemoteGoalEntry {
|
|
||||||
ap_id: ap_id.as_str().to_string(),
|
|
||||||
actor_url: actor_url.as_str().to_string(),
|
|
||||||
year: obj.goal_year,
|
|
||||||
target_count: obj.goal_target,
|
|
||||||
current_count: obj.goal_current,
|
|
||||||
received_at: chrono::Utc::now(),
|
|
||||||
};
|
|
||||||
self.remote_goal_repo.save(entry).await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, year = obj.goal_year, "saved remote goal");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_update(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let obj: GoalObject = match serde_json::from_value(object) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Update: {}", e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if obj.attributed_to != *actor_url {
|
|
||||||
anyhow::bail!("goal Update actor does not match object attributed_to");
|
|
||||||
}
|
|
||||||
self.remote_goal_repo
|
|
||||||
.update_by_ap_id(ap_id.as_str(), obj.goal_target, obj.goal_current)
|
|
||||||
.await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, "updated remote goal progress");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.remote_goal_repo
|
|
||||||
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
|
|
||||||
.await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, "removed remote goal");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.remote_goal_repo
|
|
||||||
.remove_all_by_actor(actor_url.as_str())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +1,19 @@
|
|||||||
pub mod composite_handler;
|
|
||||||
pub mod event_handler;
|
pub mod event_handler;
|
||||||
pub mod federation_event_bridge;
|
|
||||||
pub mod goal_handler;
|
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
pub mod port;
|
pub mod port;
|
||||||
pub mod remote_review_repository;
|
pub mod remote_review_repository;
|
||||||
pub mod review_handler;
|
pub mod review_handler;
|
||||||
pub mod social_adapter;
|
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
pub mod user_adapter;
|
pub mod user_adapter;
|
||||||
pub mod watchlist_handler;
|
|
||||||
|
|
||||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
|
||||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
|
||||||
|
|
||||||
// Re-export the generic base types that callers need
|
// Re-export the generic base types that callers need
|
||||||
pub use k_ap::{
|
pub use activitypub_base::{
|
||||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository,
|
||||||
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use event_handler::ActivityPubEventHandler;
|
pub use event_handler::ActivityPubEventHandler;
|
||||||
pub use port::{ActivityPubPort, NoopActivityPubService};
|
pub use port::{ActivityPubPort, NoopActivityPubService};
|
||||||
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
pub use remote_review_repository::RemoteReviewRepository;
|
||||||
pub use review_handler::ReviewObjectHandler;
|
pub use review_handler::ReviewObjectHandler;
|
||||||
pub use social_adapter::CompositeSocialAdapter;
|
|
||||||
pub use user_adapter::DomainUserRepoAdapter;
|
pub use user_adapter::DomainUserRepoAdapter;
|
||||||
|
|
||||||
pub struct FederationRepos {
|
|
||||||
pub activity: std::sync::Arc<dyn ActivityRepository>,
|
|
||||||
pub follow: std::sync::Arc<dyn FollowRepository>,
|
|
||||||
pub actor: std::sync::Arc<dyn ActorRepository>,
|
|
||||||
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
|
|
||||||
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
|
||||||
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
|
||||||
pub remote_watchlist: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
|
||||||
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
|
||||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ActivityPubWire {
|
|
||||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
|
||||||
pub router: axum::Router,
|
|
||||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ActivityPubDeps {
|
|
||||||
pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
|
|
||||||
pub follow_repo: std::sync::Arc<dyn FollowRepository>,
|
|
||||||
pub actor_repo: std::sync::Arc<dyn ActorRepository>,
|
|
||||||
pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
|
|
||||||
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
|
||||||
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
|
||||||
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
|
||||||
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
|
||||||
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieQuery>,
|
|
||||||
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
|
||||||
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryQuery>,
|
|
||||||
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalQuery>,
|
|
||||||
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
|
||||||
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
|
||||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
|
||||||
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
|
||||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
|
||||||
pub base_url: String,
|
|
||||||
pub allow_registration: bool,
|
|
||||||
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|
||||||
let ActivityPubDeps {
|
|
||||||
activity_repo,
|
|
||||||
follow_repo,
|
|
||||||
actor_repo,
|
|
||||||
blocklist_repo,
|
|
||||||
review_store,
|
|
||||||
remote_watchlist_repo,
|
|
||||||
remote_goal_repo,
|
|
||||||
local_ap_content,
|
|
||||||
movie_repo,
|
|
||||||
review_repo,
|
|
||||||
diary_repo,
|
|
||||||
goal_repo,
|
|
||||||
stats_repo,
|
|
||||||
user_repo,
|
|
||||||
federation_settings,
|
|
||||||
follow_command: _,
|
|
||||||
follow_query: _,
|
|
||||||
base_url,
|
|
||||||
allow_registration,
|
|
||||||
event_publisher,
|
|
||||||
} = deps;
|
|
||||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
|
||||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
|
||||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
|
||||||
diary_repo,
|
|
||||||
review_store,
|
|
||||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
|
||||||
base_url: base_url.clone(),
|
|
||||||
});
|
|
||||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
|
||||||
remote_watchlist_repo,
|
|
||||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
|
||||||
base_url: base_url.clone(),
|
|
||||||
});
|
|
||||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
|
||||||
remote_goal_repo,
|
|
||||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
|
||||||
base_url: base_url.clone(),
|
|
||||||
});
|
|
||||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
|
||||||
review: review_handler,
|
|
||||||
watchlist: watchlist_handler,
|
|
||||||
goal: goal_handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
let federation_debug = std::env::var("FEDERATION_DEBUG")
|
|
||||||
.map(|v| v == "true" || v == "1")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if federation_debug {
|
|
||||||
tracing::warn!(
|
|
||||||
"federation running in DEBUG mode — PermissiveVerifier active, \
|
|
||||||
no URL/signature validation. Do NOT use in production."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fed_event_bridge = std::sync::Arc::new(
|
|
||||||
federation_event_bridge::FederationEventBridge::new(event_publisher),
|
|
||||||
);
|
|
||||||
|
|
||||||
let concrete = std::sync::Arc::new(
|
|
||||||
ActivityPubService::builder(base_url.clone())
|
|
||||||
.activity_repo(activity_repo)
|
|
||||||
.follow_repo(follow_repo)
|
|
||||||
.actor_repo(actor_repo)
|
|
||||||
.blocklist_repo(blocklist_repo)
|
|
||||||
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
|
||||||
user_repo,
|
|
||||||
base_url.clone(),
|
|
||||||
)))
|
|
||||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
|
|
||||||
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
|
|
||||||
.object_handler(composite as std::sync::Arc<dyn ApObjectHandler>)
|
|
||||||
.event_publisher(fed_event_bridge)
|
|
||||||
.allow_registration(allow_registration)
|
|
||||||
.software_name("movies-diary")
|
|
||||||
.nodeinfo_metadata(serde_json::json!({
|
|
||||||
"nodeName": "movies-diary",
|
|
||||||
"nodeDescription": "A federated movie diary"
|
|
||||||
}))
|
|
||||||
.debug(federation_debug)
|
|
||||||
.build()
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let router = concrete.router();
|
|
||||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
|
||||||
std::sync::Arc::clone(&concrete),
|
|
||||||
local_ap_content,
|
|
||||||
review_repo,
|
|
||||||
movie_repo,
|
|
||||||
goal_repo,
|
|
||||||
stats_repo,
|
|
||||||
federation_settings,
|
|
||||||
base_url,
|
|
||||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
|
||||||
|
|
||||||
Ok(ActivityPubWire {
|
|
||||||
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
|
|
||||||
router,
|
|
||||||
event_handler,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,46 +1,15 @@
|
|||||||
|
use activitypub_federation::kinds::object::NoteType;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use k_ap::AS_PUBLIC;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use domain::models::Review;
|
use domain::models::Review;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub(crate) enum ActivityStreamsType {
|
|
||||||
#[default]
|
|
||||||
Note,
|
|
||||||
Article,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ApAttachment {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub(crate) kind: String,
|
|
||||||
pub(crate) url: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub(crate) media_type: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub(crate) name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ApHashtag {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub(crate) kind: String,
|
|
||||||
pub(crate) href: Url,
|
|
||||||
pub(crate) name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn normalize_hashtag(title: &str) -> String {
|
|
||||||
title.chars().filter(|c| c.is_alphanumeric()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ReviewObject {
|
pub struct ReviewObject {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub(crate) kind: ActivityStreamsType,
|
pub(crate) kind: NoteType,
|
||||||
pub(crate) id: Url,
|
pub(crate) id: Url,
|
||||||
pub(crate) attributed_to: Url,
|
pub(crate) attributed_to: Url,
|
||||||
pub(crate) content: String,
|
pub(crate) content: String,
|
||||||
@@ -49,47 +18,22 @@ pub struct ReviewObject {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) release_year: u16,
|
pub(crate) release_year: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) external_metadata_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) poster_url: Option<String>,
|
pub(crate) poster_url: Option<String>,
|
||||||
pub(crate) rating: u8,
|
pub(crate) rating: u8,
|
||||||
pub(crate) comment: Option<String>,
|
pub(crate) comment: Option<String>,
|
||||||
pub(crate) watched_at: DateTime<Utc>,
|
pub(crate) watched_at: DateTime<Utc>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
||||||
pub(crate) watch_medium: Option<String>,
|
|
||||||
/// Discriminator so Movies Diary instances detect this as a review Note.
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) review: bool,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) attachment: Vec<ApAttachment>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) tag: Vec<ApHashtag>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ReviewApInput {
|
/// Serialize a local Review into a ReviewObject for AP delivery.
|
||||||
pub ap_id: Url,
|
/// Takes movie metadata explicitly since the handler fetches it separately.
|
||||||
pub actor_url: Url,
|
pub fn review_to_ap_object(
|
||||||
pub movie_title: String,
|
review: &Review,
|
||||||
pub release_year: u16,
|
ap_id: Url,
|
||||||
pub external_metadata_id: Option<String>,
|
actor_url: Url,
|
||||||
pub poster_url: Option<String>,
|
movie_title: String,
|
||||||
pub base_url: String,
|
release_year: u16,
|
||||||
}
|
poster_url: Option<String>,
|
||||||
|
) -> ReviewObject {
|
||||||
pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObject {
|
|
||||||
let ReviewApInput {
|
|
||||||
ap_id,
|
|
||||||
actor_url,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
poster_url,
|
|
||||||
base_url,
|
|
||||||
} = input;
|
|
||||||
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
||||||
let comment_text = review.comment().map(|c| c.value().to_string());
|
let comment_text = review.comment().map(|c| c.value().to_string());
|
||||||
let year_str = if release_year > 0 {
|
let year_str = if release_year > 0 {
|
||||||
@@ -106,198 +50,17 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
|||||||
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
|
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalized = normalize_hashtag(&movie_title);
|
|
||||||
let tag = vec![
|
|
||||||
ApHashtag {
|
|
||||||
kind: "Hashtag".to_string(),
|
|
||||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
|
|
||||||
name: "#MoviesDiary".to_string(),
|
|
||||||
},
|
|
||||||
ApHashtag {
|
|
||||||
kind: "Hashtag".to_string(),
|
|
||||||
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
|
|
||||||
.expect("valid base_url"),
|
|
||||||
name: format!("#{}", normalized),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let attachment = match &poster_url {
|
|
||||||
Some(url) => vec![ApAttachment {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url: url.clone(),
|
|
||||||
media_type: Some("image/jpeg".to_string()),
|
|
||||||
name: Some(movie_title.clone()),
|
|
||||||
}],
|
|
||||||
None => vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
ReviewObject {
|
ReviewObject {
|
||||||
kind: ActivityStreamsType::default(),
|
kind: NoteType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url.clone(),
|
attributed_to: actor_url,
|
||||||
content,
|
content,
|
||||||
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
||||||
movie_title,
|
movie_title,
|
||||||
release_year,
|
release_year,
|
||||||
external_metadata_id,
|
|
||||||
poster_url,
|
poster_url,
|
||||||
rating: review.rating().value(),
|
rating: review.rating().value(),
|
||||||
comment: comment_text,
|
comment: comment_text,
|
||||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
|
||||||
review: true,
|
|
||||||
attachment,
|
|
||||||
tag,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![format!("{}/followers", actor_url)],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct WatchlistObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub(crate) kind: ActivityStreamsType,
|
|
||||||
pub(crate) id: Url,
|
|
||||||
pub(crate) attributed_to: Url,
|
|
||||||
pub(crate) content: String,
|
|
||||||
pub(crate) published: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub(crate) movie_title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) release_year: u16,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) external_metadata_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) poster_url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) tag: Vec<ApHashtag>,
|
|
||||||
/// Discriminator so Movies Diary instances distinguish this from a review Note.
|
|
||||||
/// Non-Movies-Diary apps ignore unknown fields.
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) watchlist_entry: bool,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct WatchlistApInput {
|
|
||||||
pub ap_id: Url,
|
|
||||||
pub actor_url: Url,
|
|
||||||
pub movie_title: String,
|
|
||||||
pub release_year: u16,
|
|
||||||
pub external_metadata_id: Option<String>,
|
|
||||||
pub poster_url: Option<String>,
|
|
||||||
pub added_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
|
||||||
let WatchlistApInput {
|
|
||||||
ap_id,
|
|
||||||
actor_url,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
poster_url,
|
|
||||||
added_at,
|
|
||||||
base_url,
|
|
||||||
} = input;
|
|
||||||
let year_str = if release_year > 0 {
|
|
||||||
format!(" ({})", release_year)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let content = format!("📋 {}{} — want to watch", movie_title, year_str);
|
|
||||||
let normalized = normalize_hashtag(&movie_title);
|
|
||||||
let tag = vec![
|
|
||||||
ApHashtag {
|
|
||||||
kind: "Hashtag".to_string(),
|
|
||||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
|
|
||||||
name: "#MoviesDiary".to_string(),
|
|
||||||
},
|
|
||||||
ApHashtag {
|
|
||||||
kind: "Hashtag".to_string(),
|
|
||||||
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
|
|
||||||
.expect("valid base_url"),
|
|
||||||
name: format!("#{}", normalized),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
WatchlistObject {
|
|
||||||
kind: ActivityStreamsType::default(),
|
|
||||||
id: ap_id,
|
|
||||||
attributed_to: actor_url.clone(),
|
|
||||||
content,
|
|
||||||
published: added_at,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
poster_url,
|
|
||||||
tag,
|
|
||||||
watchlist_entry: true,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![format!("{}/followers", actor_url)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Goal object ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct GoalObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub(crate) kind: ActivityStreamsType,
|
|
||||||
pub(crate) id: Url,
|
|
||||||
pub(crate) attributed_to: Url,
|
|
||||||
pub(crate) content: String,
|
|
||||||
pub(crate) published: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub(crate) goal_year: u16,
|
|
||||||
pub(crate) goal_target: u32,
|
|
||||||
pub(crate) goal_current: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) goal: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) tag: Vec<ApHashtag>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) to: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
pub(crate) cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn goal_to_ap_object(
|
|
||||||
ap_id: Url,
|
|
||||||
actor_url: Url,
|
|
||||||
year: u16,
|
|
||||||
target: u32,
|
|
||||||
current: u32,
|
|
||||||
base_url: &str,
|
|
||||||
) -> GoalObject {
|
|
||||||
let content = format!(
|
|
||||||
"🎯 Goal: Watch {} movies in {} ({}/{})",
|
|
||||||
target, year, current, target
|
|
||||||
);
|
|
||||||
let tag = vec![ApHashtag {
|
|
||||||
kind: "Hashtag".to_string(),
|
|
||||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
|
|
||||||
name: "#MoviesDiary".to_string(),
|
|
||||||
}];
|
|
||||||
|
|
||||||
GoalObject {
|
|
||||||
kind: ActivityStreamsType::default(),
|
|
||||||
id: ap_id,
|
|
||||||
attributed_to: actor_url.clone(),
|
|
||||||
content,
|
|
||||||
published: chrono::Utc::now(),
|
|
||||||
goal_year: year,
|
|
||||||
goal_target: target,
|
|
||||||
goal_current: current,
|
|
||||||
goal: true,
|
|
||||||
tag,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![format!("{}/followers", actor_url)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/objects.rs"]
|
|
||||||
mod tests;
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
|
use activitypub_base::{ActivityPubService, RemoteActor};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ActivityPubPort: Send + Sync {
|
pub trait ActivityPubPort: Send + Sync {
|
||||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
|
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
|
||||||
|
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||||
|
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||||
|
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
|
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
|
||||||
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||||
async fn accept_follower(
|
async fn accept_follower(
|
||||||
@@ -19,29 +22,9 @@ pub trait ActivityPubPort: Send + Sync {
|
|||||||
remote_actor_url: &str,
|
remote_actor_url: &str,
|
||||||
) -> anyhow::Result<()>;
|
) -> anyhow::Result<()>;
|
||||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||||
|
async fn get_accepted_followers(&self, local_user_id: Uuid)
|
||||||
|
-> anyhow::Result<Vec<RemoteActor>>;
|
||||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
|
||||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
|
||||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
|
||||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
|
||||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
|
||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
|
||||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
|
|
||||||
async fn followers_collection_json(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> anyhow::Result<String>;
|
|
||||||
async fn following_collection_json(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> anyhow::Result<String>;
|
|
||||||
async fn run_backfill_for_follower(
|
|
||||||
&self,
|
|
||||||
owner_user_id: Uuid,
|
|
||||||
follower_inbox_url: String,
|
|
||||||
) -> anyhow::Result<()>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -49,6 +32,15 @@ impl ActivityPubPort for ActivityPubService {
|
|||||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
|
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
|
||||||
self.actor_json(user_id).await
|
self.actor_json(user_id).await
|
||||||
}
|
}
|
||||||
|
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||||
|
self.count_following(local_user_id).await
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||||
|
self.count_accepted_followers(local_user_id).await
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
self.get_pending_followers(local_user_id).await
|
||||||
|
}
|
||||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
|
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
|
||||||
self.follow(local_user_id, handle).await
|
self.follow(local_user_id, handle).await
|
||||||
}
|
}
|
||||||
@@ -72,52 +64,15 @@ impl ActivityPubPort for ActivityPubService {
|
|||||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
self.get_following(local_user_id).await
|
self.get_following(local_user_id).await
|
||||||
}
|
}
|
||||||
|
async fn get_accepted_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: Uuid,
|
||||||
|
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
self.get_accepted_followers(local_user_id).await
|
||||||
|
}
|
||||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||||
self.remove_follower(local_user_id, actor_url).await
|
self.remove_follower(local_user_id, actor_url).await
|
||||||
}
|
}
|
||||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
|
||||||
self.block_actor(local_user_id, actor_url).await
|
|
||||||
}
|
|
||||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
|
||||||
self.unblock_actor(local_user_id, actor_url).await
|
|
||||||
}
|
|
||||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
|
||||||
self.get_blocked_actors(local_user_id).await
|
|
||||||
}
|
|
||||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
|
||||||
self.add_blocked_domain(domain, reason).await
|
|
||||||
}
|
|
||||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
|
||||||
self.remove_blocked_domain(domain).await
|
|
||||||
}
|
|
||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
|
||||||
self.get_blocked_domains().await
|
|
||||||
}
|
|
||||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
|
|
||||||
self.import_remote_outbox(outbox_url, actor_url).await
|
|
||||||
}
|
|
||||||
async fn followers_collection_json(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
self.followers_collection_json(user_id, page).await
|
|
||||||
}
|
|
||||||
async fn following_collection_json(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
self.following_collection_json(user_id, page).await
|
|
||||||
}
|
|
||||||
async fn run_backfill_for_follower(
|
|
||||||
&self,
|
|
||||||
owner_user_id: Uuid,
|
|
||||||
follower_inbox_url: String,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NoopActivityPubService;
|
pub struct NoopActivityPubService;
|
||||||
@@ -127,6 +82,15 @@ impl ActivityPubPort for NoopActivityPubService {
|
|||||||
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
|
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -142,37 +106,10 @@ impl ActivityPubPort for NoopActivityPubService {
|
|||||||
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
|
||||||
Ok(String::new())
|
|
||||||
}
|
|
||||||
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
|
||||||
Ok(String::new())
|
|
||||||
}
|
|
||||||
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,6 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::models::Review;
|
use domain::models::Review;
|
||||||
|
|
||||||
pub struct RemoteReviewUpdate<'a> {
|
|
||||||
pub ap_id: &'a str,
|
|
||||||
pub actor_url: &'a str,
|
|
||||||
pub rating: u8,
|
|
||||||
pub comment: Option<&'a str>,
|
|
||||||
pub watched_at: NaiveDateTime,
|
|
||||||
pub poster_url: Option<&'a str>,
|
|
||||||
pub watch_medium: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RemoteReviewRepository: Send + Sync {
|
pub trait RemoteReviewRepository: Send + Sync {
|
||||||
async fn save_remote_review(
|
async fn save_remote_review(
|
||||||
@@ -21,13 +11,19 @@ pub trait RemoteReviewRepository: Send + Sync {
|
|||||||
ap_id: &str,
|
ap_id: &str,
|
||||||
movie_title: &str,
|
movie_title: &str,
|
||||||
release_year: u16,
|
release_year: u16,
|
||||||
external_metadata_id: Option<&str>,
|
|
||||||
poster_url: Option<&str>,
|
poster_url: Option<&str>,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||||
|
|
||||||
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
|
async fn update_remote_review(
|
||||||
|
&self,
|
||||||
|
ap_id: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
rating: u8,
|
||||||
|
comment: Option<&str>,
|
||||||
|
watched_at: NaiveDateTime,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +1,119 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use activitypub_base::ApObjectHandler;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
models::{Review, ReviewSource},
|
||||||
models::ReviewSource,
|
ports::{DiaryRepository, MovieRepository},
|
||||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
|
||||||
};
|
};
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
|
use crate::objects::{ReviewObject, review_to_ap_object};
|
||||||
use crate::remote_review_repository::RemoteReviewRepository;
|
use crate::remote_review_repository::RemoteReviewRepository;
|
||||||
use crate::urls::{actor_url, review_url};
|
use crate::urls::{actor_url, review_url};
|
||||||
|
|
||||||
pub struct ReviewObjectHandler {
|
pub struct ReviewObjectHandler {
|
||||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
pub movie_repository: Arc<dyn MovieRepository>,
|
||||||
pub movie_repo: Arc<dyn MovieQuery>,
|
pub diary_repository: Arc<dyn DiaryRepository>,
|
||||||
pub diary_repo: Arc<dyn DiaryQuery>,
|
|
||||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApContentReader for ReviewObjectHandler {
|
impl ApObjectHandler for ReviewObjectHandler {
|
||||||
async fn get_local_objects_page(
|
async fn get_local_objects_for_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<LocalObject>> {
|
|
||||||
let domain_user_id = UserId::from_uuid(user_id);
|
let domain_user_id = UserId::from_uuid(user_id);
|
||||||
let before_naive = before.map(|dt| dt.naive_utc());
|
let history = self
|
||||||
let entries = self
|
.diary_repository
|
||||||
.content_query
|
.get_user_history(&domain_user_id)
|
||||||
.get_local_reviews_page(&domain_user_id, before_naive, limit)
|
.await?;
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let actor = actor_url(&self.base_url, user_id);
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for entry in entries {
|
for entry in history {
|
||||||
let review = entry.review();
|
let review = entry.review();
|
||||||
let published =
|
if !matches!(review.source(), ReviewSource::Local) {
|
||||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
continue;
|
||||||
let movie = entry.movie();
|
}
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review.id());
|
let ap_id = review_url(&self.base_url, review.id());
|
||||||
|
let actor_url = actor_url(&self.base_url, user_id);
|
||||||
|
|
||||||
|
let movie = self
|
||||||
|
.movie_repository
|
||||||
|
.get_movie_by_id(review.movie_id())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let movie_title = movie
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.title().value().to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let release_year = movie
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.release_year().value())
|
||||||
|
.unwrap_or(0);
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.poster_path()
|
.as_ref()
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
.and_then(|m| m.poster_path())
|
||||||
|
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
|
||||||
|
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
review,
|
review,
|
||||||
ReviewApInput {
|
ap_id.clone(),
|
||||||
ap_id: ap_id.clone(),
|
actor_url,
|
||||||
actor_url: actor.clone(),
|
movie_title,
|
||||||
movie_title: movie.title().value().to_string(),
|
release_year,
|
||||||
release_year: movie.release_year().value(),
|
poster_url,
|
||||||
external_metadata_id: movie
|
|
||||||
.external_metadata_id()
|
|
||||||
.map(|id| id.value().to_string()),
|
|
||||||
poster_url,
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let follower_cc = format!("{}/followers", actor);
|
let json = serde_json::to_value(obj)?;
|
||||||
results.push(LocalObject {
|
results.push((ap_id, json));
|
||||||
ap_id,
|
|
||||||
object: serde_json::to_value(obj)?,
|
|
||||||
published_at: published,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![follower_cc],
|
|
||||||
bto: vec![],
|
|
||||||
bcc: vec![],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
self.diary_repo
|
|
||||||
.count_local_posts()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApObjectHandler for ReviewObjectHandler {
|
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &Url,
|
_ap_id: &Url,
|
||||||
_actor_url: &Url,
|
_actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut obj: ReviewObject = match serde_json::from_value(object) {
|
let obj: ReviewObject = match serde_json::from_value(object) {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("ignoring unrecognized Create object: {}", e);
|
tracing::debug!("ignoring unrecognized Create object: {}", e);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
obj.movie_title = ammonia::clean(&obj.movie_title);
|
|
||||||
obj.comment = obj.comment.map(|c| ammonia::clean(&c));
|
|
||||||
|
|
||||||
let actor_url_str = obj.attributed_to.to_string();
|
let actor_url_str = obj.attributed_to.to_string();
|
||||||
let review_id = ReviewId::generate();
|
let review_id = ReviewId::generate();
|
||||||
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
|
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||||
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
|
&uuid::Uuid::NAMESPACE_URL,
|
||||||
self.movie_repo
|
obj.movie_title.as_bytes(),
|
||||||
.get_movie_by_external_id(&ext_meta_id)
|
));
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
match found {
|
|
||||||
Some(movie) => movie.id().clone(),
|
|
||||||
None => MovieId::from_uuid(uuid::Uuid::new_v5(
|
|
||||||
&uuid::Uuid::NAMESPACE_URL,
|
|
||||||
ext_id.as_bytes(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
MovieId::from_uuid(uuid::Uuid::new_v5(
|
|
||||||
&uuid::Uuid::NAMESPACE_URL,
|
|
||||||
format!("{}:{}", obj.movie_title, obj.release_year).as_bytes(),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
|
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
|
||||||
&uuid::Uuid::NAMESPACE_URL,
|
&uuid::Uuid::NAMESPACE_URL,
|
||||||
actor_url_str.as_bytes(),
|
actor_url_str.as_bytes(),
|
||||||
));
|
));
|
||||||
let rating = Rating::new(obj.rating.min(5))?;
|
let rating = Rating::new(obj.rating.min(5))?;
|
||||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
let comment = obj.comment.map(Comment::new).transpose()?;
|
||||||
let watch_medium = obj
|
|
||||||
.watch_medium
|
|
||||||
.as_deref()
|
|
||||||
.map(|s| s.parse())
|
|
||||||
.transpose()
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
let review = Review::from_persistence(
|
||||||
id: review_id,
|
review_id,
|
||||||
movie_id: movie_id.clone(),
|
movie_id,
|
||||||
user_id,
|
user_id,
|
||||||
rating,
|
rating,
|
||||||
comment,
|
comment,
|
||||||
watched_at: obj.watched_at.naive_utc(),
|
obj.watched_at.naive_utc(),
|
||||||
created_at: obj.published.naive_utc(),
|
obj.published.naive_utc(),
|
||||||
source: ReviewSource::Remote {
|
ReviewSource::Remote {
|
||||||
actor_url: actor_url_str,
|
actor_url: actor_url_str,
|
||||||
},
|
},
|
||||||
watch_medium,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.save_remote_review(
|
.save_remote_review(
|
||||||
@@ -163,23 +121,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
obj.id.as_str(),
|
obj.id.as_str(),
|
||||||
&obj.movie_title,
|
&obj.movie_title,
|
||||||
obj.release_year,
|
obj.release_year,
|
||||||
obj.external_metadata_id.as_deref(),
|
|
||||||
obj.poster_url.as_deref(),
|
obj.poster_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(ref ext_id_str) = obj.external_metadata_id
|
|
||||||
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
|
|
||||||
{
|
|
||||||
let _ = self
|
|
||||||
.event_publisher
|
|
||||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
|
||||||
movie_id: movie_id.clone(),
|
|
||||||
external_metadata_id,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,30 +134,26 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut obj: ReviewObject = match serde_json::from_value(object) {
|
let obj: ReviewObject = match serde_json::from_value(object) {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!(actor = %actor_url, "ignoring non-review Update activity");
|
tracing::debug!(actor = %actor_url, "ignoring non-review Update activity");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
obj.movie_title = ammonia::clean(&obj.movie_title);
|
|
||||||
obj.comment = obj.comment.map(|c| ammonia::clean(&c));
|
|
||||||
|
|
||||||
if obj.attributed_to != *actor_url {
|
if obj.attributed_to != *actor_url {
|
||||||
anyhow::bail!("update actor does not match object attributed_to");
|
anyhow::bail!("update actor does not match object attributed_to");
|
||||||
}
|
}
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
|
.update_remote_review(
|
||||||
ap_id: ap_id.as_str(),
|
ap_id.as_str(),
|
||||||
actor_url: actor_url.as_str(),
|
actor_url.as_str(),
|
||||||
rating: obj.rating.min(5),
|
obj.rating.min(5),
|
||||||
comment: obj.comment.as_deref(),
|
obj.comment.as_deref(),
|
||||||
watched_at: obj.watched_at.naive_utc(),
|
obj.watched_at.naive_utc(),
|
||||||
poster_url: obj.poster_url.as_deref(),
|
)
|
||||||
watch_medium: obj.watch_medium.as_deref(),
|
|
||||||
})
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -227,37 +168,4 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
||||||
self.review_store.delete_by_actor(actor_url.as_str()).await
|
self.review_store.delete_by_actor(actor_url.as_str()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_announce_received(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_announce_of_remote(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_mention(
|
|
||||||
&self,
|
|
||||||
_thought_ap_id: &Url,
|
|
||||||
_mentioned_user_uuid: uuid::Uuid,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
|
|
||||||
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::ActivityPubPort;
|
|
||||||
|
|
||||||
pub struct CompositeSocialAdapter {
|
|
||||||
ap_service: Arc<dyn ActivityPubPort>,
|
|
||||||
user_repo: Arc<dyn UserRepository>,
|
|
||||||
follow_command: Arc<dyn FollowCommand>,
|
|
||||||
follow_query: Arc<dyn FollowQuery>,
|
|
||||||
base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CompositeSocialAdapter {
|
|
||||||
pub fn new(
|
|
||||||
ap_service: Arc<dyn ActivityPubPort>,
|
|
||||||
user_repo: Arc<dyn UserRepository>,
|
|
||||||
follow_command: Arc<dyn FollowCommand>,
|
|
||||||
follow_query: Arc<dyn FollowQuery>,
|
|
||||||
base_url: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
ap_service,
|
|
||||||
user_repo,
|
|
||||||
follow_command,
|
|
||||||
follow_query,
|
|
||||||
base_url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_actor_url(&self, user_id: &UserId) -> String {
|
|
||||||
format!("{}/users/{}", self.base_url, user_id.value())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String {
|
|
||||||
match identity {
|
|
||||||
SocialIdentity::Local(uid) => self.local_actor_url(uid),
|
|
||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_target_identity(
|
|
||||||
&self,
|
|
||||||
target: &FollowTarget,
|
|
||||||
) -> Result<SocialIdentity, DomainError> {
|
|
||||||
match target {
|
|
||||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
|
||||||
FollowTarget::Handle(handle) => {
|
|
||||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
|
||||||
let local_host = SocialIdentity::host_from_base_url(&self.base_url);
|
|
||||||
if host == local_host {
|
|
||||||
let username_str = handle
|
|
||||||
.trim_start_matches('@')
|
|
||||||
.split('@')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
if let Ok(username) = Username::new(username_str.to_string())
|
|
||||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
|
||||||
{
|
|
||||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(SocialIdentity::Remote {
|
|
||||||
actor_url: handle.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ap_err(e: anyhow::Error) -> DomainError {
|
|
||||||
DomainError::InfrastructureError(e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SocialCommand for CompositeSocialAdapter {
|
|
||||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
|
||||||
let identity = self.resolve_target_identity(target).await?;
|
|
||||||
|
|
||||||
if let SocialIdentity::Local(ref target_id) = identity {
|
|
||||||
if follower == target_id {
|
|
||||||
return Err(DomainError::ValidationError(
|
|
||||||
"Cannot follow yourself".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let follower_url = self.local_actor_url(follower);
|
|
||||||
let target_url = self.local_actor_url(target_id);
|
|
||||||
self.follow_command
|
|
||||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = match target {
|
|
||||||
FollowTarget::Handle(h) => h.clone(),
|
|
||||||
FollowTarget::Identity(id) => match id {
|
|
||||||
SocialIdentity::Local(uid) => {
|
|
||||||
let user = self
|
|
||||||
.user_repo
|
|
||||||
.find_by_id(uid)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
|
||||||
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
self.ap_service
|
|
||||||
.follow(follower.value(), &handle)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unfollow(
|
|
||||||
&self,
|
|
||||||
follower: &UserId,
|
|
||||||
target: &SocialIdentity,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
match target {
|
|
||||||
SocialIdentity::Local(target_id) => {
|
|
||||||
let follower_url = self.local_actor_url(follower);
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(follower.value(), &actor_url)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follower_record(target_id.value(), &follower_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
|
||||||
.ap_service
|
|
||||||
.unfollow(follower.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn accept_follow(
|
|
||||||
&self,
|
|
||||||
owner: &UserId,
|
|
||||||
requester: &SocialIdentity,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(requester);
|
|
||||||
match requester {
|
|
||||||
SocialIdentity::Local(requester_id) => {
|
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
|
||||||
.ap_service
|
|
||||||
.accept_follower(owner.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reject_follow(
|
|
||||||
&self,
|
|
||||||
owner: &UserId,
|
|
||||||
requester: &SocialIdentity,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(requester);
|
|
||||||
match requester {
|
|
||||||
SocialIdentity::Local(requester_id) => {
|
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(requester_id.value(), &owner_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
|
||||||
.ap_service
|
|
||||||
.reject_follower(owner.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_follower(
|
|
||||||
&self,
|
|
||||||
owner: &UserId,
|
|
||||||
follower: &SocialIdentity,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(follower);
|
|
||||||
match follower {
|
|
||||||
SocialIdentity::Local(follower_id) => {
|
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.remove_follower_record(owner.value(), &actor_url)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(follower_id.value(), &owner_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
|
||||||
.ap_service
|
|
||||||
.remove_follower(owner.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
self.ap_service
|
|
||||||
.block_actor(blocker.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
self.ap_service
|
|
||||||
.unblock_actor(blocker.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(ap_err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SocialQuery for CompositeSocialAdapter {
|
|
||||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
self.follow_query
|
|
||||||
.get_following(user.value(), &self.base_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
self.follow_query
|
|
||||||
.get_followers(user.value(), &self.base_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
self.follow_query
|
|
||||||
.get_pending_followers(user.value(), &self.base_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
|
||||||
self.follow_query.count_following(user.value()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
|
||||||
self.follow_query.count_followers(user.value()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
let actors = self
|
|
||||||
.ap_service
|
|
||||||
.get_blocked_actors(user.value())
|
|
||||||
.await
|
|
||||||
.map_err(ap_err)?;
|
|
||||||
Ok(actors
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| {
|
|
||||||
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
|
|
||||||
SocialActor {
|
|
||||||
identity,
|
|
||||||
handle: a.handle,
|
|
||||||
display_name: a.display_name,
|
|
||||||
avatar_url: a.avatar_url,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_following(
|
|
||||||
&self,
|
|
||||||
follower: &UserId,
|
|
||||||
target: &SocialIdentity,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
self.follow_query
|
|
||||||
.is_following(follower.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
|
||||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
|
||||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
|
||||||
assert_eq!(
|
|
||||||
normalize_hashtag("2001: A Space Odyssey"),
|
|
||||||
"2001ASpaceOdyssey"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn review_to_ap_object_includes_two_hashtags() {
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use domain::{
|
|
||||||
models::{PersistedReview, Review, ReviewSource},
|
|
||||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
|
||||||
};
|
|
||||||
|
|
||||||
let review = Review::from_persistence(PersistedReview {
|
|
||||||
id: ReviewId::generate(),
|
|
||||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
|
||||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
|
||||||
rating: Rating::new(4).unwrap(),
|
|
||||||
comment: None,
|
|
||||||
watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
|
||||||
.unwrap(),
|
|
||||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
|
||||||
.unwrap(),
|
|
||||||
source: ReviewSource::Local,
|
|
||||||
watch_medium: None,
|
|
||||||
});
|
|
||||||
let obj = review_to_ap_object(
|
|
||||||
&review,
|
|
||||||
ReviewApInput {
|
|
||||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
|
||||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
|
||||||
movie_title: "Dune".to_string(),
|
|
||||||
release_year: 2021,
|
|
||||||
external_metadata_id: None,
|
|
||||||
poster_url: None,
|
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(obj.tag.len(), 2);
|
|
||||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
|
||||||
assert!(names.contains(&"#MoviesDiary"));
|
|
||||||
assert!(names.contains(&"#Dune"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn review_to_ap_object_has_public_addressing() {
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use domain::{
|
|
||||||
models::{PersistedReview, Review, ReviewSource},
|
|
||||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
|
||||||
};
|
|
||||||
|
|
||||||
let review = Review::from_persistence(PersistedReview {
|
|
||||||
id: ReviewId::generate(),
|
|
||||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
|
||||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
|
||||||
rating: Rating::new(3).unwrap(),
|
|
||||||
comment: None,
|
|
||||||
watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
|
||||||
.unwrap(),
|
|
||||||
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
|
||||||
.unwrap(),
|
|
||||||
source: ReviewSource::Local,
|
|
||||||
watch_medium: None,
|
|
||||||
});
|
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
|
||||||
let obj = review_to_ap_object(
|
|
||||||
&review,
|
|
||||||
ReviewApInput {
|
|
||||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
|
||||||
actor_url: actor_url.clone(),
|
|
||||||
movie_title: "Dune".to_string(),
|
|
||||||
release_year: 2021,
|
|
||||||
external_metadata_id: None,
|
|
||||||
poster_url: None,
|
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
|
||||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watchlist_to_ap_object_has_public_addressing() {
|
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
|
||||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
|
||||||
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
|
|
||||||
actor_url: actor_url.clone(),
|
|
||||||
movie_title: "Alien".to_string(),
|
|
||||||
release_year: 1979,
|
|
||||||
external_metadata_id: None,
|
|
||||||
poster_url: None,
|
|
||||||
added_at: chrono::Utc::now(),
|
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
});
|
|
||||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
|
||||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
|
||||||
}
|
|
||||||
@@ -12,17 +12,3 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
|||||||
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
||||||
.expect("base_url is always a valid URL prefix")
|
.expect("base_url is always a valid URL prefix")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
|
|
||||||
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
|
|
||||||
.expect("base_url is always a valid URL prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
|
|
||||||
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
|
|
||||||
Url::parse(&format!(
|
|
||||||
"{}/users/{}/watchlist/{}",
|
|
||||||
base_url, user_id, movie_id
|
|
||||||
))
|
|
||||||
.expect("base_url is always a valid URL prefix")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,89 +1,28 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use activitypub_base::{ApUser, ApUserRepository};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{ports::UserRepository, value_objects::UserId};
|
use domain::{ports::UserRepository, value_objects::UserId};
|
||||||
use k_ap::{ApProfileField, ApUser, ApUserRepository};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
pub struct DomainUserRepoAdapter {
|
pub struct DomainUserRepoAdapter(pub Arc<dyn UserRepository>);
|
||||||
pub repo: Arc<dyn UserRepository>,
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DomainUserRepoAdapter {
|
|
||||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
|
||||||
Self { repo, base_url }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
|
||||||
let avatar_url = u
|
|
||||||
.avatar_path()
|
|
||||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
|
||||||
let banner_url = u
|
|
||||||
.banner_path()
|
|
||||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
|
||||||
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
|
||||||
ApUser {
|
|
||||||
id: u.id().value(),
|
|
||||||
username: u.username().value().to_string(),
|
|
||||||
display_name: u.display_name().map(|s| s.to_string()),
|
|
||||||
bio: u.bio().map(|s| s.to_string()),
|
|
||||||
avatar_url,
|
|
||||||
banner_url,
|
|
||||||
also_known_as: u
|
|
||||||
.also_known_as()
|
|
||||||
.map(|s| vec![s.to_string()])
|
|
||||||
.unwrap_or_default(),
|
|
||||||
profile_url,
|
|
||||||
attachment: u
|
|
||||||
.profile_fields()
|
|
||||||
.iter()
|
|
||||||
.map(|f| ApProfileField {
|
|
||||||
name: f.name.clone(),
|
|
||||||
value: f.value.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
manually_approves_followers: true,
|
|
||||||
discoverable: true,
|
|
||||||
actor_type: Default::default(),
|
|
||||||
featured_url: Url::parse(&format!(
|
|
||||||
"{}/users/{}/featured",
|
|
||||||
self.base_url,
|
|
||||||
u.id().value()
|
|
||||||
))
|
|
||||||
.ok(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApUserRepository for DomainUserRepoAdapter {
|
impl ApUserRepository for DomainUserRepoAdapter {
|
||||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
||||||
let user_id = UserId::from_uuid(id);
|
let user_id = UserId::from_uuid(id);
|
||||||
let user = match self.repo.find_by_id(&user_id).await? {
|
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
|
||||||
Some(u) => u,
|
id: u.id().value(),
|
||||||
None => return Ok(None),
|
username: u.username().value().to_string(),
|
||||||
};
|
}))
|
||||||
Ok(Some(self.build_user(&user)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
||||||
use domain::value_objects::Username;
|
use domain::value_objects::Username;
|
||||||
let uname =
|
let uname =
|
||||||
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||||
let user = match self.repo.find_by_username(&uname).await? {
|
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
|
||||||
Some(u) => u,
|
id: u.id().value(),
|
||||||
None => return Ok(None),
|
username: u.username().value().to_string(),
|
||||||
};
|
}))
|
||||||
Ok(Some(self.build_user(&user)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_users(&self) -> anyhow::Result<usize> {
|
|
||||||
Ok(self
|
|
||||||
.repo
|
|
||||||
.list_with_stats()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?
|
|
||||||
.len())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::DateTime;
|
|
||||||
use domain::{
|
|
||||||
models::{RemoteWatchlistEntry, WatchlistWithMovie},
|
|
||||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
|
||||||
value_objects::UserId,
|
|
||||||
};
|
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
|
|
||||||
use crate::urls::{actor_url, watchlist_entry_url};
|
|
||||||
|
|
||||||
pub struct WatchlistObjectHandler {
|
|
||||||
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
|
||||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApContentReader for WatchlistObjectHandler {
|
|
||||||
async fn get_local_objects_page(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
_before: Option<DateTime<chrono::Utc>>,
|
|
||||||
_limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<LocalObject>> {
|
|
||||||
let uid = UserId::from_uuid(user_id);
|
|
||||||
let entries = self
|
|
||||||
.content_query
|
|
||||||
.get_local_watchlist_for_user(&uid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let actor = actor_url(&self.base_url, user_id);
|
|
||||||
let follower_cc = format!("{}/followers", actor);
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for WatchlistWithMovie { entry, movie } in entries {
|
|
||||||
let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value());
|
|
||||||
let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc);
|
|
||||||
let poster_url = movie
|
|
||||||
.poster_path()
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
|
||||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
|
||||||
ap_id: ap_id.clone(),
|
|
||||||
actor_url: actor.clone(),
|
|
||||||
movie_title: movie.title().value().to_string(),
|
|
||||||
release_year: movie.release_year().value(),
|
|
||||||
external_metadata_id: movie
|
|
||||||
.external_metadata_id()
|
|
||||||
.map(|id| id.value().to_string()),
|
|
||||||
poster_url,
|
|
||||||
added_at: published,
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
});
|
|
||||||
results.push(LocalObject {
|
|
||||||
ap_id,
|
|
||||||
object: serde_json::to_value(obj)?,
|
|
||||||
published_at: published,
|
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![follower_cc.clone()],
|
|
||||||
bto: vec![],
|
|
||||||
bcc: vec![],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApObjectHandler for WatchlistObjectHandler {
|
|
||||||
async fn on_create(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut obj: WatchlistObject = match serde_json::from_value(object) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Create: {}", e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
obj.movie_title = ammonia::clean(&obj.movie_title);
|
|
||||||
let added_at = obj.published;
|
|
||||||
let entry = RemoteWatchlistEntry {
|
|
||||||
ap_id: ap_id.as_str().to_string(),
|
|
||||||
actor_url: actor_url.as_str().to_string(),
|
|
||||||
movie_title: obj.movie_title,
|
|
||||||
release_year: obj.release_year,
|
|
||||||
external_metadata_id: obj.external_metadata_id,
|
|
||||||
poster_url: obj.poster_url,
|
|
||||||
added_at,
|
|
||||||
};
|
|
||||||
self.remote_watchlist_repo.save(entry).await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, "saved remote watchlist entry");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_update(
|
|
||||||
&self,
|
|
||||||
ap_id: &Url,
|
|
||||||
actor_url: &Url,
|
|
||||||
object: serde_json::Value,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut obj: WatchlistObject = match serde_json::from_value(object) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Update: {}", e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if obj.attributed_to != *actor_url {
|
|
||||||
anyhow::bail!("watchlist Update actor does not match object attributed_to");
|
|
||||||
}
|
|
||||||
obj.movie_title = ammonia::clean(&obj.movie_title);
|
|
||||||
let entry = RemoteWatchlistEntry {
|
|
||||||
ap_id: ap_id.as_str().to_string(),
|
|
||||||
actor_url: actor_url.as_str().to_string(),
|
|
||||||
movie_title: obj.movie_title,
|
|
||||||
release_year: obj.release_year,
|
|
||||||
external_metadata_id: obj.external_metadata_id,
|
|
||||||
poster_url: obj.poster_url,
|
|
||||||
added_at: obj.published,
|
|
||||||
};
|
|
||||||
self.remote_watchlist_repo.save(entry).await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, "updated remote watchlist entry");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.remote_watchlist_repo
|
|
||||||
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
|
|
||||||
.await?;
|
|
||||||
tracing::info!(ap_id = %ap_id, "removed remote watchlist entry");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
self.remote_watchlist_repo
|
|
||||||
.remove_all_by_actor(actor_url.as_str())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "adapter-common"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
domain = { workspace = true }
|
|
||||||
sqlx = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
models::{
|
|
||||||
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
|
|
||||||
WatchlistEntry, WatchlistWithMovie,
|
|
||||||
},
|
|
||||||
value_objects::{
|
|
||||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
|
||||||
ReviewId, UserId, Username, WatchlistEntryId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Map a [`sqlx::Error`] to a [`DomainError::InfrastructureError`], logging the
|
|
||||||
/// underlying database error at `error` level.
|
|
||||||
pub fn map_sqlx_error(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a string as a UUID, returning a [`DomainError`] on failure.
|
|
||||||
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
|
|
||||||
uuid::Uuid::parse_str(s)
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a `%Y-%m-%d %H:%M:%S` string into a [`chrono::NaiveDateTime`].
|
|
||||||
pub fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
|
||||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a [`chrono::NaiveDateTime`] as `%Y-%m-%d %H:%M:%S`.
|
|
||||||
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
|
|
||||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a `YYYY-MM` string into a human-readable label like `Jan '24`.
|
|
||||||
pub fn format_year_month(ym: &str) -> String {
|
|
||||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
|
||||||
if parts.len() != 2 {
|
|
||||||
return ym.to_string();
|
|
||||||
}
|
|
||||||
let year = parts[0].get(2..).unwrap_or(parts[0]);
|
|
||||||
let month = match parts[1] {
|
|
||||||
"01" => "Jan",
|
|
||||||
"02" => "Feb",
|
|
||||||
"03" => "Mar",
|
|
||||||
"04" => "Apr",
|
|
||||||
"05" => "May",
|
|
||||||
"06" => "Jun",
|
|
||||||
"07" => "Jul",
|
|
||||||
"08" => "Aug",
|
|
||||||
"09" => "Sep",
|
|
||||||
"10" => "Oct",
|
|
||||||
"11" => "Nov",
|
|
||||||
"12" => "Dec",
|
|
||||||
_ => parts[1],
|
|
||||||
};
|
|
||||||
format!("{} '{}", month, year)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared row-to-domain conversion functions
|
|
||||||
//
|
|
||||||
// Each database adapter keeps its own `FromRow` structs (sqlite vs postgres
|
|
||||||
// derive different impls) but the conversion from parsed row fields into
|
|
||||||
// domain types is identical. These functions capture that shared logic so
|
|
||||||
// each adapter's `into_domain()` becomes a one-liner delegation.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Convert raw movie row fields into a [`Movie`] domain object.
|
|
||||||
pub fn movie_row_to_domain(
|
|
||||||
id: String,
|
|
||||||
external_metadata_id: Option<String>,
|
|
||||||
title: String,
|
|
||||||
release_year: i64,
|
|
||||||
director: Option<String>,
|
|
||||||
poster_path: Option<String>,
|
|
||||||
) -> Result<Movie, DomainError> {
|
|
||||||
let id = MovieId::from_uuid(parse_uuid(&id)?);
|
|
||||||
let external_metadata_id = external_metadata_id
|
|
||||||
.map(ExternalMetadataId::new)
|
|
||||||
.transpose()?;
|
|
||||||
let title = MovieTitle::new(title)?;
|
|
||||||
let release_year = ReleaseYear::new(release_year as u16)?;
|
|
||||||
let poster_path = poster_path.map(PosterPath::new).transpose()?;
|
|
||||||
Ok(Movie::from_persistence(
|
|
||||||
id,
|
|
||||||
external_metadata_id,
|
|
||||||
title,
|
|
||||||
release_year,
|
|
||||||
director,
|
|
||||||
poster_path,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw review row fields into a [`Review`] domain object.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn review_row_to_domain(
|
|
||||||
id: String,
|
|
||||||
movie_id: String,
|
|
||||||
user_id: String,
|
|
||||||
rating: i64,
|
|
||||||
comment: Option<String>,
|
|
||||||
watched_at: String,
|
|
||||||
created_at: String,
|
|
||||||
remote_actor_url: Option<String>,
|
|
||||||
watch_medium: Option<String>,
|
|
||||||
) -> Result<Review, DomainError> {
|
|
||||||
let id = ReviewId::from_uuid(parse_uuid(&id)?);
|
|
||||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
|
|
||||||
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
|
|
||||||
let rating = Rating::new(rating as u8)?;
|
|
||||||
let comment = comment.map(Comment::new).transpose()?;
|
|
||||||
let watched_at = parse_datetime(&watched_at)?;
|
|
||||||
let created_at = parse_datetime(&created_at)?;
|
|
||||||
let source = match remote_actor_url {
|
|
||||||
None => ReviewSource::Local,
|
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
|
||||||
};
|
|
||||||
let watch_medium = watch_medium.map(|s| s.parse()).transpose()?;
|
|
||||||
Ok(Review::from_persistence(PersistedReview {
|
|
||||||
id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
comment,
|
|
||||||
watched_at,
|
|
||||||
created_at,
|
|
||||||
source,
|
|
||||||
watch_medium,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assemble a [`MovieSummary`] from an already-converted [`Movie`] and extra
|
|
||||||
/// metadata fields. The caller is responsible for converting genres into a
|
|
||||||
/// `Vec<String>` (sqlite splits a comma-separated string, postgres receives a
|
|
||||||
/// `Vec` directly).
|
|
||||||
pub fn movie_summary_to_domain(
|
|
||||||
movie: Movie,
|
|
||||||
genres: Vec<String>,
|
|
||||||
runtime_minutes: Option<i64>,
|
|
||||||
original_language: Option<String>,
|
|
||||||
overview: Option<String>,
|
|
||||||
collection_name: Option<String>,
|
|
||||||
) -> MovieSummary {
|
|
||||||
MovieSummary {
|
|
||||||
movie,
|
|
||||||
genres,
|
|
||||||
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
|
||||||
original_language,
|
|
||||||
overview,
|
|
||||||
collection_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw aggregate stats into a [`MovieStats`] domain object.
|
|
||||||
pub fn movie_stats_to_domain(
|
|
||||||
total_count: i64,
|
|
||||||
avg_rating: Option<f64>,
|
|
||||||
federated_count: i64,
|
|
||||||
rating_histogram: [i64; 5],
|
|
||||||
) -> MovieStats {
|
|
||||||
MovieStats {
|
|
||||||
total_count: total_count as u64,
|
|
||||||
avg_rating,
|
|
||||||
federated_count: federated_count as u64,
|
|
||||||
rating_histogram: [
|
|
||||||
rating_histogram[0] as u64,
|
|
||||||
rating_histogram[1] as u64,
|
|
||||||
rating_histogram[2] as u64,
|
|
||||||
rating_histogram[3] as u64,
|
|
||||||
rating_histogram[4] as u64,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw user summary row fields into a [`UserSummary`] domain object.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn user_summary_to_domain(
|
|
||||||
id: String,
|
|
||||||
email: String,
|
|
||||||
username: String,
|
|
||||||
display_name: Option<String>,
|
|
||||||
total_movies: i64,
|
|
||||||
avg_rating: Option<f64>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
) -> Result<UserSummary, DomainError> {
|
|
||||||
Ok(UserSummary::new(
|
|
||||||
UserId::from_uuid(parse_uuid(&id)?),
|
|
||||||
Email::new(email)?,
|
|
||||||
Username::new(username)?,
|
|
||||||
display_name,
|
|
||||||
total_movies,
|
|
||||||
avg_rating,
|
|
||||||
avatar_path,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw watchlist entry fields into a [`WatchlistEntry`] domain object.
|
|
||||||
pub fn watchlist_entry_to_domain(
|
|
||||||
id: String,
|
|
||||||
user_id: String,
|
|
||||||
movie_id: String,
|
|
||||||
added_at: String,
|
|
||||||
) -> Result<WatchlistEntry, DomainError> {
|
|
||||||
Ok(WatchlistEntry {
|
|
||||||
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
|
|
||||||
added_at: parse_datetime(&added_at)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw watchlist+movie row fields into a [`WatchlistWithMovie`].
|
|
||||||
///
|
|
||||||
/// Takes the watchlist entry fields and a pre-converted [`Movie`].
|
|
||||||
pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie {
|
|
||||||
WatchlistWithMovie { entry, movie }
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::GeneratedToken,
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
ports::{AuthService, PasswordHasher},
|
|
||||||
value_objects::{PasswordHash, UserId},
|
value_objects::{PasswordHash, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ impl AuthConfig {
|
|||||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(900u64);
|
.unwrap_or(86400u64);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
secret,
|
secret,
|
||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
@@ -106,14 +105,3 @@ impl PasswordHasher for Argon2PasswordHasher {
|
|||||||
.is_ok())
|
.is_ok())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create() -> anyhow::Result<(
|
|
||||||
std::sync::Arc<dyn domain::ports::AuthService>,
|
|
||||||
std::sync::Arc<dyn domain::ports::PasswordHasher>,
|
|
||||||
)> {
|
|
||||||
let config = AuthConfig::from_env()?;
|
|
||||||
Ok((
|
|
||||||
std::sync::Arc::new(JwtAuthService::new(config)),
|
|
||||||
std::sync::Arc::new(Argon2PasswordHasher),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "event-payload"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
domain = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
@@ -1,724 +0,0 @@
|
|||||||
use chrono::NaiveDateTime;
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
events::DomainEvent,
|
|
||||||
models::{ExternalPersonId, PersonId},
|
|
||||||
value_objects::{
|
|
||||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId,
|
|
||||||
WrapUpId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(tag = "type", content = "data")]
|
|
||||||
pub enum EventPayload {
|
|
||||||
ReviewLogged {
|
|
||||||
review_id: String,
|
|
||||||
movie_id: String,
|
|
||||||
user_id: String,
|
|
||||||
rating: u8,
|
|
||||||
watched_at: i64,
|
|
||||||
},
|
|
||||||
ReviewUpdated {
|
|
||||||
review_id: String,
|
|
||||||
movie_id: String,
|
|
||||||
user_id: String,
|
|
||||||
rating: u8,
|
|
||||||
watched_at: i64,
|
|
||||||
},
|
|
||||||
MovieDiscovered {
|
|
||||||
movie_id: String,
|
|
||||||
external_metadata_id: String,
|
|
||||||
},
|
|
||||||
MovieDeleted {
|
|
||||||
movie_id: String,
|
|
||||||
poster_path: Option<String>,
|
|
||||||
},
|
|
||||||
UserUpdated {
|
|
||||||
user_id: String,
|
|
||||||
},
|
|
||||||
ReviewDeleted {
|
|
||||||
review_id: String,
|
|
||||||
user_id: String,
|
|
||||||
},
|
|
||||||
MovieEnrichmentRequested {
|
|
||||||
movie_id: String,
|
|
||||||
external_metadata_id: String,
|
|
||||||
},
|
|
||||||
ImageStored {
|
|
||||||
key: String,
|
|
||||||
},
|
|
||||||
WatchlistEntryAdded {
|
|
||||||
user_id: String,
|
|
||||||
movie_id: String,
|
|
||||||
movie_title: String,
|
|
||||||
release_year: u16,
|
|
||||||
external_metadata_id: Option<String>,
|
|
||||||
added_at: i64,
|
|
||||||
},
|
|
||||||
WatchlistEntryRemoved {
|
|
||||||
user_id: String,
|
|
||||||
movie_id: String,
|
|
||||||
},
|
|
||||||
FollowRequested {
|
|
||||||
follower_id: String,
|
|
||||||
target_kind: String,
|
|
||||||
target_id: String,
|
|
||||||
},
|
|
||||||
FollowAccepted {
|
|
||||||
owner_id: String,
|
|
||||||
requester_kind: String,
|
|
||||||
requester_id: String,
|
|
||||||
},
|
|
||||||
FollowRejected {
|
|
||||||
owner_id: String,
|
|
||||||
requester_kind: String,
|
|
||||||
requester_id: String,
|
|
||||||
},
|
|
||||||
Unfollowed {
|
|
||||||
follower_id: String,
|
|
||||||
target_kind: String,
|
|
||||||
target_id: String,
|
|
||||||
},
|
|
||||||
FollowerRemoved {
|
|
||||||
owner_id: String,
|
|
||||||
follower_kind: String,
|
|
||||||
follower_id: String,
|
|
||||||
},
|
|
||||||
ActorBlocked {
|
|
||||||
blocker_id: String,
|
|
||||||
target_kind: String,
|
|
||||||
target_id: String,
|
|
||||||
},
|
|
||||||
ActorUnblocked {
|
|
||||||
blocker_id: String,
|
|
||||||
target_kind: String,
|
|
||||||
target_id: String,
|
|
||||||
},
|
|
||||||
BackfillFollower {
|
|
||||||
owner_user_id: String,
|
|
||||||
follower_inbox_url: String,
|
|
||||||
},
|
|
||||||
FederationDeliveryRequested {
|
|
||||||
inbox_url: String,
|
|
||||||
activity_json: serde_json::Value,
|
|
||||||
signing_actor_id: String,
|
|
||||||
},
|
|
||||||
WatchEventIngested {
|
|
||||||
user_id: String,
|
|
||||||
title: String,
|
|
||||||
source: String,
|
|
||||||
},
|
|
||||||
WrapUpRequested {
|
|
||||||
wrapup_id: String,
|
|
||||||
user_id: Option<String>,
|
|
||||||
start_date: String,
|
|
||||||
end_date: String,
|
|
||||||
},
|
|
||||||
WrapUpCompleted {
|
|
||||||
wrapup_id: String,
|
|
||||||
},
|
|
||||||
SearchReindexRequested,
|
|
||||||
PosterSynced {
|
|
||||||
movie_id: String,
|
|
||||||
},
|
|
||||||
GoalCreated {
|
|
||||||
goal_id: String,
|
|
||||||
user_id: String,
|
|
||||||
year: u16,
|
|
||||||
target_count: u32,
|
|
||||||
},
|
|
||||||
GoalUpdated {
|
|
||||||
goal_id: String,
|
|
||||||
user_id: String,
|
|
||||||
year: u16,
|
|
||||||
target_count: u32,
|
|
||||||
},
|
|
||||||
GoalDeleted {
|
|
||||||
goal_id: String,
|
|
||||||
user_id: String,
|
|
||||||
year: u16,
|
|
||||||
},
|
|
||||||
PersonEnrichmentRequested {
|
|
||||||
person_id: String,
|
|
||||||
external_person_id: String,
|
|
||||||
},
|
|
||||||
UserDeleted {
|
|
||||||
user_id: String,
|
|
||||||
},
|
|
||||||
UserAccountMoved {
|
|
||||||
user_id: String,
|
|
||||||
new_actor_url: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventPayload {
|
|
||||||
pub fn event_type(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
EventPayload::ReviewLogged { .. } => "ReviewLogged",
|
|
||||||
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
|
|
||||||
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
|
|
||||||
EventPayload::MovieDeleted { .. } => "MovieDeleted",
|
|
||||||
EventPayload::UserUpdated { .. } => "UserUpdated",
|
|
||||||
EventPayload::ReviewDeleted { .. } => "ReviewDeleted",
|
|
||||||
EventPayload::MovieEnrichmentRequested { .. } => "MovieEnrichmentRequested",
|
|
||||||
EventPayload::ImageStored { .. } => "ImageStored",
|
|
||||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
|
||||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
|
||||||
EventPayload::FollowRequested { .. } => "FollowRequested",
|
|
||||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
|
||||||
EventPayload::FollowRejected { .. } => "FollowRejected",
|
|
||||||
EventPayload::Unfollowed { .. } => "Unfollowed",
|
|
||||||
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
|
|
||||||
EventPayload::ActorBlocked { .. } => "ActorBlocked",
|
|
||||||
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
|
|
||||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
|
||||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
|
||||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
|
||||||
EventPayload::WrapUpRequested { .. } => "WrapUpRequested",
|
|
||||||
EventPayload::WrapUpCompleted { .. } => "WrapUpCompleted",
|
|
||||||
EventPayload::SearchReindexRequested => "SearchReindexRequested",
|
|
||||||
EventPayload::PosterSynced { .. } => "PosterSynced",
|
|
||||||
EventPayload::GoalCreated { .. } => "GoalCreated",
|
|
||||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
|
||||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
|
||||||
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
|
||||||
EventPayload::UserDeleted { .. } => "UserDeleted",
|
|
||||||
EventPayload::UserAccountMoved { .. } => "UserAccountMoved",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
|
|
||||||
Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity_to_payload(id: &SocialIdentity) -> (String, String) {
|
|
||||||
match id {
|
|
||||||
SocialIdentity::Local(uid) => ("local".into(), uid.value().to_string()),
|
|
||||||
SocialIdentity::Remote { actor_url } => ("remote".into(), actor_url.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) {
|
|
||||||
match target {
|
|
||||||
domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id),
|
|
||||||
domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainError> {
|
|
||||||
match kind {
|
|
||||||
"local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid(
|
|
||||||
&id, "user_id",
|
|
||||||
)?))),
|
|
||||||
"remote" => Ok(SocialIdentity::Remote { actor_url: id }),
|
|
||||||
other => Err(DomainError::InfrastructureError(format!(
|
|
||||||
"unknown identity kind: {other}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn payload_to_follow_target(
|
|
||||||
kind: &str,
|
|
||||||
id: String,
|
|
||||||
) -> Result<domain::value_objects::FollowTarget, DomainError> {
|
|
||||||
match kind {
|
|
||||||
"handle" => Ok(domain::value_objects::FollowTarget::Handle(id)),
|
|
||||||
other => Ok(domain::value_objects::FollowTarget::Identity(
|
|
||||||
payload_to_identity(other, id)?,
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
|
||||||
chrono::DateTime::from_timestamp(ts, 0)
|
|
||||||
.map(|dt| dt.naive_utc())
|
|
||||||
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&DomainEvent> for EventPayload {
|
|
||||||
fn from(event: &DomainEvent) -> Self {
|
|
||||||
match event {
|
|
||||||
DomainEvent::ReviewLogged {
|
|
||||||
review_id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
watched_at,
|
|
||||||
} => EventPayload::ReviewLogged {
|
|
||||||
review_id: review_id.value().to_string(),
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
rating: rating.value(),
|
|
||||||
watched_at: watched_at.and_utc().timestamp(),
|
|
||||||
},
|
|
||||||
DomainEvent::ReviewUpdated {
|
|
||||||
review_id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
watched_at,
|
|
||||||
} => EventPayload::ReviewUpdated {
|
|
||||||
review_id: review_id.value().to_string(),
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
rating: rating.value(),
|
|
||||||
watched_at: watched_at.and_utc().timestamp(),
|
|
||||||
},
|
|
||||||
DomainEvent::MovieDiscovered {
|
|
||||||
movie_id,
|
|
||||||
external_metadata_id,
|
|
||||||
} => EventPayload::MovieDiscovered {
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
external_metadata_id: external_metadata_id.value().to_owned(),
|
|
||||||
},
|
|
||||||
DomainEvent::MovieDeleted {
|
|
||||||
movie_id,
|
|
||||||
poster_path,
|
|
||||||
} => EventPayload::MovieDeleted {
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
|
|
||||||
},
|
|
||||||
DomainEvent::UserUpdated { user_id } => EventPayload::UserUpdated {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::ReviewDeleted { review_id, user_id } => EventPayload::ReviewDeleted {
|
|
||||||
review_id: review_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.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
|
|
||||||
DomainEvent::WatchlistEntryAdded {
|
|
||||||
user_id,
|
|
||||||
movie_id,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
added_at,
|
|
||||||
} => EventPayload::WatchlistEntryAdded {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
movie_title: movie_title.clone(),
|
|
||||||
release_year: *release_year,
|
|
||||||
external_metadata_id: external_metadata_id.clone(),
|
|
||||||
added_at: added_at.and_utc().timestamp(),
|
|
||||||
},
|
|
||||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => {
|
|
||||||
EventPayload::WatchlistEntryRemoved {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::FollowRequested { follower, target } => {
|
|
||||||
let (kind, id) = follow_target_to_payload(target);
|
|
||||||
EventPayload::FollowRequested {
|
|
||||||
follower_id: follower.value().to_string(),
|
|
||||||
target_kind: kind,
|
|
||||||
target_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::FollowAccepted { owner, requester } => {
|
|
||||||
let (kind, id) = identity_to_payload(requester);
|
|
||||||
EventPayload::FollowAccepted {
|
|
||||||
owner_id: owner.value().to_string(),
|
|
||||||
requester_kind: kind,
|
|
||||||
requester_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::FollowRejected { owner, requester } => {
|
|
||||||
let (kind, id) = identity_to_payload(requester);
|
|
||||||
EventPayload::FollowRejected {
|
|
||||||
owner_id: owner.value().to_string(),
|
|
||||||
requester_kind: kind,
|
|
||||||
requester_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::Unfollowed { follower, target } => {
|
|
||||||
let (kind, id) = identity_to_payload(target);
|
|
||||||
EventPayload::Unfollowed {
|
|
||||||
follower_id: follower.value().to_string(),
|
|
||||||
target_kind: kind,
|
|
||||||
target_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::FollowerRemoved { owner, follower } => {
|
|
||||||
let (kind, id) = identity_to_payload(follower);
|
|
||||||
EventPayload::FollowerRemoved {
|
|
||||||
owner_id: owner.value().to_string(),
|
|
||||||
follower_kind: kind,
|
|
||||||
follower_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::ActorBlocked { blocker, target } => {
|
|
||||||
let (kind, id) = identity_to_payload(target);
|
|
||||||
EventPayload::ActorBlocked {
|
|
||||||
blocker_id: blocker.value().to_string(),
|
|
||||||
target_kind: kind,
|
|
||||||
target_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::ActorUnblocked { blocker, target } => {
|
|
||||||
let (kind, id) = identity_to_payload(target);
|
|
||||||
EventPayload::ActorUnblocked {
|
|
||||||
blocker_id: blocker.value().to_string(),
|
|
||||||
target_kind: kind,
|
|
||||||
target_id: id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DomainEvent::BackfillFollower {
|
|
||||||
owner_user_id,
|
|
||||||
follower_inbox_url,
|
|
||||||
} => EventPayload::BackfillFollower {
|
|
||||||
owner_user_id: owner_user_id.value().to_string(),
|
|
||||||
follower_inbox_url: follower_inbox_url.clone(),
|
|
||||||
},
|
|
||||||
DomainEvent::FederationDeliveryRequested {
|
|
||||||
inbox_url,
|
|
||||||
activity_json,
|
|
||||||
signing_actor_id,
|
|
||||||
} => EventPayload::FederationDeliveryRequested {
|
|
||||||
inbox_url: inbox_url.clone(),
|
|
||||||
activity_json: activity_json.clone(),
|
|
||||||
signing_actor_id: signing_actor_id.to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::WatchEventIngested {
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
source,
|
|
||||||
} => EventPayload::WatchEventIngested {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
title: title.clone(),
|
|
||||||
source: source.clone(),
|
|
||||||
},
|
|
||||||
DomainEvent::WrapUpRequested {
|
|
||||||
wrapup_id,
|
|
||||||
user_id,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
} => EventPayload::WrapUpRequested {
|
|
||||||
wrapup_id: wrapup_id.value().to_string(),
|
|
||||||
user_id: user_id.as_ref().map(|u| u.value().to_string()),
|
|
||||||
start_date: start_date.to_string(),
|
|
||||||
end_date: end_date.to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::WrapUpCompleted { wrapup_id } => EventPayload::WrapUpCompleted {
|
|
||||||
wrapup_id: wrapup_id.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::SearchReindexRequested => EventPayload::SearchReindexRequested,
|
|
||||||
DomainEvent::PosterSynced { movie_id } => EventPayload::PosterSynced {
|
|
||||||
movie_id: movie_id.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::GoalCreated {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
} => EventPayload::GoalCreated {
|
|
||||||
goal_id: goal_id.value().to_string(),
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
year: *year,
|
|
||||||
target_count: *target_count,
|
|
||||||
},
|
|
||||||
DomainEvent::GoalUpdated {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
} => EventPayload::GoalUpdated {
|
|
||||||
goal_id: goal_id.value().to_string(),
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
year: *year,
|
|
||||||
target_count: *target_count,
|
|
||||||
},
|
|
||||||
DomainEvent::GoalDeleted {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
} => EventPayload::GoalDeleted {
|
|
||||||
goal_id: goal_id.value().to_string(),
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
year: *year,
|
|
||||||
},
|
|
||||||
DomainEvent::PersonEnrichmentRequested {
|
|
||||||
person_id,
|
|
||||||
external_person_id,
|
|
||||||
} => EventPayload::PersonEnrichmentRequested {
|
|
||||||
person_id: person_id.value().to_string(),
|
|
||||||
external_person_id: external_person_id.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::UserDeleted { user_id } => EventPayload::UserDeleted {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
},
|
|
||||||
DomainEvent::UserAccountMoved {
|
|
||||||
user_id,
|
|
||||||
new_actor_url,
|
|
||||||
} => EventPayload::UserAccountMoved {
|
|
||||||
user_id: user_id.value().to_string(),
|
|
||||||
new_actor_url: new_actor_url.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<EventPayload> for DomainEvent {
|
|
||||||
type Error = DomainError;
|
|
||||||
fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
|
|
||||||
match payload {
|
|
||||||
EventPayload::ReviewLogged {
|
|
||||||
review_id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
watched_at,
|
|
||||||
} => Ok(DomainEvent::ReviewLogged {
|
|
||||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
rating: Rating::new(rating)?,
|
|
||||||
watched_at: parse_ts(watched_at)?,
|
|
||||||
}),
|
|
||||||
EventPayload::ReviewUpdated {
|
|
||||||
review_id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
watched_at,
|
|
||||||
} => Ok(DomainEvent::ReviewUpdated {
|
|
||||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
rating: Rating::new(rating)?,
|
|
||||||
watched_at: parse_ts(watched_at)?,
|
|
||||||
}),
|
|
||||||
EventPayload::MovieDiscovered {
|
|
||||||
movie_id,
|
|
||||||
external_metadata_id,
|
|
||||||
} => Ok(DomainEvent::MovieDiscovered {
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::MovieDeleted {
|
|
||||||
movie_id,
|
|
||||||
poster_path,
|
|
||||||
} => {
|
|
||||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
|
|
||||||
let poster_path = poster_path
|
|
||||||
.map(PosterPath::new)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(DomainEvent::MovieDeleted {
|
|
||||||
movie_id,
|
|
||||||
poster_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
}),
|
|
||||||
EventPayload::ReviewDeleted { review_id, user_id } => Ok(DomainEvent::ReviewDeleted {
|
|
||||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_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: ExternalMetadataId::new(external_metadata_id)
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
|
||||||
}),
|
|
||||||
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
|
|
||||||
EventPayload::WatchlistEntryAdded {
|
|
||||||
user_id,
|
|
||||||
movie_id,
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
added_at,
|
|
||||||
} => Ok(DomainEvent::WatchlistEntryAdded {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
movie_title,
|
|
||||||
release_year,
|
|
||||||
external_metadata_id,
|
|
||||||
added_at: parse_ts(added_at)?,
|
|
||||||
}),
|
|
||||||
EventPayload::WatchlistEntryRemoved { user_id, movie_id } => {
|
|
||||||
Ok(DomainEvent::WatchlistEntryRemoved {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
EventPayload::FollowRequested {
|
|
||||||
follower_id,
|
|
||||||
target_kind,
|
|
||||||
target_id,
|
|
||||||
} => Ok(DomainEvent::FollowRequested {
|
|
||||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
|
||||||
target: payload_to_follow_target(&target_kind, target_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::FollowAccepted {
|
|
||||||
owner_id,
|
|
||||||
requester_kind,
|
|
||||||
requester_id,
|
|
||||||
} => Ok(DomainEvent::FollowAccepted {
|
|
||||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
|
||||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::FollowRejected {
|
|
||||||
owner_id,
|
|
||||||
requester_kind,
|
|
||||||
requester_id,
|
|
||||||
} => Ok(DomainEvent::FollowRejected {
|
|
||||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
|
||||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::Unfollowed {
|
|
||||||
follower_id,
|
|
||||||
target_kind,
|
|
||||||
target_id,
|
|
||||||
} => Ok(DomainEvent::Unfollowed {
|
|
||||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
|
||||||
target: payload_to_identity(&target_kind, target_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::FollowerRemoved {
|
|
||||||
owner_id,
|
|
||||||
follower_kind,
|
|
||||||
follower_id,
|
|
||||||
} => Ok(DomainEvent::FollowerRemoved {
|
|
||||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
|
||||||
follower: payload_to_identity(&follower_kind, follower_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::ActorBlocked {
|
|
||||||
blocker_id,
|
|
||||||
target_kind,
|
|
||||||
target_id,
|
|
||||||
} => Ok(DomainEvent::ActorBlocked {
|
|
||||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
|
||||||
target: payload_to_identity(&target_kind, target_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::ActorUnblocked {
|
|
||||||
blocker_id,
|
|
||||||
target_kind,
|
|
||||||
target_id,
|
|
||||||
} => Ok(DomainEvent::ActorUnblocked {
|
|
||||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
|
||||||
target: payload_to_identity(&target_kind, target_id)?,
|
|
||||||
}),
|
|
||||||
EventPayload::BackfillFollower {
|
|
||||||
owner_user_id,
|
|
||||||
follower_inbox_url,
|
|
||||||
} => Ok(DomainEvent::BackfillFollower {
|
|
||||||
owner_user_id: UserId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
|
|
||||||
follower_inbox_url,
|
|
||||||
}),
|
|
||||||
EventPayload::FederationDeliveryRequested {
|
|
||||||
inbox_url,
|
|
||||||
activity_json,
|
|
||||||
signing_actor_id,
|
|
||||||
} => Ok(DomainEvent::FederationDeliveryRequested {
|
|
||||||
inbox_url,
|
|
||||||
activity_json,
|
|
||||||
signing_actor_id: parse_uuid(&signing_actor_id, "signing_actor_id")?,
|
|
||||||
}),
|
|
||||||
EventPayload::WatchEventIngested {
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
source,
|
|
||||||
} => Ok(DomainEvent::WatchEventIngested {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
title,
|
|
||||||
source,
|
|
||||||
}),
|
|
||||||
EventPayload::WrapUpRequested {
|
|
||||||
wrapup_id,
|
|
||||||
user_id,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
} => {
|
|
||||||
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
|
|
||||||
let uid = user_id.map(|s| parse_uuid(&s, "user_id")).transpose()?;
|
|
||||||
let sd = chrono::NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
|
|
||||||
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
|
|
||||||
let ed = chrono::NaiveDate::parse_from_str(&end_date, "%Y-%m-%d")
|
|
||||||
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
|
|
||||||
Ok(DomainEvent::WrapUpRequested {
|
|
||||||
wrapup_id: WrapUpId::from_uuid(wid),
|
|
||||||
user_id: uid.map(UserId::from_uuid),
|
|
||||||
start_date: sd,
|
|
||||||
end_date: ed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
EventPayload::WrapUpCompleted { wrapup_id } => {
|
|
||||||
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
|
|
||||||
Ok(DomainEvent::WrapUpCompleted {
|
|
||||||
wrapup_id: WrapUpId::from_uuid(wid),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
EventPayload::SearchReindexRequested => Ok(DomainEvent::SearchReindexRequested),
|
|
||||||
EventPayload::PosterSynced { movie_id } => Ok(DomainEvent::PosterSynced {
|
|
||||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
|
||||||
}),
|
|
||||||
EventPayload::GoalCreated {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
} => Ok(DomainEvent::GoalCreated {
|
|
||||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
}),
|
|
||||||
EventPayload::GoalUpdated {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
} => Ok(DomainEvent::GoalUpdated {
|
|
||||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
year,
|
|
||||||
target_count,
|
|
||||||
}),
|
|
||||||
EventPayload::GoalDeleted {
|
|
||||||
goal_id,
|
|
||||||
user_id,
|
|
||||||
year,
|
|
||||||
} => Ok(DomainEvent::GoalDeleted {
|
|
||||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
year,
|
|
||||||
}),
|
|
||||||
EventPayload::PersonEnrichmentRequested {
|
|
||||||
person_id,
|
|
||||||
external_person_id,
|
|
||||||
} => Ok(DomainEvent::PersonEnrichmentRequested {
|
|
||||||
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
|
||||||
external_person_id: ExternalPersonId::new(external_person_id),
|
|
||||||
}),
|
|
||||||
EventPayload::UserDeleted { user_id } => Ok(DomainEvent::UserDeleted {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
}),
|
|
||||||
EventPayload::UserAccountMoved {
|
|
||||||
user_id,
|
|
||||||
new_actor_url,
|
|
||||||
} => Ok(DomainEvent::UserAccountMoved {
|
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
|
||||||
new_actor_url,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/lib.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
fn fixed_dt() -> NaiveDateTime {
|
|
||||||
chrono::DateTime::from_timestamp(1_700_000_000, 0)
|
|
||||||
.unwrap()
|
|
||||||
.naive_utc()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn review_logged() -> DomainEvent {
|
|
||||||
DomainEvent::ReviewLogged {
|
|
||||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
|
||||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
|
||||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
|
||||||
rating: Rating::new(4).unwrap(),
|
|
||||||
watched_at: fixed_dt(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn review_updated() -> DomainEvent {
|
|
||||||
DomainEvent::ReviewUpdated {
|
|
||||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
|
||||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
|
||||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
|
||||||
rating: Rating::new(3).unwrap(),
|
|
||||||
watched_at: fixed_dt(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn movie_discovered() -> DomainEvent {
|
|
||||||
DomainEvent::MovieDiscovered {
|
|
||||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
|
||||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round_trip(event: DomainEvent) {
|
|
||||||
let payload = EventPayload::from(&event);
|
|
||||||
let json = serde_json::to_string(&payload).expect("serialize");
|
|
||||||
let back: EventPayload = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
let recovered = DomainEvent::try_from(back).expect("try_from");
|
|
||||||
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_review_logged() {
|
|
||||||
round_trip(review_logged());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_review_updated() {
|
|
||||||
round_trip(review_updated());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_movie_discovered() {
|
|
||||||
round_trip(movie_discovered());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serialized_format_is_tagged() {
|
|
||||||
let payload = EventPayload::from(&movie_discovered());
|
|
||||||
let json = serde_json::to_string(&payload).unwrap();
|
|
||||||
assert!(json.contains(r#""type":"MovieDiscovered""#));
|
|
||||||
assert!(json.contains(r#""data":"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn event_type_strings() {
|
|
||||||
assert_eq!(
|
|
||||||
EventPayload::from(&review_logged()).event_type(),
|
|
||||||
"ReviewLogged"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
EventPayload::from(&review_updated()).event_type(),
|
|
||||||
"ReviewUpdated"
|
|
||||||
);
|
|
||||||
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,4 +7,4 @@ edition = "2024"
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||||
errors::DomainError,
|
|
||||||
events::{AckHandle, DomainEvent, EventEnvelope},
|
|
||||||
ports::{EventConsumer, EventPublisher},
|
|
||||||
};
|
|
||||||
use futures::stream::{self, BoxStream};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
pub use domain::ports::EventHandler;
|
pub use domain::ports::EventHandler;
|
||||||
@@ -39,30 +32,65 @@ impl EventPublisher for ChannelEventPublisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoopAck;
|
pub struct EventWorker {
|
||||||
|
receiver: mpsc::Receiver<DomainEvent>,
|
||||||
#[async_trait]
|
handlers: Vec<Box<dyn EventHandler>>,
|
||||||
impl AckHandle for NoopAck {
|
|
||||||
async fn ack(&self) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn nack(&self) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ChannelEventConsumer {
|
impl EventWorker {
|
||||||
receiver: Arc<Mutex<mpsc::Receiver<DomainEvent>>>,
|
pub async fn run(mut self) {
|
||||||
}
|
while let Some(event) = self.receiver.recv().await {
|
||||||
|
match &event {
|
||||||
impl EventConsumer for ChannelEventConsumer {
|
DomainEvent::ReviewLogged {
|
||||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
review_id,
|
||||||
let receiver = Arc::clone(&self.receiver);
|
movie_id,
|
||||||
Box::pin(stream::unfold(receiver, |rx| async move {
|
user_id,
|
||||||
let event = rx.lock().await.recv().await?;
|
rating,
|
||||||
let envelope = EventEnvelope::new(event, Box::new(NoopAck));
|
watched_at,
|
||||||
Some((Ok(envelope), rx))
|
} => {
|
||||||
}))
|
tracing::info!(
|
||||||
|
review_id = %review_id.value(),
|
||||||
|
movie_id = %movie_id.value(),
|
||||||
|
user_id = %user_id.value(),
|
||||||
|
rating = rating.value(),
|
||||||
|
watched_at = %watched_at,
|
||||||
|
"event: review_logged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DomainEvent::ReviewUpdated {
|
||||||
|
review_id,
|
||||||
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
watched_at,
|
||||||
|
} => {
|
||||||
|
tracing::info!(
|
||||||
|
review_id = %review_id.value(),
|
||||||
|
movie_id = %movie_id.value(),
|
||||||
|
user_id = %user_id.value(),
|
||||||
|
rating = rating.value(),
|
||||||
|
watched_at = %watched_at,
|
||||||
|
"event: review_updated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DomainEvent::MovieDiscovered {
|
||||||
|
movie_id,
|
||||||
|
external_metadata_id,
|
||||||
|
} => {
|
||||||
|
tracing::info!(
|
||||||
|
movie_id = %movie_id.value(),
|
||||||
|
external_id = external_metadata_id.value(),
|
||||||
|
"event: movie_discovered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for handler in &self.handlers {
|
||||||
|
if let Err(e) = handler.handle(&event).await {
|
||||||
|
tracing::error!("event handler error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("event worker shut down");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +105,123 @@ impl EventPublisher for NoopEventPublisher {
|
|||||||
|
|
||||||
pub fn create_event_channel(
|
pub fn create_event_channel(
|
||||||
config: EventPublisherConfig,
|
config: EventPublisherConfig,
|
||||||
) -> (ChannelEventPublisher, ChannelEventConsumer) {
|
handlers: Vec<Box<dyn EventHandler>>,
|
||||||
|
) -> (ChannelEventPublisher, EventWorker) {
|
||||||
let (tx, rx) = mpsc::channel(config.channel_buffer);
|
let (tx, rx) = mpsc::channel(config.channel_buffer);
|
||||||
(
|
(
|
||||||
ChannelEventPublisher { sender: tx },
|
ChannelEventPublisher { sender: tx },
|
||||||
ChannelEventConsumer {
|
EventWorker {
|
||||||
receiver: Arc::new(Mutex::new(rx)),
|
receiver: rx,
|
||||||
|
handlers,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/lib.rs"]
|
mod tests {
|
||||||
mod tests;
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
value_objects::{ExternalMetadataId, MovieId},
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct RecordingHandler {
|
||||||
|
calls: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventHandler for RecordingHandler {
|
||||||
|
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
let label = match event {
|
||||||
|
DomainEvent::ReviewLogged { .. } => "review_logged",
|
||||||
|
DomainEvent::ReviewUpdated { .. } => "review_updated",
|
||||||
|
DomainEvent::MovieDiscovered { .. } => "movie_discovered",
|
||||||
|
};
|
||||||
|
self.calls.lock().unwrap().push(label.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_handler_receives_event() {
|
||||||
|
let calls = Arc::new(Mutex::new(vec![]));
|
||||||
|
let handler = RecordingHandler {
|
||||||
|
calls: Arc::clone(&calls),
|
||||||
|
};
|
||||||
|
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||||
|
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(worker.run());
|
||||||
|
|
||||||
|
let event = DomainEvent::MovieDiscovered {
|
||||||
|
movie_id: MovieId::generate(),
|
||||||
|
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||||
|
};
|
||||||
|
publisher.publish(&event).await.unwrap();
|
||||||
|
drop(publisher);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_handlers_all_receive_event() {
|
||||||
|
let calls1 = Arc::new(Mutex::new(vec![]));
|
||||||
|
let calls2 = Arc::new(Mutex::new(vec![]));
|
||||||
|
let handler1 = RecordingHandler {
|
||||||
|
calls: Arc::clone(&calls1),
|
||||||
|
};
|
||||||
|
let handler2 = RecordingHandler {
|
||||||
|
calls: Arc::clone(&calls2),
|
||||||
|
};
|
||||||
|
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||||
|
let (publisher, worker) =
|
||||||
|
create_event_channel(config, vec![Box::new(handler1), Box::new(handler2)]);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(worker.run());
|
||||||
|
|
||||||
|
let event = DomainEvent::MovieDiscovered {
|
||||||
|
movie_id: MovieId::generate(),
|
||||||
|
external_metadata_id: ExternalMetadataId::new("tt9999999".into()).unwrap(),
|
||||||
|
};
|
||||||
|
publisher.publish(&event).await.unwrap();
|
||||||
|
drop(publisher);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(calls1.lock().unwrap().len(), 1);
|
||||||
|
assert_eq!(calls2.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handler_error_does_not_stop_worker() {
|
||||||
|
struct FailingHandler;
|
||||||
|
#[async_trait]
|
||||||
|
impl EventHandler for FailingHandler {
|
||||||
|
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
Err(DomainError::InfrastructureError("boom".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls = Arc::new(Mutex::new(vec![]));
|
||||||
|
let good = RecordingHandler {
|
||||||
|
calls: Arc::clone(&calls),
|
||||||
|
};
|
||||||
|
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||||
|
let (publisher, worker) =
|
||||||
|
create_event_channel(config, vec![Box::new(FailingHandler), Box::new(good)]);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(worker.run());
|
||||||
|
|
||||||
|
let event = DomainEvent::MovieDiscovered {
|
||||||
|
movie_id: MovieId::generate(),
|
||||||
|
external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(),
|
||||||
|
};
|
||||||
|
publisher.publish(&event).await.unwrap();
|
||||||
|
drop(publisher);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use domain::{
|
|
||||||
events::DomainEvent,
|
|
||||||
value_objects::{ExternalMetadataId, MovieId},
|
|
||||||
};
|
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
fn movie_discovered() -> DomainEvent {
|
|
||||||
DomainEvent::MovieDiscovered {
|
|
||||||
movie_id: MovieId::generate(),
|
|
||||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn consumer_yields_published_events() {
|
|
||||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
|
||||||
let (publisher, consumer) = create_event_channel(config);
|
|
||||||
|
|
||||||
publisher.publish(&movie_discovered()).await.unwrap();
|
|
||||||
drop(publisher);
|
|
||||||
|
|
||||||
let mut stream = consumer.consume();
|
|
||||||
let envelope = stream.next().await.unwrap().unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
envelope.event,
|
|
||||||
DomainEvent::MovieDiscovered { .. }
|
|
||||||
));
|
|
||||||
assert!(stream.next().await.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn consumer_yields_multiple_events_in_order() {
|
|
||||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
|
||||||
let (publisher, consumer) = create_event_channel(config);
|
|
||||||
|
|
||||||
publisher.publish(&movie_discovered()).await.unwrap();
|
|
||||||
publisher.publish(&movie_discovered()).await.unwrap();
|
|
||||||
drop(publisher);
|
|
||||||
|
|
||||||
let mut stream = consumer.consume();
|
|
||||||
let first = stream.next().await.unwrap().unwrap();
|
|
||||||
let second = stream.next().await.unwrap().unwrap();
|
|
||||||
assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. }));
|
|
||||||
assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. }));
|
|
||||||
assert!(stream.next().await.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stream_ends_when_publisher_dropped() {
|
|
||||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
|
||||||
let (publisher, consumer) = create_event_channel(config);
|
|
||||||
drop(publisher);
|
|
||||||
|
|
||||||
let mut stream = consumer.consume();
|
|
||||||
assert!(stream.next().await.is_none());
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,6 @@ domain = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
futures = { workspace = true }
|
|
||||||
bytes = { workspace = true }
|
|
||||||
async-stream = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -1,89 +1,51 @@
|
|||||||
use bytes::Bytes;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DiaryEntry, ExportFormat},
|
models::{DiaryEntry, ExportFormat},
|
||||||
ports::DiaryExporter,
|
ports::DiaryExporter,
|
||||||
};
|
};
|
||||||
use futures::stream::BoxStream;
|
|
||||||
|
|
||||||
pub struct ExportAdapter;
|
pub struct ExportAdapter;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl DiaryExporter for ExportAdapter {
|
impl DiaryExporter for ExportAdapter {
|
||||||
fn stream_entries(
|
async fn serialize_entries(
|
||||||
&self,
|
&self,
|
||||||
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
entries: &[DiaryEntry],
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
) -> Result<Vec<u8>, DomainError> {
|
||||||
match format {
|
match format {
|
||||||
ExportFormat::Csv => stream_csv(stream),
|
ExportFormat::Csv => serialize_csv(entries),
|
||||||
ExportFormat::Json => stream_json(stream),
|
ExportFormat::Json => serialize_json(entries),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stream_csv(
|
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||||
entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
let mut out =
|
||||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
|
||||||
use futures::StreamExt;
|
for e in entries {
|
||||||
let header = futures::stream::once(async {
|
let title = csv_escape(e.movie().title().value());
|
||||||
Ok(Bytes::from_static(
|
let year = e.movie().release_year().value();
|
||||||
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
|
let director = e.movie().director().map(csv_escape).unwrap_or_default();
|
||||||
))
|
let rating = e.review().rating().value();
|
||||||
});
|
let comment = e
|
||||||
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
|
.review()
|
||||||
Box::pin(header.chain(rows))
|
.comment()
|
||||||
}
|
.map(|c| csv_escape(c.value()))
|
||||||
|
.unwrap_or_default();
|
||||||
fn stream_json(
|
let watched_at = e.review().watched_at().format("%Y-%m-%d");
|
||||||
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
let ext_id = e
|
||||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
.movie()
|
||||||
Box::pin(async_stream::stream! {
|
.external_metadata_id()
|
||||||
futures::pin_mut!(stream);
|
.map(|id| id.value().to_string())
|
||||||
let mut is_first = true;
|
.unwrap_or_default();
|
||||||
while let Some(r) = futures::StreamExt::next(&mut stream).await {
|
out.push_str(&format!(
|
||||||
match r {
|
"{},{},{},{},{},{},{}\n",
|
||||||
Err(e) => { yield Err(e); return; }
|
title, year, director, rating, comment, watched_at, ext_id
|
||||||
Ok(entry) => {
|
));
|
||||||
let json = serde_json::to_string(&entry_to_json(&entry))
|
}
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()));
|
Ok(out.into_bytes())
|
||||||
let json = match json {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => { yield Err(e); return; }
|
|
||||||
};
|
|
||||||
let prefix = if is_first { "[" } else { "," };
|
|
||||||
is_first = false;
|
|
||||||
yield Ok(Bytes::from(format!("{}{}", prefix, json)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if is_first {
|
|
||||||
yield Ok(Bytes::from_static(b"[]"));
|
|
||||||
} else {
|
|
||||||
yield Ok(Bytes::from_static(b"]"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn csv_row(e: &DiaryEntry) -> String {
|
|
||||||
let title = csv_escape(e.movie().title().value());
|
|
||||||
let year = e.movie().release_year().value();
|
|
||||||
let director = e.movie().director().map(csv_escape).unwrap_or_default();
|
|
||||||
let rating = e.review().rating().value();
|
|
||||||
let comment = e
|
|
||||||
.review()
|
|
||||||
.comment()
|
|
||||||
.map(|c| csv_escape(c.value()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let watched_at = e.review().watched_at().format("%Y-%m-%d");
|
|
||||||
let ext_id = e
|
|
||||||
.movie()
|
|
||||||
.external_metadata_id()
|
|
||||||
.map(|id| id.value().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!(
|
|
||||||
"{},{},{},{},{},{},{}\n",
|
|
||||||
title, year, director, rating, comment, watched_at, ext_id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn csv_escape(s: &str) -> String {
|
fn csv_escape(s: &str) -> String {
|
||||||
@@ -94,18 +56,170 @@ fn csv_escape(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
|
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||||
serde_json::json!({
|
let arr: Vec<serde_json::Value> = entries
|
||||||
"title": e.movie().title().value(),
|
.iter()
|
||||||
"year": e.movie().release_year().value(),
|
.map(|e| {
|
||||||
"director": e.movie().director(),
|
serde_json::json!({
|
||||||
"rating": e.review().rating().value(),
|
"title": e.movie().title().value(),
|
||||||
"comment": e.review().comment().map(|c| c.value().to_string()),
|
"year": e.movie().release_year().value(),
|
||||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
"director": e.movie().director(),
|
||||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
|
"rating": e.review().rating().value(),
|
||||||
})
|
"comment": e.review().comment().map(|c| c.value()),
|
||||||
|
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||||
|
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/lib.rs"]
|
mod tests {
|
||||||
mod tests;
|
use super::ExportAdapter;
|
||||||
|
use domain::{
|
||||||
|
models::{DiaryEntry, ExportFormat, Movie, Review},
|
||||||
|
ports::DiaryExporter,
|
||||||
|
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn make_entry(
|
||||||
|
title: &str,
|
||||||
|
year: u16,
|
||||||
|
director: Option<&str>,
|
||||||
|
rating: u8,
|
||||||
|
comment: Option<&str>,
|
||||||
|
) -> DiaryEntry {
|
||||||
|
make_entry_full(title, year, director, rating, comment, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_entry_full(
|
||||||
|
title: &str,
|
||||||
|
year: u16,
|
||||||
|
director: Option<&str>,
|
||||||
|
rating: u8,
|
||||||
|
comment: Option<&str>,
|
||||||
|
external_id: Option<&str>,
|
||||||
|
) -> DiaryEntry {
|
||||||
|
let movie = Movie::new(
|
||||||
|
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
|
||||||
|
MovieTitle::new(title.to_string()).unwrap(),
|
||||||
|
ReleaseYear::new(year).unwrap(),
|
||||||
|
director.map(str::to_string),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
|
||||||
|
let review = Review::new(
|
||||||
|
movie.id().clone(),
|
||||||
|
user_id,
|
||||||
|
Rating::new(rating).unwrap(),
|
||||||
|
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
|
||||||
|
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
DiaryEntry::new(movie, review)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn csv_has_header_and_one_row() {
|
||||||
|
let adapter = ExportAdapter;
|
||||||
|
let entry = make_entry(
|
||||||
|
"Inception",
|
||||||
|
2010,
|
||||||
|
Some("Christopher Nolan"),
|
||||||
|
5,
|
||||||
|
Some("great"),
|
||||||
|
);
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(&[entry], ExportFormat::Csv)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(bytes).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.starts_with(
|
||||||
|
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(text.contains("Inception"));
|
||||||
|
assert!(text.contains("2010"));
|
||||||
|
assert!(text.contains("Christopher Nolan"));
|
||||||
|
assert!(text.contains("5"));
|
||||||
|
assert!(text.contains("great"));
|
||||||
|
assert!(text.contains("2024-03-15"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn csv_escapes_commas_in_title() {
|
||||||
|
let adapter = ExportAdapter;
|
||||||
|
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(&[entry], ExportFormat::Csv)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(bytes).unwrap();
|
||||||
|
assert!(text.contains("\"Tár, A Film\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn json_is_valid_array() {
|
||||||
|
let adapter = ExportAdapter;
|
||||||
|
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(&[entry], ExportFormat::Json)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(arr.len(), 1);
|
||||||
|
assert_eq!(arr[0]["title"], "Dune");
|
||||||
|
assert_eq!(arr[0]["year"], 2021);
|
||||||
|
assert_eq!(arr[0]["rating"], 5);
|
||||||
|
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
|
||||||
|
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_metadata_id_included_when_present() {
|
||||||
|
let adapter = ExportAdapter;
|
||||||
|
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(&[entry], ExportFormat::Json)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
|
||||||
|
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(
|
||||||
|
&[make_entry_full(
|
||||||
|
"Alien",
|
||||||
|
1979,
|
||||||
|
None,
|
||||||
|
5,
|
||||||
|
None,
|
||||||
|
Some("tt0078748"),
|
||||||
|
)],
|
||||||
|
ExportFormat::Csv,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(bytes).unwrap();
|
||||||
|
assert!(text.contains("tt0078748"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_entries_returns_csv_header_only() {
|
||||||
|
let adapter = ExportAdapter;
|
||||||
|
let bytes = adapter
|
||||||
|
.serialize_entries(&[], ExportFormat::Csv)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(bytes).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
text,
|
||||||
|
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
use super::ExportAdapter;
|
|
||||||
use domain::{
|
|
||||||
models::{DiaryEntry, ExportFormat, Movie, Review},
|
|
||||||
ports::DiaryExporter,
|
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn collect_stream(
|
|
||||||
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
use futures::StreamExt;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
futures::pin_mut!(stream);
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
out.extend_from_slice(&chunk.unwrap());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_stream(
|
|
||||||
entries: Vec<domain::models::DiaryEntry>,
|
|
||||||
) -> futures::stream::BoxStream<
|
|
||||||
'static,
|
|
||||||
Result<domain::models::DiaryEntry, domain::errors::DomainError>,
|
|
||||||
> {
|
|
||||||
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_entry(
|
|
||||||
title: &str,
|
|
||||||
year: u16,
|
|
||||||
director: Option<&str>,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
) -> DiaryEntry {
|
|
||||||
make_entry_full(title, year, director, rating, comment, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_entry_full(
|
|
||||||
title: &str,
|
|
||||||
year: u16,
|
|
||||||
director: Option<&str>,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
external_id: Option<&str>,
|
|
||||||
) -> DiaryEntry {
|
|
||||||
let movie = Movie::new(
|
|
||||||
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
|
|
||||||
MovieTitle::new(title.to_string()).unwrap(),
|
|
||||||
ReleaseYear::new(year).unwrap(),
|
|
||||||
director.map(str::to_string),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
|
|
||||||
let review = Review::new(
|
|
||||||
movie.id().clone(),
|
|
||||||
user_id,
|
|
||||||
Rating::new(rating).unwrap(),
|
|
||||||
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
|
|
||||||
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
|
|
||||||
.unwrap()
|
|
||||||
.and_hms_opt(0, 0, 0)
|
|
||||||
.unwrap(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
DiaryEntry::new(movie, review)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn csv_has_header_and_one_row() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let entry = make_entry(
|
|
||||||
"Inception",
|
|
||||||
2010,
|
|
||||||
Some("Christopher Nolan"),
|
|
||||||
5,
|
|
||||||
Some("great"),
|
|
||||||
);
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
|
||||||
let text = String::from_utf8(bytes).unwrap();
|
|
||||||
assert!(
|
|
||||||
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
|
|
||||||
);
|
|
||||||
assert!(text.contains("Inception"));
|
|
||||||
assert!(text.contains("2010"));
|
|
||||||
assert!(text.contains("Christopher Nolan"));
|
|
||||||
assert!(text.contains("5"));
|
|
||||||
assert!(text.contains("great"));
|
|
||||||
assert!(text.contains("2024-03-15"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn csv_escapes_commas_in_title() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
|
||||||
let text = String::from_utf8(bytes).unwrap();
|
|
||||||
assert!(text.contains("\"Tár, A Film\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn json_is_valid_array() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
|
|
||||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
||||||
assert_eq!(arr.len(), 1);
|
|
||||||
assert_eq!(arr[0]["title"], "Dune");
|
|
||||||
assert_eq!(arr[0]["year"], 2021);
|
|
||||||
assert_eq!(arr[0]["rating"], 5);
|
|
||||||
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
|
|
||||||
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn external_metadata_id_included_when_present() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
|
|
||||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
||||||
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
|
|
||||||
|
|
||||||
let bytes = collect_stream(adapter.stream_entries(
|
|
||||||
entry_stream(vec![make_entry_full(
|
|
||||||
"Alien",
|
|
||||||
1979,
|
|
||||||
None,
|
|
||||||
5,
|
|
||||||
None,
|
|
||||||
Some("tt0078748"),
|
|
||||||
)]),
|
|
||||||
ExportFormat::Csv,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
let text = String::from_utf8(bytes).unwrap();
|
|
||||||
assert!(text.contains("tt0078748"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_entries_returns_csv_header_only() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Csv)).await;
|
|
||||||
let text = String::from_utf8(bytes).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
text,
|
|
||||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_json_is_valid_empty_array() {
|
|
||||||
let adapter = ExportAdapter;
|
|
||||||
let bytes =
|
|
||||||
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Json)).await;
|
|
||||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
|
||||||
assert!(arr.is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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]
|
|
||||||
object-storage = { workspace = true }
|
|
||||||
object_store = "0.11"
|
|
||||||
uuid = { workspace = true }
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)]
|
|
||||||
#[path = "tests/backfill.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#[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)]
|
|
||||||
#[path = "tests/config.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
events::DomainEvent,
|
|
||||||
ports::{EventHandler, ImageRefCommand, ObjectStorage},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::Format;
|
|
||||||
|
|
||||||
pub struct ImageConversionHandler {
|
|
||||||
storage: Arc<dyn ObjectStorage>,
|
|
||||||
image_ref: Arc<dyn ImageRefCommand>,
|
|
||||||
format: Format,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageConversionHandler {
|
|
||||||
pub fn new(
|
|
||||||
storage: Arc<dyn ObjectStorage>,
|
|
||||||
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(DomainError::InfrastructureError)?;
|
|
||||||
|
|
||||||
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)]
|
|
||||||
#[path = "tests/handler.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
mod backfill;
|
|
||||||
mod config;
|
|
||||||
mod handler;
|
|
||||||
|
|
||||||
pub use backfill::ConversionBackfillJob;
|
|
||||||
pub use config::{ConversionConfig, Format};
|
|
||||||
pub use handler::ImageConversionHandler;
|
|
||||||
|
|
||||||
use domain::ports::{
|
|
||||||
EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ObjectStorage, PeriodicJob,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
|
|
||||||
|
|
||||||
pub fn build(
|
|
||||||
object_storage: Arc<dyn ObjectStorage>,
|
|
||||||
image_ref_command: Arc<dyn ImageRefCommand>,
|
|
||||||
image_ref_query: Arc<dyn ImageRefQuery>,
|
|
||||||
event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
) -> anyhow::Result<Option<ConversionPair>> {
|
|
||||||
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(&object_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)))
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use object_storage::ObjectStorageAdapter;
|
|
||||||
use object_store::memory::InMemory;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
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<ObjectStorageAdapter> {
|
|
||||||
Arc::new(ObjectStorageAdapter::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 ObjectStorage>,
|
|
||||||
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 ObjectStorage>,
|
|
||||||
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 ObjectStorage>,
|
|
||||||
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 ObjectStorage>,
|
|
||||||
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());
|
|
||||||
// Old raw key deleted — fallback resolves to .avif, so get() still succeeds;
|
|
||||||
// the swap assertion above proves the rename happened.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 ObjectStorage>,
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "importer"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
xlsx = ["dep:calamine"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
domain = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
csv = { workspace = true }
|
|
||||||
calamine = { version = "0.35", optional = true }
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
mod mapper;
|
|
||||||
mod parsers;
|
|
||||||
|
|
||||||
use domain::{
|
|
||||||
models::{AnnotatedRow, FieldMapping, FileFormat, ImportError, ParsedFile},
|
|
||||||
ports::DocumentParser,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct ImporterDocumentParser;
|
|
||||||
|
|
||||||
impl DocumentParser for ImporterDocumentParser {
|
|
||||||
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError> {
|
|
||||||
match format {
|
|
||||||
FileFormat::Csv => parsers::parse_csv(bytes),
|
|
||||||
FileFormat::Json => parsers::parse_json(bytes),
|
|
||||||
FileFormat::Xlsx => {
|
|
||||||
#[cfg(feature = "xlsx")]
|
|
||||||
{
|
|
||||||
parsers::parse_xlsx(bytes)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "xlsx"))]
|
|
||||||
{
|
|
||||||
Err(ImportError::Xlsx("XLSX support not compiled in".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
|
|
||||||
mapper::apply_mapping(file, mappings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
use domain::models::{
|
|
||||||
AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
|
|
||||||
file.rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| {
|
|
||||||
let result = map_row(row, &file.columns, mappings);
|
|
||||||
AnnotatedRow {
|
|
||||||
result,
|
|
||||||
is_duplicate: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult {
|
|
||||||
let mut import_row = ImportRow::default();
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
for mapping in mappings {
|
|
||||||
let Some(col_idx) = columns.iter().position(|c| c == &mapping.source_column) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let raw_value = row.get(col_idx).map(|s| s.as_str()).unwrap_or("").trim();
|
|
||||||
if raw_value.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(value) = apply_transform(raw_value, &mapping.transform, &mut errors) {
|
|
||||||
set_field(&mut import_row, &mapping.domain_field, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if import_row.title.is_none() && import_row.external_metadata_id.is_none() {
|
|
||||||
errors.push("missing required field: title or external_metadata_id".into());
|
|
||||||
}
|
|
||||||
if import_row.rating.is_none() {
|
|
||||||
errors.push("missing required field: rating".into());
|
|
||||||
}
|
|
||||||
if import_row.watched_at.is_none() {
|
|
||||||
errors.push("missing required field: watched_at".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
RowResult::Valid(import_row)
|
|
||||||
} else {
|
|
||||||
let raw = columns
|
|
||||||
.iter()
|
|
||||||
.zip(row.iter())
|
|
||||||
.map(|(c, v)| (c.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
RowResult::Invalid { errors, raw }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec<String>) -> Option<String> {
|
|
||||||
match transform {
|
|
||||||
Transform::Identity => Some(value.to_string()),
|
|
||||||
Transform::DateFormat(_) => Some(value.to_string()),
|
|
||||||
Transform::RatingScale(factor) => match value.parse::<f64>() {
|
|
||||||
Ok(n) => Some((n * factor).round().to_string()),
|
|
||||||
Err(_) => {
|
|
||||||
errors.push(format!("rating '{}' is not a number", value));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_field(row: &mut ImportRow, field: &DomainField, value: String) {
|
|
||||||
match field {
|
|
||||||
DomainField::Title => row.title = Some(value),
|
|
||||||
DomainField::ReleaseYear => row.release_year = Some(value),
|
|
||||||
DomainField::Director => row.director = Some(value),
|
|
||||||
DomainField::Rating => row.rating = Some(value),
|
|
||||||
DomainField::WatchedAt => row.watched_at = Some(value),
|
|
||||||
DomainField::Comment => row.comment = Some(value),
|
|
||||||
DomainField::ExternalMetadataId => row.external_metadata_id = Some(value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/mapper.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use domain::models::{ImportError, ParsedFile};
|
|
||||||
|
|
||||||
pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err(ImportError::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
let delimiter = detect_delimiter(bytes);
|
|
||||||
let mut rdr = csv::ReaderBuilder::new()
|
|
||||||
.delimiter(delimiter)
|
|
||||||
.from_reader(bytes);
|
|
||||||
|
|
||||||
let columns: Vec<String> = rdr
|
|
||||||
.headers()
|
|
||||||
.map_err(|e| ImportError::Csv(e.to_string()))?
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if columns.is_empty() {
|
|
||||||
return Err(ImportError::NoHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<Vec<String>> = rdr
|
|
||||||
.records()
|
|
||||||
.map(|r| {
|
|
||||||
r.map_err(|e| ImportError::Csv(e.to_string())).map(|rec| {
|
|
||||||
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
|
|
||||||
cells.resize(columns.len(), String::new());
|
|
||||||
cells.truncate(columns.len());
|
|
||||||
cells
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<_, _>>()?;
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Err(ImportError::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ParsedFile { columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_delimiter(bytes: &[u8]) -> u8 {
|
|
||||||
let first_line = bytes.split(|&b| b == b'\n').next().unwrap_or(bytes);
|
|
||||||
let tabs = first_line.iter().filter(|&&b| b == b'\t').count();
|
|
||||||
let commas = first_line.iter().filter(|&&b| b == b',').count();
|
|
||||||
if tabs > commas { b'\t' } else { b',' }
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use domain::models::{ImportError, ParsedFile};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
|
||||||
let value: Value =
|
|
||||||
serde_json::from_slice(bytes).map_err(|e| ImportError::Json(e.to_string()))?;
|
|
||||||
|
|
||||||
let arr = value
|
|
||||||
.as_array()
|
|
||||||
.ok_or_else(|| ImportError::Json("expected a JSON array".into()))?;
|
|
||||||
|
|
||||||
if arr.is_empty() {
|
|
||||||
return Err(ImportError::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
let first = arr[0]
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| ImportError::Json("array elements must be objects".into()))?;
|
|
||||||
let columns: Vec<String> = first.keys().cloned().collect();
|
|
||||||
|
|
||||||
if columns.is_empty() {
|
|
||||||
return Err(ImportError::NoHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<Vec<String>> = arr
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, item)| {
|
|
||||||
let obj = item.as_object().ok_or_else(|| {
|
|
||||||
ImportError::Json(format!("element at index {} is not an object", idx))
|
|
||||||
})?;
|
|
||||||
Ok(columns
|
|
||||||
.iter()
|
|
||||||
.map(|col| obj.get(col).map(value_to_string).unwrap_or_default())
|
|
||||||
.collect())
|
|
||||||
})
|
|
||||||
.collect::<Result<_, ImportError>>()?;
|
|
||||||
|
|
||||||
Ok(ParsedFile { columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_to_string(v: &Value) -> String {
|
|
||||||
match v {
|
|
||||||
Value::String(s) => s.clone(),
|
|
||||||
Value::Null => String::new(),
|
|
||||||
other => other.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
mod csv;
|
|
||||||
mod json;
|
|
||||||
#[cfg(feature = "xlsx")]
|
|
||||||
mod xlsx;
|
|
||||||
|
|
||||||
pub use csv::parse_csv;
|
|
||||||
pub use json::parse_json;
|
|
||||||
#[cfg(feature = "xlsx")]
|
|
||||||
pub use xlsx::parse_xlsx;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn csv_parses_headers_and_rows() {
|
|
||||||
let data = b"title,rating,watched_at\nInception,5,2024-01-01\nDune,4,2024-02-15\n";
|
|
||||||
let file = parse_csv(data).unwrap();
|
|
||||||
assert_eq!(file.columns, vec!["title", "rating", "watched_at"]);
|
|
||||||
assert_eq!(file.rows.len(), 2);
|
|
||||||
assert_eq!(file.rows[0], vec!["Inception", "5", "2024-01-01"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn csv_rejects_empty() {
|
|
||||||
assert!(parse_csv(b"").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tsv_parses_correctly() {
|
|
||||||
let data = b"title\trating\nInception\t5\n";
|
|
||||||
let file = parse_csv(data).unwrap();
|
|
||||||
assert_eq!(file.columns, vec!["title", "rating"]);
|
|
||||||
assert_eq!(file.rows[0], vec!["Inception", "5"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn json_array_of_objects() {
|
|
||||||
let data = br#"[{"title":"Inception","rating":"5"},{"title":"Dune","rating":"4"}]"#;
|
|
||||||
let file = parse_json(data).unwrap();
|
|
||||||
assert_eq!(file.columns.len(), 2);
|
|
||||||
assert!(file.columns.contains(&"title".to_string()));
|
|
||||||
assert_eq!(file.rows.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn json_empty_array_errors() {
|
|
||||||
assert!(parse_json(b"[]").is_err());
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
use calamine::{Data, Reader, Xlsx, open_workbook_from_rs};
|
|
||||||
use domain::models::{ImportError, ParsedFile};
|
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
|
||||||
let cursor = Cursor::new(bytes);
|
|
||||||
let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor)
|
|
||||||
.map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?;
|
|
||||||
|
|
||||||
let sheet_name = workbook
|
|
||||||
.sheet_names()
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ImportError::Empty)?;
|
|
||||||
|
|
||||||
let range = workbook
|
|
||||||
.worksheet_range(&sheet_name)
|
|
||||||
.map_err(|e| ImportError::Xlsx(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut iter = range.rows();
|
|
||||||
|
|
||||||
let header = iter.next().ok_or(ImportError::NoHeader)?;
|
|
||||||
let columns: Vec<String> = header
|
|
||||||
.iter()
|
|
||||||
.map(|c| cell_to_string(c).trim().to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if columns.is_empty() {
|
|
||||||
return Err(ImportError::NoHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<Vec<String>> = iter
|
|
||||||
.map(|row| {
|
|
||||||
let mut cells: Vec<String> = row.iter().map(cell_to_string).collect();
|
|
||||||
cells.resize(columns.len(), String::new());
|
|
||||||
cells.truncate(columns.len());
|
|
||||||
cells
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Err(ImportError::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ParsedFile { columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cell_to_string(cell: &Data) -> String {
|
|
||||||
match cell {
|
|
||||||
Data::String(s) => s.clone(),
|
|
||||||
Data::Float(f) => {
|
|
||||||
if f.fract() == 0.0 {
|
|
||||||
format!("{}", *f as i64)
|
|
||||||
} else {
|
|
||||||
format!("{}", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Data::Int(i) => i.to_string(),
|
|
||||||
Data::Bool(b) => b.to_string(),
|
|
||||||
Data::DateTime(dt) => {
|
|
||||||
// ExcelDateTime::to_ymd_hms_milli() works without the chrono feature.
|
|
||||||
let (year, month, day, _, _, _, _) = dt.to_ymd_hms_milli();
|
|
||||||
format!("{:04}-{:02}-{:02}", year, month, day)
|
|
||||||
}
|
|
||||||
Data::DateTimeIso(s) => s.clone(),
|
|
||||||
Data::DurationIso(s) => s.clone(),
|
|
||||||
Data::Empty | Data::Error(_) => String::new(),
|
|
||||||
// Fallback for unexpected calamine Data variants; renders as debug string
|
|
||||||
other => format!("{other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user