Compare commits

...

7 Commits

Author SHA1 Message Date
7faf14fb2f docs: update project description to reflect current scope
All checks were successful
CI / Check / Test (push) Successful in 43m3s
2026-06-12 12:53:15 +02:00
ddb5966c9b docs: mention Insomnia collection in API section
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:51:24 +02:00
4ec231017e docs: badges, TOC, quick start, env var table, screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:46:31 +02:00
fab236688b screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:31:13 +02:00
4683a408d7 fix(spa): use import type for UpdateUserSettingsRequest
All checks were successful
CI / Check / Test (push) Successful in 38m33s
2026-06-12 02:29:50 +02:00
6d4c70553a fix: remove unused UserFederationSettingsQuery import in worker/db.rs 2026-06-12 02:28:02 +02:00
ca7ca51949 feat: per-entity federation privacy toggles for reviews and watchlist
- add federate_reviews + federate_watchlist to UserSettings (default true)
- new UserFederationSettingsQuery port with FederationFlags struct
- remove get_user_federate_goals from LocalApContentQuery
- gate ReviewLogged, ReviewUpdated, WatchlistEntryAdded, on_poster_synced on flags
- goals gating migrated to UserFederationSettingsQuery
- ReviewDeleted and WatchlistEntryRemoved ungated (tombstones always fire)
- sqlite + postgres migrations and adapter impls
- settings API and SPA toggles
2026-06-12 02:26:01 +02:00
32 changed files with 514 additions and 162 deletions

137
README.md
View File

@@ -1,6 +1,49 @@
# Movies Diary
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.
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.
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Built with Rust](https://img.shields.io/badge/built_with-Rust-orange.svg?logo=rust)](https://www.rust-lang.org/)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/)
[![ActivityPub](https://img.shields.io/badge/ActivityPub-federated-5b5ea6)](https://activitypub.rocks/)
[![SQLite](https://img.shields.io/badge/database-SQLite%20%7C%20PostgreSQL-003B57?logo=sqlite&logoColor=white)](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
@@ -28,6 +71,18 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- 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`, deprecated) for logging reviews, bulk CSV import, and diary browsing
## Screenshots
> SPA at `/app/` — React + TanStack Router + shadcn/ui
| Feed | Movie | Person |
|------|-------|--------|
| ![Feed](screenshots/feed.jpeg) | ![Movie detail](screenshots/movie.jpeg) | ![Person detail](screenshots/person.jpeg) |
| Profile | Wrap-Up | Wrap-Up card |
|---------|---------|--------------|
| ![Profile](screenshots/profile.jpeg) | ![Wrap-Up stats](screenshots/wrapup-stats.jpeg) | ![Wrap-Up shareable card](screenshots/wrapup-card.jpeg) |
## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
@@ -75,59 +130,35 @@ 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)
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables
## Configuration
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values.
Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
```env
# Database
DATABASE_URL=sqlite://movies.db
# Authentication
JWT_SECRET=change-me
# OMDb metadata
OMDB_API_KEY=your-key
# TMDb metadata + enrichment (optional — enables full cast/crew/genre data)
# TMDB_API_KEY=your-key
# Public base URL (used for ActivityPub actor URLs and canonical links)
BASE_URL=https://yourdomain.example.com
# Image storage — pick one backend:
# Option A: local filesystem (zero deps)
IMAGE_STORAGE_BACKEND=local
IMAGE_STORAGE_PATH=./images
# Option B: S3-compatible (MinIO, AWS S3, etc.)
# IMAGE_STORAGE_BACKEND=s3
# MINIO_ENDPOINT=http://localhost:9000
# MINIO_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Image conversion (optional — converts stored images to AVIF or WebP to save space)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif or webp
# Optional
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,worker=info,application=info
# CORS — comma-separated origins for SPA dev (omit or "*" for any)
# CORS_ORIGINS=http://localhost:5173
# Event bus — "db" (default, uses same database) or "nats"
EVENT_BUS_BACKEND=db
# NATS_URL=nats://localhost:4222 # required when EVENT_BUS_BACKEND=nats
```
| Variable | Default | Required | Description |
|---|---|---|---|
| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string |
| `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:
@@ -153,6 +184,8 @@ Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs`
- **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.
## SPA
The single-page app lives in `spa/` and is served at `/app/` by the backend. For local development:

View File

@@ -4,7 +4,7 @@ use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::LocalApContentQuery,
ports::{LocalApContentQuery, UserFederationSettingsQuery},
value_objects::{MovieId, ReviewId, UserId},
};
use std::sync::Arc;
@@ -17,6 +17,7 @@ use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
}
@@ -24,11 +25,13 @@ impl ActivityPubEventHandler {
pub fn new(
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
) -> Self {
Self {
ap_service,
content_query,
federation_settings,
base_url,
}
}
@@ -131,6 +134,19 @@ impl EventHandler for ActivityPubEventHandler {
impl ActivityPubEventHandler {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
@@ -184,6 +200,19 @@ impl ActivityPubEventHandler {
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
@@ -250,6 +279,19 @@ impl ActivityPubEventHandler {
external_metadata_id: &Option<String>,
added_at: &chrono::NaiveDateTime,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
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());
@@ -316,6 +358,20 @@ impl ActivityPubEventHandler {
for entry in entries {
let review = entry.review();
let user_id = review.user_id();
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
continue;
}
let ap_id = review_url(&self.base_url, review.id());
let actor = actor_url(&self.base_url, user_id.value());
@@ -343,12 +399,16 @@ impl ActivityPubEventHandler {
user_id: &UserId,
year: u16,
) -> anyhow::Result<()> {
if !self
.content_query
.get_user_federate_goals(user_id)
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(false)
{
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let Some((goal, current)) = self
@@ -384,12 +444,16 @@ impl ActivityPubEventHandler {
target_count: u32,
is_create: bool,
) -> anyhow::Result<()> {
if !self
.content_query
.get_user_federate_goals(user_id)
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(false)
{
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let current = self
@@ -418,12 +482,16 @@ impl ActivityPubEventHandler {
}
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
if !self
.content_query
.get_user_federate_goals(user_id)
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(false)
{
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.base_url, user_id.value(), year);

View File

@@ -52,6 +52,7 @@ pub struct ActivityPubDeps {
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String,
pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
@@ -68,6 +69,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_goal_repo,
local_ap_content,
user_repo,
federation_settings,
base_url,
allow_registration,
event_publisher,
@@ -129,6 +131,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
federation_settings,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -229,23 +229,6 @@ impl LocalApContentQuery for PostgresApContentQuery {
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,

View File

@@ -93,6 +93,7 @@ pub struct PostgresWireOutput {
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
}
@@ -108,6 +109,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
let user_settings_repo = std::sync::Arc::new(
user_settings::PostgresUserSettingsRepository::new(pool.clone()),
);
Ok(PostgresWireOutput {
pool: pool.clone(),
movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _,
@@ -125,9 +130,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::PostgresUserSettingsRepository::new(
pool.clone(),
)) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool))
as _,
})

View File

@@ -1,6 +1,9 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
errors::DomainError,
models::UserSettings,
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
value_objects::UserId,
};
use sqlx::{PgPool, Row};
@@ -23,20 +26,25 @@ impl PostgresUserSettingsRepository {
impl UserSettingsRepository for PostgresUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string();
let row =
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = $1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0);
let goals: bool = r.try_get("federate_goals").unwrap_or(true);
let reviews: bool = r.try_get("federate_reviews").unwrap_or(true);
let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true);
Ok(UserSettings::from_persistence(
user_id.clone(),
federate != 0,
goals,
reviews,
watchlist,
))
}
None => Ok(UserSettings::new(user_id.clone())),
@@ -45,18 +53,52 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 };
sqlx::query(
"INSERT INTO user_settings (user_id, federate_goals) VALUES ($1, $2) \
ON CONFLICT (user_id) DO UPDATE SET federate_goals = $2",
"INSERT INTO user_settings (user_id, federate_goals, federate_reviews, federate_watchlist) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (user_id) DO UPDATE \
SET federate_goals = $2, federate_reviews = $3, federate_watchlist = $4",
)
.bind(&uid)
.bind(federate)
.bind(settings.federate_goals())
.bind(settings.federate_reviews())
.bind(settings.federate_watchlist())
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
}
#[async_trait]
impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = $1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let goals: bool = r.try_get("federate_goals").unwrap_or(true);
let reviews: bool = r.try_get("federate_reviews").unwrap_or(true);
let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true);
Ok(FederationFlags {
goals,
reviews,
watchlist,
})
}
None => Ok(FederationFlags {
goals: true,
reviews: true,
watchlist: true,
}),
}
}
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -5,7 +5,7 @@ use domain::{
ports::LocalApContentQuery,
value_objects::{MovieId, ReviewId, UserId},
};
use sqlx::{Row, SqlitePool};
use sqlx::SqlitePool;
use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow};
@@ -169,23 +169,6 @@ impl LocalApContentQuery for SqliteApContentQuery {
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,

View File

@@ -89,6 +89,7 @@ pub struct SqliteWireOutput {
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
}
@@ -113,6 +114,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
pool.clone(),
));
Ok(SqliteWireOutput {
pool: pool.clone(),
movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
@@ -128,9 +133,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
pool.clone(),
)) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _,
})
}

View File

@@ -1,6 +1,9 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
errors::DomainError,
models::UserSettings,
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
value_objects::UserId,
};
use sqlx::{Row, SqlitePool};
@@ -23,20 +26,25 @@ impl SqliteUserSettingsRepository {
impl UserSettingsRepository for SqliteUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string();
let row =
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = ?",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0);
let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
Ok(UserSettings::from_persistence(
user_id.clone(),
federate != 0,
goals != 0,
reviews != 0,
watchlist != 0,
))
}
None => Ok(UserSettings::new(user_id.clone())),
@@ -45,15 +53,55 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 };
sqlx::query("INSERT OR REPLACE INTO user_settings (user_id, federate_goals) VALUES (?, ?)")
.bind(&uid)
.bind(federate)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
sqlx::query(
"INSERT OR REPLACE INTO user_settings \
(user_id, federate_goals, federate_reviews, federate_watchlist) \
VALUES (?, ?, ?, ?)",
)
.bind(&uid)
.bind(if settings.federate_goals() { 1i64 } else { 0 })
.bind(if settings.federate_reviews() { 1i64 } else { 0 })
.bind(if settings.federate_watchlist() {
1i64
} else {
0
})
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
}
#[async_trait]
impl UserFederationSettingsQuery for SqliteUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = ?",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
Ok(FederationFlags {
goals: goals != 0,
reviews: reviews != 0,
watchlist: watchlist != 0,
})
}
None => Ok(FederationFlags {
goals: true,
reviews: true,
watchlist: true,
}),
}
}
}

View File

@@ -29,9 +29,13 @@ pub struct UpdateGoalRequest {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}

View File

@@ -11,5 +11,7 @@ async fn returns_default_settings() {
.await
.unwrap();
assert!(!settings.federate_goals());
assert!(settings.federate_goals());
assert!(settings.federate_reviews());
assert!(settings.federate_watchlist());
}

View File

@@ -13,7 +13,31 @@ async fn updates_federate_goals() {
let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil();
crate::users::update_settings::execute(
user_settings.clone(),
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: false,
federate_reviews: true,
federate_watchlist: true,
},
)
.await
.unwrap();
let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(!settings.federate_goals());
assert!(settings.federate_reviews());
assert!(settings.federate_watchlist());
}
#[tokio::test]
async fn updates_federate_reviews() {
let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil();
crate::users::update_settings::execute(
@@ -21,6 +45,8 @@ async fn updates_federate_goals() {
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: true,
federate_reviews: false,
federate_watchlist: true,
},
)
.await
@@ -28,4 +54,31 @@ async fn updates_federate_goals() {
let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(settings.federate_goals());
assert!(!settings.federate_reviews());
assert!(settings.federate_watchlist());
}
#[tokio::test]
async fn updates_federate_watchlist() {
let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil();
crate::users::update_settings::execute(
user_settings.clone(),
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: true,
federate_reviews: true,
federate_watchlist: false,
},
)
.await
.unwrap();
let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(settings.federate_goals());
assert!(settings.federate_reviews());
assert!(!settings.federate_watchlist());
}

View File

@@ -5,6 +5,8 @@ use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects::
pub struct UpdateUserSettingsCommand {
pub user_id: uuid::Uuid,
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
pub async fn execute(
@@ -14,6 +16,8 @@ pub async fn execute(
let uid = UserId::from_uuid(cmd.user_id);
let mut settings = user_settings.get(&uid).await?;
settings.set_federate_goals(cmd.federate_goals);
settings.set_federate_reviews(cmd.federate_reviews);
settings.set_federate_watchlist(cmd.federate_watchlist);
user_settings.save(&settings).await
}

View File

@@ -4,27 +4,34 @@ use crate::value_objects::UserId;
pub struct UserSettings {
user_id: UserId,
federate_goals: bool,
federate_reviews: bool,
federate_watchlist: bool,
}
impl UserSettings {
pub fn new(user_id: UserId) -> Self {
Self {
user_id,
federate_goals: false,
federate_goals: true,
federate_reviews: true,
federate_watchlist: true,
}
}
pub fn from_persistence(user_id: UserId, federate_goals: bool) -> Self {
pub fn from_persistence(
user_id: UserId,
federate_goals: bool,
federate_reviews: bool,
federate_watchlist: bool,
) -> Self {
Self {
user_id,
federate_goals,
federate_reviews,
federate_watchlist,
}
}
pub fn set_federate_goals(&mut self, value: bool) {
self.federate_goals = value;
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
@@ -32,4 +39,24 @@ impl UserSettings {
pub fn federate_goals(&self) -> bool {
self.federate_goals
}
pub fn set_federate_goals(&mut self, value: bool) {
self.federate_goals = value;
}
pub fn federate_reviews(&self) -> bool {
self.federate_reviews
}
pub fn set_federate_reviews(&mut self, value: bool) {
self.federate_reviews = value;
}
pub fn federate_watchlist(&self) -> bool {
self.federate_watchlist
}
pub fn set_federate_watchlist(&mut self, value: bool) {
self.federate_watchlist = value;
}
}

View File

@@ -456,6 +456,17 @@ pub trait UserSettingsRepository: Send + Sync {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>;
}
pub struct FederationFlags {
pub goals: bool,
pub reviews: bool,
pub watchlist: bool,
}
#[async_trait]
pub trait UserFederationSettingsQuery: Send + Sync {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError>;
}
#[async_trait]
pub trait RemoteGoalRepository: Send + Sync {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>;
@@ -502,8 +513,6 @@ pub trait LocalApContentQuery: Send + Sync {
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError>;
async fn get_goal_with_progress(
&self,
user_id: &UserId,

View File

@@ -18,10 +18,10 @@ use crate::{
collections::{PageParams, Paginated},
},
ports::{
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository,
FederationFlags, GoalRepository, ImportProfileRepository, ImportSessionRepository,
MovieProfileRepository, MovieRepository, RefreshSessionRepository, ReviewRepository,
UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
},
value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -441,6 +441,22 @@ impl UserSettingsRepository for InMemoryUserSettingsRepository {
}
}
#[async_trait]
impl UserFederationSettingsQuery for InMemoryUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let store = self.store.lock().unwrap();
let settings = store
.get(&user_id.value())
.cloned()
.unwrap_or_else(|| UserSettings::new(user_id.clone()));
Ok(FederationFlags {
goals: settings.federate_goals(),
reviews: settings.federate_reviews(),
watchlist: settings.federate_watchlist(),
})
}
}
// ── InMemoryWebhookTokenRepository ──────────────────────────────────────────
pub struct InMemoryWebhookTokenRepository {

View File

@@ -36,6 +36,7 @@ pub struct DatabaseOutput {
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub db_pool: DbPool,
@@ -78,6 +79,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(),
@@ -119,6 +121,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _,

View File

@@ -176,6 +176,8 @@ pub async fn get_settings(
.await?;
Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(),
federate_reviews: settings.federate_reviews(),
federate_watchlist: settings.federate_watchlist(),
}))
}
@@ -198,6 +200,8 @@ pub async fn update_settings(
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,
federate_reviews: req.federate_reviews,
federate_watchlist: req.federate_watchlist,
},
)
.await?;

View File

@@ -119,6 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep),

View File

@@ -30,6 +30,7 @@ pub struct WorkerDbOutput {
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
pub federation_settings: Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub db_pool: DbPool,
}
@@ -64,6 +65,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(),
)) as _,
federation_settings: w.federation_settings,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -95,6 +97,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _,
federation_settings: w.federation_settings,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -241,6 +241,7 @@ async fn main() -> anyhow::Result<()> {
base_url,
allow_registration,
event_publisher: Arc::clone(&event_publisher),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
})
.await?;

BIN
screenshots/feed.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
screenshots/movie.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
screenshots/person.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
screenshots/profile.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -18,11 +18,15 @@ export type UpdateGoalRequest = {
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
federate_reviews: z.boolean(),
federate_watchlist: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
federate_reviews: boolean
federate_watchlist: boolean
}
export function getGoals() {

View File

@@ -178,6 +178,10 @@
"privacy": "Privacy",
"federateGoals": "Share goals on Fediverse",
"federateGoalsDesc": "Broadcast goal progress to followers",
"federateReviews": "Share reviews on Fediverse",
"federateReviewsDesc": "Broadcast diary entries to followers",
"federateWatchlist": "Share watchlist on Fediverse",
"federateWatchlistDesc": "Broadcast watchlist additions to followers",
"export": "Export",
"exportDesc": "Download your diary",
"exportCsv": "CSV",

View File

@@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next"
import { useMutation } from "@tanstack/react-query"
import {
ArrowLeft,
BookOpen,
ChevronRight,
Download,
Key,
List,
LogOut,
RefreshCw,
ShieldBan,
@@ -19,6 +21,7 @@ import { Switch } from "@/components/ui/switch"
import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { reindexSearch } from "@/lib/api/users"
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
import type { UpdateUserSettingsRequest } from "@/lib/api/goals"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({
@@ -128,6 +131,18 @@ function PrivacySection() {
const { data: settings } = useSettings()
const updateMutation = useUpdateSettings()
const disabled = updateMutation.isPending
const toggle = (patch: Partial<UpdateUserSettingsRequest>) => {
if (!settings) return
updateMutation.mutate({
federate_goals: settings.federate_goals,
federate_reviews: settings.federate_reviews,
federate_watchlist: settings.federate_watchlist,
...patch,
})
}
return (
<div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
@@ -145,11 +160,41 @@ function PrivacySection() {
</p>
</div>
<Switch
checked={settings?.federate_goals ?? false}
onCheckedChange={(checked) =>
updateMutation.mutate({ federate_goals: checked })
}
disabled={updateMutation.isPending}
checked={settings?.federate_goals ?? true}
onCheckedChange={(checked) => toggle({ federate_goals: checked })}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<BookOpen className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateReviews")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateReviewsDesc")}
</p>
</div>
<Switch
checked={settings?.federate_reviews ?? true}
onCheckedChange={(checked) => toggle({ federate_reviews: checked })}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<List className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateWatchlist")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateWatchlistDesc")}
</p>
</div>
<Switch
checked={settings?.federate_watchlist ?? true}
onCheckedChange={(checked) => toggle({ federate_watchlist: checked })}
disabled={disabled}
/>
</div>
</div>