Compare commits

..

131 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
33aa5bdab3 fmt
All checks were successful
CI / Check / Test (push) Successful in 38m21s
2026-06-12 01:46:16 +02:00
b844339795 fix(domain): ImportSession::new() generates own ID, add from_persistence() 2026-06-12 01:41:03 +02:00
cedb13d7a8 fix(domain): typed VOs in MovieEnrichmentRequested and PersonEnrichmentRequested 2026-06-12 01:34:47 +02:00
aec5f6b058 feat(dependencies): add async-stream and bytes to Cargo.toml and Cargo.lock
Some checks failed
CI / Check / Test (push) Has been cancelled
refactor(adapters): update diary.rs to use BoxStream from futures
2026-06-12 01:20:45 +02:00
d9234ecd11 fix(presentation): log errors in diary export stream 2026-06-12 01:18:54 +02:00
010ee404c8 feat(presentation): pipe diary export stream to Body::from_stream 2026-06-12 01:15:23 +02:00
d4c42f8567 feat(application): export_diary::execute returns BoxStream<Bytes> 2026-06-12 01:11:36 +02:00
9c44330f14 feat(adapters): stream_user_history in SQLite and Postgres diary adapters 2026-06-12 01:10:21 +02:00
2fa118570f feat(export): stream_entries — CSV/JSON streaming via BoxStream<Bytes> 2026-06-12 01:08:11 +02:00
ded7517a8a feat(domain): DiaryRepository::stream_user_history, DiaryExporter::stream_entries 2026-06-12 01:05:32 +02:00
bf272bf8d9 perf(import): parallelize row processing with JoinSet + Semaphore (limit 10)
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:40:27 +02:00
6f34b7b5ec fix(worker): nack on transient handler failures, ack on permanent
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:30:06 +02:00
17d4de461b feat(domain): DomainError::is_transient() for retry classification 2026-06-12 00:27:16 +02:00
40cb15e7cb refactor(postgres): split fat PostgresRepository into per-port structs
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:00:15 +02:00
c80287bb9e refactor(presentation): use split sqlite repos in api_test 2026-06-11 23:52:14 +02:00
06ab5c8df1 refactor(sqlite): split fat SqliteMovieRepository into per-port structs 2026-06-11 23:49:55 +02:00
57520c00f3 refactor: move AppContext to presentation crate, structurally enforce boundary
All checks were successful
CI / Check / Test (push) Successful in 39m33s
2026-06-11 23:18:28 +02:00
b5cc7f8371 refactor(search): fix test to not use AppContext 2026-06-11 23:00:01 +02:00
9ca5ada924 refactor(auth): LoginDeps, RegisterDeps, RefreshDeps, RegisterAndLoginDeps, RefreshSessionCleanupJob 2026-06-11 22:58:42 +02:00
70d1f10e3d refactor(users): fix test files to not use AppContext 2026-06-11 22:49:44 +02:00
61980b0cfb refactor(users): GetProfileDeps, UpdateProfileDeps, scoped Arc deps 2026-06-11 22:47:17 +02:00
7bf5c47f5b refactor(diary): DeleteReviewDeps, GetMovieSocialPageDeps, GetActivityFeedDeps 2026-06-11 22:37:35 +02:00
ddf100cfc2 refactor(wrapup): scoped deps — HandleWrapUpRequestedDeps, flat-Arc jobs 2026-06-11 22:29:30 +02:00
cdff0de53d refactor(movies): collapse single-field deps structs to Arc params 2026-06-11 22:17:09 +02:00
1e62f12903 refactor(movies): EnrichMovieDeps, ReindexSearchDeps, SyncPosterDeps, SearchReindexHandler, EnrichmentStalenessJob 2026-06-11 22:13:25 +02:00
66bd138927 refactor(person): EnrichPersonDeps + GetPersonDeps, PersonEnrichmentHandler 2026-06-11 22:05:38 +02:00
b29f3020e6 refactor(integrations): IngestWatchEventDeps, scoped Arc deps, WatchEventCleanupJob 2026-06-11 22:01:15 +02:00
76edd52bb0 refactor(import): fix test files to not use AppContext 2026-06-11 21:51:24 +02:00
b5ff43d9dc refactor(import): scoped Arc deps, ImportSessionCleanupJob 2026-06-11 21:49:15 +02:00
b552c1d156 refactor(watchlist): WatchlistAddDeps, scoped Arc deps 2026-06-11 21:40:48 +02:00
f006ba00a8 refactor(goals): scoped Arc deps instead of AppContext 2026-06-11 21:36:11 +02:00
2b295e10ba refactor(search): scoped Arc dep instead of AppContext 2026-06-11 21:32:19 +02:00
20ac0d3adf fmt
All checks were successful
CI / Check / Test (push) Successful in 38m10s
2026-06-11 14:44:53 +02:00
8ac87a3735 refactor: split jobs.rs into per-context modules 2026-06-11 14:44:23 +02:00
4f0f44dec3 test: refresh token rotation, logout revocation, login refresh token 2026-06-11 14:42:39 +02:00
96c753c2c6 spa: silent refresh on 401, persistent login 2026-06-11 14:39:10 +02:00
822f3f9d9c api: refresh + logout endpoints, update login response 2026-06-11 14:37:09 +02:00
55feaa353f app: refresh/logout use cases, update login with refresh token 2026-06-11 14:35:53 +02:00
3a3f3b3889 db: refresh_sessions migration + SQLite/Postgres adapters 2026-06-11 14:31:46 +02:00
ef9ecbae06 domain: add RefreshSession model + repository port 2026-06-11 14:29:43 +02:00
db285b513b fix: swap import/export icons 2026-06-11 14:13:51 +02:00
2617c77b42 feat: add arrow navigation to horizontal scroll strips 2026-06-11 14:12:42 +02:00
e618e1aa84 feat: horizontal poster strip for person filmography 2026-06-11 14:10:20 +02:00
7dc372a7b6 feat: redesign person page with Cards, SwipeTabs, collapsible bio 2026-06-11 14:07:27 +02:00
e39fcf6802 feat: show person age calculated from birthday 2026-06-11 14:04:52 +02:00
b09ef4686a fmt 2026-06-11 14:03:05 +02:00
2074a2244e refactor: move person enrichment staleness check to application layer 2026-06-11 14:01:44 +02:00
338ecb71c8 perf: reduce worker concurrency to 4 to ease SQLite write contention 2026-06-11 13:55:48 +02:00
8cb90b256c fix: guard also_known_as access when undefined 2026-06-11 13:52:44 +02:00
c05087a6c7 fmt 2026-06-11 13:51:33 +02:00
0fdc79af23 fix: add enrichment columns to test schema 2026-06-11 13:51:14 +02:00
262ba5ca39 fix: collapse nested if per clippy 2026-06-11 13:48:08 +02:00
57c720b22f fix: collapse nested if per clippy 2026-06-11 13:47:32 +02:00
53b7f730cb refactor: drop EnrichmentHandler alias, use MovieEnrichmentHandler directly 2026-06-11 13:46:30 +02:00
e8fa24bf9b refactor: split tmdb-enrichment into client, movie_handler, person_handler 2026-06-11 13:46:00 +02:00
7437ed89ad fix: handle PersonEnrichmentRequested in test fakes 2026-06-11 13:43:37 +02:00
b2a41db290 feat: rich person detail page with bio, dates, links 2026-06-11 13:42:04 +02:00
9b932cde8e feat: wire person enrichment handler, update API DTOs 2026-06-11 13:40:16 +02:00
a68e19aad7 feat: TMDB person enrichment client + event handler 2026-06-11 13:38:07 +02:00
371a3cdc46 app: person enrichment use case + staleness checks 2026-06-11 13:36:43 +02:00
517a18da8a db: person enrichment migrations + adapter updates 2026-06-11 13:34:10 +02:00
7df24a19ee domain: add person enrichment fields, event, port 2026-06-11 13:30:19 +02:00
549923b92e feat: show relative watched date and comment preview in profile entries 2026-06-11 13:03:49 +02:00
3cbb406ea7 feat: add export route to settings in route tree 2026-06-11 13:01:12 +02:00
21cc6ed437 fix: move useDocumentTitle before early returns (Rules of Hooks) 2026-06-11 12:59:13 +02:00
5dc90724d3 feat: JSON import + mapping presets in SPA
- Accept .json files in import upload, send format to backend
- Backend endpoint PUT /import/sessions/{id}/profile/{profile_id}
- Load saved presets on mapping step, auto-apply and skip to preview
- Save current mapping as preset on confirm step
- Delete presets from mapping step
2026-06-11 12:58:08 +02:00
9a894c3a95 refactor: move export to its own settings page under Data group 2026-06-11 12:46:46 +02:00
acc20d2f43 feat: dynamic page titles across SPA
useDocumentTitle hook sets document.title per page.
Dynamic: movie name, person name, username, wrapup year.
Static: diary, profile, search, social, all settings pages.
2026-06-11 12:45:01 +02:00
a95be0b131 feat: add search input on user profile pages 2026-06-11 12:39:14 +02:00
f10b114e83 feat: expose search param on user profile API endpoint 2026-06-11 12:37:20 +02:00
c020135cd1 feat: move diary export to settings page with CSV/JSON options 2026-06-11 12:36:03 +02:00
ad55897871 i18n: add export and profile search translations 2026-06-11 12:34:29 +02:00
87fcdc12ca clean up
All checks were successful
CI / Check / Test (push) Successful in 37m41s
2026-06-10 11:02:18 +02:00
d14199813f perf: optimize compile times
All checks were successful
CI / Check / Test (push) Successful in 37m59s
- Remove sqlx 'macros' feature — migrated to runtime queries in f4fd915,
  proc-macro crates (sqlx-macros-core, sqlx-macros) were dead weight
- Add [profile.dev] debug=1 (line tables only) + split-debuginfo=unpacked
  to speed up linking on macOS
- Add [profile.dev.package.'*'] opt-level=2 to compile deps faster at
  runtime; one-time cost on first build, cached after that
2026-06-10 03:33:48 +02:00
f52219b2b7 docs: update architecture diagram — add domain services, Password VO
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 03:25:14 +02:00
6e21ec115d fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 03:24:17 +02:00
deae83cfd1 fix: Password uses char count not byte length, redact Debug output, tighten test assertion
Some checks failed
CI / Check / Test (push) Failing after 49s
2026-06-10 03:23:23 +02:00
c4d6b68ef9 refactor: enforce password min-length via domain Password value object
Some checks failed
CI / Check / Test (push) Failing after 49s
2026-06-10 03:15:43 +02:00
d8cff33679 refactor: move profile-field count validation into domain UserProfile 2026-06-10 03:13:19 +02:00
e9aa6131ae refactor: extract wrapup analytics into domain::services::wrapup_analyzer 2026-06-10 03:10:44 +02:00
43f6c5679d fmt
All checks were successful
CI / Check / Test (push) Successful in 15m39s
2026-06-10 02:56:56 +02:00
956e51530e refactor: move domain inline tests to separate files under tests/
Some checks failed
CI / Check / Test (push) Failing after 44s
Match the application crate convention: each source file references its
tests via #[cfg(test)] #[path = "tests/filename.rs"] mod tests; with
the test code in a sibling tests/ directory.

- events.rs       -> tests/events.rs
- value_objects.rs -> tests/value_objects.rs
- models/mod.rs   -> models/tests/mod.rs  (renamed from tests.rs)
- models/person.rs -> models/tests/person.rs
- models/goal.rs   -> models/tests/goal.rs
- models/watch_event.rs -> models/tests/watch_event.rs
- services/review_history.rs -> services/tests/review_history.rs
2026-06-10 02:55:47 +02:00
b882569ee1 fix: use correct i18n key for comment placeholder in queue tab
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 02:47:04 +02:00
5dbc02b58f fix: remove .sqlx copy from Dockerfile
Some checks failed
CI / Check / Test (push) Has been cancelled
Queries were migrated to the runtime sqlx API (no query! macros) in
f4fd915, making the pre-verified .sqlx cache obsolete. The directory
is also gitignored, so the COPY would break any fresh build.
2026-06-10 02:41:59 +02:00
d389e26e39 fix: broadcast goal progress on review log, fix goal handler security gaps
Some checks failed
CI / Check / Test (push) Has been cancelled
- Broadcast GoalUpdated AP note after ReviewLogged so federated goal
  progress reflects the new review count without requiring a manual goal edit
- Add attribution check in GoalObjectHandler::on_update (mirrors
  review_handler) to prevent any remote actor from overwriting another's goal
- Implement on_actor_removed in GoalObjectHandler via new
  RemoteGoalRepository::remove_all_by_actor — remote goals were never
  cleaned up when an actor unfollowed or was deleted
- Add remove_all_by_actor to SQLite, Postgres, Noop, and test Panic impls
2026-06-10 02:40:25 +02:00
05d062f4e0 fmt
All checks were successful
CI / Check / Test (push) Successful in 15m57s
2026-06-09 02:31:02 +02:00
70b3ca0f5c refactor: split domain models, move presentation logic out of app layer
Some checks failed
CI / Check / Test (push) Failing after 47s
Split domain/models/mod.rs (630 lines) into focused files:
movie.rs, review.rs, user.rs, stats.rs, enrichment.rs, feed.rs.

Move URL/date formatting from application use cases to
presentation mappers — use cases now return raw domain data.

Delete watchlist/get_page.rs (was pure presentation logic),
replace with presentation/mappers/watchlist.rs.

Document signature conventions in CONTRIBUTING.md.
2026-06-09 02:29:11 +02:00
ac03182aa6 docs: add ReviewLogger to architecture diagram
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 02:17:49 +02:00
6d3182525f fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 02:16:18 +02:00
7bcbc59587 fix: implement get_all_reviews_for_user, add crate metadata
Some checks failed
CI / Check / Test (push) Failing after 43s
Replace todo!() stubs in sqlite/postgres adapters with
actual queries. Add description+license to presentation crate.
2026-06-09 02:12:35 +02:00
d867a14b28 add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled
Extract ReviewLogger trait to decouple import/integrations
from diary::log_review (cross-module coupling smell).

Add in-memory fakes for all repository ports, enabling
isolated testing of every use case module without a database.

Coverage: domain+application 22% → 80%, 427 tests.
2026-06-09 02:07:26 +02:00
30a6200b5b remove wrapup video rendering (ffmpeg)
All checks were successful
CI / Check / Test (push) Successful in 15m34s
SPA handles wrapup visuals client-side; server-side
renderer was dead code pulling in ffmpeg + image crates.
2026-06-09 00:36:44 +02:00
f4fd915e35 refactor: replace sqlx compile-time macros with runtime queries
Some checks failed
CI / Check / Test (push) Failing after 44s
No longer requires DATABASE_URL at build time.
2026-06-09 00:19:02 +02:00
f3eee9131b chore: remove stale .sqlx cache, add to gitignore
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:09:30 +02:00
9e4370b068 fix: register goal + settings endpoints in OpenAPI spec
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:07:29 +02:00
37ab24be74 docs: npm → bun
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:06:22 +02:00
307113381f docs: add CONTRIBUTING.md with setup, architecture, PR guidelines
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:04:30 +02:00
6d238b6c3d docs: mark TUI as deprecated experiment
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:02:20 +02:00
a7a11dde08 refactor: split monolithic handlers + testing into domain-grouped modules
Some checks failed
CI / Check / Test (push) Has been cancelled
handlers/api.rs (1706 LOC) + html.rs (1735 LOC) → 12 domain files:
auth, diary, movies, users, search, watchlist, goals, social,
integrations, helpers + existing import/webhook/wrapup/images/rss.

domain/testing.rs (1309 LOC) → testing/ module:
in_memory, fakes, noops, panics, wrapup.

Update README + architecture.mmd with goals feature.
2026-06-08 23:59:23 +02:00
988e15eac6 fix: add missing common.edit i18n key
All checks were successful
CI / Check / Test (push) Successful in 18m44s
2026-06-08 22:38:54 +02:00
fff5f4af2f feat: goals — "watch N movies in YEAR" with progress bar
Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
2026-06-08 22:37:52 +02:00
213f9a2433 Remove backdrop filter from input slot in aero-theme.css for improved performance
All checks were successful
CI / Check / Test (push) Successful in 17m46s
2026-06-08 21:05:16 +02:00
4dacbbfded fix: update poster_url on remote review Update activity
All checks were successful
CI / Check / Test (push) Successful in 21m35s
on_update was ignoring posterUrl from the AP note, so remote instances
never got the poster even after receiving the Update.
2026-06-04 23:21:19 +02:00
886f26c7dc fix: broadcast AP Update after poster sync to fix missing posters on remote instances
Some checks failed
CI / Check / Test (push) Has been cancelled
New movies had no poster at AP broadcast time (race between poster sync
and ReviewLogged handler). PosterSynced event now fires after sync
completes, triggering Update notes so remote apps get the poster URL.
2026-06-04 23:12:27 +02:00
6a10ba15c1 feat(spa): comment field in queue review, date picker in log sheet
All checks were successful
CI / Check / Test (push) Successful in 17m20s
2026-06-04 22:35:30 +02:00
237f1e7d32 style: fmt persons test
All checks were successful
CI / Check / Test (push) Successful in 16m49s
2026-06-04 17:59:05 +02:00
c647f4c13b fix: clippy 1.96 lints — map_or, duplicate_mod, needless_borrows, slice_refs
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-04 17:58:01 +02:00
3bb2dbee19 ci: split workflows — Gitea tests only, GitHub tests + Docker push
Some checks failed
CI / Check / Test (push) Failing after 6m24s
2026-06-04 17:48:37 +02:00
b0bfc149e1 ci: build + push Docker image to ghcr.io on master/tags
Some checks failed
CI / Build & Push Docker Image (push) Has been cancelled
CI / Check / Test (push) Has been cancelled
2026-06-04 17:47:04 +02:00
7e8a1b8379 feat: batteries-included deployment — compose, .env.example, sane defaults
Some checks failed
CI / Check / Test (push) Failing after 6m21s
2026-06-04 17:32:34 +02:00
4bd8dcbf05 feat: wrapup wow — animated counters, scroll-reveal, fun facts, component split, budget formatting
Some checks failed
CI / Check / Test (push) Failing after 6m25s
2026-06-04 17:15:35 +02:00
ebf9a9f4a8 feat: replace CSS bar charts with recharts, fix label readability
Some checks failed
CI / Check / Test (push) Failing after 6m20s
2026-06-04 17:04:29 +02:00
a76386345f feat: SPA polish — wrapup sections, shareable card, webhook instructions, blocked merge
Some checks failed
CI / Check / Test (push) Failing after 6m35s
2026-06-04 16:56:09 +02:00
49728f8cd7 fix: fall back to converted extensions in object storage get
Some checks failed
CI / Check / Test (push) Failing after 6m36s
2026-06-04 16:22:57 +02:00
5334312d64 refactor: extract reindex + enrichment logic from handlers into use cases
Some checks failed
CI / Check / Test (push) Failing after 6m45s
2026-06-04 16:09:35 +02:00
81a377cbb2 docs: proper SPA readme
Some checks failed
CI / Check / Test (push) Failing after 6m21s
2026-06-04 15:58:03 +02:00
689c8a9ac3 docs: add hexagonal architecture mermaid diagram
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-04 15:52:38 +02:00
5e6bc93a6a feat: SPA quick wins — refresh, back nav, watchlist add, dates, haptics, export
Some checks failed
CI / Check / Test (push) Failing after 7m1s
- feed refresh button with spinning icon
- BackButton component using history.back()
- watchlist bookmark icon on search results
- timeAgo/shortDate date formatting (date-fns)
- haptic feedback on star rating, swipe-to-delete, review log
- global error toasts via QueryClient mutation defaults
- diary CSV export button
2026-06-04 15:45:31 +02:00
4a3a99c6d2 fix: disable vaul repositionInputs to fix iOS keyboard in drawers
Some checks failed
CI / Check / Test (push) Failing after 6m35s
2026-06-04 15:35:00 +02:00
dacc057af6 fix: lazy-load wrapup backgrounds, cap sqlite pool to 4 connections
Some checks failed
CI / Check / Test (push) Failing after 6m32s
backgrounds were decoded to RGBA at startup (~173MB for 9 images).
now only store paths, decode on demand during video generation.
2026-06-04 14:57:47 +02:00
01c1082290 feat: SPA bug fixes, interactivity, federation badges, admin reindex
Some checks failed
CI / Check / Test (push) Failing after 10m55s
- fix wrapup status "completed" → "Ready"
- fix unfollow sending {handle} instead of {actor_url}
- fix missing post import in users.ts
- fix feed/activity cache not invalidated on review delete/log
- add person_id to cast/crew types, link to /people pages
- add movie_id to wrapup MovieRef, link highlights to /movies pages
- add wrapup actor profile images + clickable person links
- add federated review globe badge in feed and movie detail
- add fediverse handle (@user@instance) in follower/following cards
- add admin reindex search button in settings
- add wrapup user picker for admins
- add username/display_name to user summary type
- use tmdbProfileUrl for person search results
2026-06-04 14:43:41 +02:00
bd7dc648c4 feat: search reindex, worker improvements, person IDs, user display names
- add admin POST /api/v1/admin/reindex-search endpoint + event-driven handler
- backfill persons from movie_cast/movie_crew into persons table
- paginate person list_page/backfill_from_credits_batch to cap memory
- concurrent worker event dispatch with semaphore (max 8)
- graceful worker shutdown (drain in-flight tasks on SIGINT)
- always ack events, log handler errors as warnings (no infinite retry)
- NATS ack_wait 600s, AtomicBool guard against concurrent reindex
- add username/display_name to UserSummaryDto and users list
- add person_id to CastMemberDto/CrewMemberDto via get_movie_profile use case
- add movie_id to wrapup MovieRef, person_id to wrapup PersonStat
- thread tmdb_person_id through wrapup cast pipeline
- add is_federated to FeedEntryDto
- cap orphaned persons query with LIMIT 500
- add SPA link to classic site footer
2026-06-04 14:43:28 +02:00
af8e58aeb8 fix: use avatar/banner URLs directly, not through posterUrl()
Some checks failed
CI / Check / Test (push) Failing after 11m13s
2026-06-04 04:30:38 +02:00
fd9a053702 fix: exclude spa/.env from docker build to prevent baked localhost URLs
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-04 04:27:02 +02:00
487 changed files with 27120 additions and 14681 deletions

View File

@@ -9,3 +9,5 @@ docs/
dev.db
spa/node_modules/
spa/dist/
spa/.env
spa/.env.local

View File

@@ -1,72 +1,55 @@
# Database backend — "sqlite" (default) or "postgres"
DATABASE_BACKEND=sqlite
# ── Required ──────────────────────────────────────────────────
# Option A: SQLite (default, zero external dependencies)
DATABASE_URL=sqlite://movies.db
# Option B: PostgreSQL
# DATABASE_BACKEND=postgres
# DATABASE_URL=postgres://user:password@localhost:5432/movies_diary
# Database (SQLite — file auto-created on first run)
DATABASE_URL=sqlite:///data/movies.db
# Authentication
JWT_SECRET=change-me
JWT_TTL_SECONDS=86400
JWT_SECRET=change-me-to-a-random-string
# OMDb/TMDB metadata
OMDB_API_KEY=your-key
TMDB_API_KEY=your-key
# Movie metadata — one of these is required (TMDB preferred)
# TMDB_API_KEY=your-tmdb-key
OMDB_API_KEY=your-omdb-key
# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B:
# ── Recommended ──────────────────────────────────────────────
# Option A: local filesystem (zero external dependencies)
POSTER_STORAGE_BACKEND=local
POSTER_STORAGE_PATH=./posters
# Public URL (used for ActivityPub federation and canonical links)
BASE_URL=https://yourdomain.example.com
# Option B: S3-compatible (MinIO, AWS S3, etc.)
# POSTER_STORAGE_BACKEND=s3
# Enable sign-ups (default: false — set true so you can register)
ALLOW_REGISTRATION=true
# ── 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_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional
HOST=0.0.0.0
PORT=3000
BASE_URL=http://localhost:3000
SECURE_COOKIES=false
ALLOW_REGISTRATION=false
RATE_LIMIT=20
POSTER_FETCH_TIMEOUT_SECONDS=30
# ── Optional ─────────────────────────────────────────────────
# Event bus — "db" (default) or "nats"
# The worker binary must run alongside the presentation to process events.
EVENT_BUS_BACKEND=db
# TMDb enrichment (cast, crew, genres — separate from search metadata above)
# TMDB_API_KEY=your-tmdb-key
# Option A: DB queue (default — no extra infrastructure needed)
# Events are persisted in the same database as the app and polled by the worker.
# EVENT_QUEUE_POLL_INTERVAL_MS=500 # polling interval (default: 500ms)
# EVENT_QUEUE_BATCH_SIZE=10 # rows claimed per poll cycle (default: 10)
# EVENT_QUEUE_MAX_ATTEMPTS=5 # retries before dead-lettering (default: 5)
# Option B: NATS (at-least-once delivery, recommended for higher throughput)
# EVENT_BUS_BACKEND=nats
# NATS_URL=nats://localhost:4222
# NATS_MODE=jetstream # "jetstream" (default, at-least-once) or "core" (fire-and-forget)
# NATS_SUBJECT_PREFIX=movies-diary.events
# NATS_STREAM_NAME=MOVIES_DIARY_EVENTS
# NATS_CONSUMER_NAME=worker
# Image conversion (optional — converts stored images to save disk space)
# Disable by default; enable in the worker by setting ENABLED=true.
# Image conversion (converts uploaded posters to AVIF or WebP)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif | webp
# IMAGE_CONVERSION_FORMAT=avif
# Annual Wrap-Up (video generation — optional, requires ffmpeg)
# WRAPUP_FONT_PATH=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
# WRAPUP_LOGO_PATH=./static/logo.webp
# WRAPUP_BG_DIR=./static/wrapup-backgrounds # directory of jpg/png/webp images for video slide backgrounds
# FFMPEG_PATH=ffmpeg
# WRAPUP_MAX_CONCURRENT=2
# Server
# HOST=0.0.0.0
# PORT=3000
# RATE_LIMIT=60
# SECURE_COOKIES=true
# RUST_LOG=presentation=info,tower_http=info,worker=info
RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=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

42
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,42 @@
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

View File

@@ -40,3 +40,42 @@ jobs:
- 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.actor }}
password: ${{ secrets.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

3
.gitignore vendored
View File

@@ -15,4 +15,5 @@
.superpowers/
docs/
imgs/
imgs/
.sqlx/

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM reviews",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_profile_fields WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086"
}

View File

@@ -1,68 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,50 +0,0 @@
{
"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"
}

View File

@@ -1,50 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0"
}

View File

@@ -1,20 +0,0 @@
{
"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"
}

View File

@@ -1,20 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07"
}

View File

@@ -1,44 +0,0 @@
{
"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 u.avatar_path\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, u.avatar_path\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"
},
{
"name": "avatar_path",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
true,
true
]
},
"hash": "771236034946abb52809203ba9a1259900e343174cf3f435fc18d9e689deb187"
}

View File

@@ -1,50 +0,0 @@
{
"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"
}

View File

@@ -1,68 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4"
}

View File

@@ -1,12 +0,0 @@
{
"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"
}

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca"
}

View File

@@ -1,62 +0,0 @@
{
"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"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_profiles WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f"
}

View File

@@ -1,26 +0,0 @@
{
"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"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,26 +0,0 @@
{
"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"
}

View File

@@ -1,62 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "parsed_data",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "row_results",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "expires_at",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910"
}

View File

@@ -1,12 +0,0 @@
{
"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"
}

View File

@@ -1,20 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM movies WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
}

View File

@@ -1,68 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM reviews WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
}

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d"
}

View File

@@ -1,32 +0,0 @@
{
"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"
}

87
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,87 @@
# 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

453
Cargo.lock generated
View File

@@ -2,22 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "activitypub"
version = "0.1.0"
@@ -262,15 +246,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
@@ -339,6 +314,7 @@ name = "application"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"chrono",
"domain",
"futures",
@@ -351,15 +327,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -398,15 +365,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "askama"
version = "0.16.0"
@@ -610,6 +568,28 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -693,26 +673,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror 2.0.18",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
@@ -879,12 +839,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -906,15 +860,6 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
[[package]]
name = "bitstream-io"
version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
dependencies = [
"no_std_io2",
]
[[package]]
name = "blake2"
version = "0.10.6"
@@ -961,12 +906,6 @@ version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -1142,12 +1081,6 @@ dependencies = [
"encoding_rs",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1273,16 +1206,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
@@ -1334,12 +1257,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1928,29 +1845,17 @@ dependencies = [
name = "export"
version = "0.1.0"
dependencies = [
"async-stream",
"async-trait",
"bytes",
"chrono",
"domain",
"futures",
"serde_json",
"tokio",
"uuid",
]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -1973,12 +1878,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -2267,16 +2166,6 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -2322,17 +2211,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -2735,18 +2613,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"qoi",
"ravif 0.13.0",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
]
@@ -2761,7 +2631,7 @@ dependencies = [
"image",
"object-storage",
"object_store",
"ravif 0.11.20",
"ravif",
"tokio",
"tracing",
"uuid",
@@ -2778,24 +2648,6 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imageproc"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77"
dependencies = [
"ab_glyph",
"approx",
"getrandom 0.2.17",
"image",
"itertools 0.12.1",
"nalgebra",
"num",
"rand 0.8.6",
"rand_distr",
"rayon",
]
[[package]]
name = "imgref"
version = "1.12.1"
@@ -3086,12 +2938,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.186"
@@ -3278,16 +3124,6 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -3295,7 +3131,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]]
@@ -3431,21 +3266,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "nalgebra"
version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
dependencies = [
"approx",
"matrixmultiply",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "nats"
version = "0.1.0"
@@ -3497,15 +3317,6 @@ dependencies = [
"signatory",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -3760,15 +3571,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3815,12 +3617,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem"
version = "3.0.6"
@@ -4078,9 +3874,12 @@ name = "postgres"
version = "0.1.0"
dependencies = [
"anyhow",
"async-stream",
"async-trait",
"bytes",
"chrono",
"domain",
"futures",
"serde",
"serde_json",
"sqlx",
@@ -4265,15 +4064,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -4453,16 +4243,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "ratatui"
version = "0.30.0"
@@ -4558,8 +4338,8 @@ dependencies = [
"arg_enum_proc_macro",
"arrayvec",
"av1-grain",
"bitstream-io 2.6.0",
"built 0.7.7",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools 0.12.1",
@@ -4583,41 +4363,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io 4.10.0",
"built 0.8.1",
"cfg-if",
"interpolate_name",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.18",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.11.20"
@@ -4628,51 +4373,10 @@ dependencies = [
"imgref",
"loop9",
"quick-error",
"rav1e 0.7.1",
"rav1e",
"rgb",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e 0.8.1",
"rayon",
"rgb",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -5047,15 +4751,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safe_arch"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -5320,19 +5015,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simba"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
dependencies = [
"approx",
"num-complex",
"num-traits",
"paste",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -5471,9 +5153,12 @@ name = "sqlite"
version = "0.1.0"
dependencies = [
"anyhow",
"async-stream",
"async-trait",
"bytes",
"chrono",
"domain",
"futures",
"serde",
"serde_json",
"sqlx",
@@ -6039,20 +5724,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -6408,12 +6079,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tui"
version = "0.1.0"
@@ -6916,12 +6581,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
@@ -7004,16 +6663,6 @@ dependencies = [
"wasite",
]
[[package]]
name = "wide"
version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -7494,21 +7143,6 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"wrapup-renderer",
]
[[package]]
name = "wrapup-renderer"
version = "0.1.0"
dependencies = [
"ab_glyph",
"async-trait",
"domain",
"image",
"imageproc",
"tempfile",
"tokio",
"tracing",
]
[[package]]
@@ -7517,12 +7151,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yoke"
version = "0.8.2"
@@ -7771,15 +7399,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"

View File

@@ -31,7 +31,6 @@ members = [
"crates/adapters/plex",
"crates/adapters/sqlite-search",
"crates/adapters/postgres-search",
"crates/adapters/wrapup-renderer",
]
resolver = "2"
@@ -39,6 +38,7 @@ resolver = "2"
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
bytes = "1"
futures = "0.3"
async-stream = "0.3"
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -53,7 +53,6 @@ sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"macros",
] }
rand = "0.9"
reqwest = { version = "0.13", features = ["json", "query"] }
@@ -92,4 +91,10 @@ 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" }
wrapup-renderer = { path = "crates/adapters/wrapup-renderer" }
[profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
[profile.dev.package."*"]
opt-level = 2 # compile deps faster at runtime; paid once, cached after

View File

@@ -14,7 +14,6 @@ WORKDIR /build
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo
COPY .sqlx ./.sqlx
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/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
@@ -45,7 +44,6 @@ 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/wrapup-renderer/Cargo.toml crates/adapters/wrapup-renderer/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
@@ -63,8 +61,8 @@ RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates
# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache.
# No live database needed at compile time.
# All sqlx queries use the runtime API (no query! macros), so no database
# or .sqlx cache is needed at compile time.
#
# To build with PostgreSQL backend instead:
# --build-arg FEATURES=postgres,postgres-federation
@@ -80,7 +78,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget \
libwebp7 \
ffmpeg \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -1,9 +1,18 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
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

220
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
@@ -17,7 +60,8 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- 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
- 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, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`, downloadable MP4 video with branded slides; admin-triggered or auto-generated in January
- 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, rewatches, budget analysis), 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)
- 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
@@ -25,7 +69,19 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- 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`)
- 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 |
|------|-------|--------|
| ![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
@@ -58,7 +114,6 @@ adapters/
postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (used in tests)
wrapup-renderer — annual wrap-up video generator (slide compositing via image/ab_glyph, stitching via ffmpeg)
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
@@ -75,66 +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
# Annual Wrap-Up video (optional — requires ffmpeg)
# WRAPUP_FONT_PATH=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
# WRAPUP_LOGO_PATH=./static/logo.webp # watermark on video slides
# WRAPUP_BG_DIR=./static/wrapup-backgrounds # slide background images (jpg/png/webp)
# FFMPEG_PATH=ffmpeg
# WRAPUP_MAX_CONCURRENT=2 # max parallel video renders
# 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:
@@ -160,25 +184,29 @@ 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:
```bash
cd spa && npm install && npm run dev # http://localhost:5173/app/
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, `npm run build` outputs to `spa/dist/` which the backend serves statically (included in Docker image automatically).
For production, `bun run build` outputs to `spa/dist/` which the backend serves statically (included in Docker image automatically).
## Terminal UI
## 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
cargo run -p tui
```
Supports review logging, bulk CSV import (column order matches the export format), and diary browsing with review history.
Supports review logging, bulk CSV import, and diary browsing.
## Development
@@ -195,46 +223,38 @@ make test # cargo test
## Test
```bash
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
cargo test -p application # domain-level unit tests only — no database required
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
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` crate has unit tests for core use cases 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.
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
The image contains both `presentation` (HTTP server) and `worker` (event processor), plus `ffmpeg` and DejaVu fonts for wrap-up video generation. Run them as separate containers sharing the same data volume:
### Quick start
```bash
# Build (SQLite + federation + NATS support)
docker build -t movies-diary \
--build-arg FEATURES=sqlite,sqlite-federation,nats .
# HTTP server
docker run -p 3000:3000 \
-e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \
movies-diary
# Event worker (separate container, same image)
docker run \
-e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \
--entrypoint ./worker \
movies-diary
cp .env.example .env
# Edit .env — set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY)
docker compose up -d
```
To build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation,nats`
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
docker build -t movies-diary .
docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
```
Build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation`
## Media Server Integration
@@ -260,7 +280,7 @@ Movies you finish watching appear in your watch queue at `/watch-queue` — rate
## 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 and downloadable MP4 video.
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
@@ -276,7 +296,9 @@ Omit `user_id` for a global instance wrap-up. The worker computes stats in the b
**Auto-generate:** The worker runs a daily job in January that generates wrap-ups for all users with reviews in the previous year.
**Video:** Requires `ffmpeg` installed. Set `WRAPUP_FONT_PATH` and `WRAPUP_LOGO_PATH` for branded slides. Set `WRAPUP_BG_DIR` to a directory of background images for frutiger aero-style glass-panel slides. Cast profile photos and movie posters are embedded automatically. Download via `GET /api/v1/wrapups/{id}/video`.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, architecture overview, and PR guidelines.
## License

140
architecture.mmd Normal file
View File

@@ -0,0 +1,140 @@
---
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, get_diary,<br/>get_activity_feed, export"]
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search"]
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile"]
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"]
end
subgraph EventHandlers["Event Handlers"]
EH_ENRICH["EnrichmentHandler"]
EH_DISCOVER["MovieDiscoveryIndexer"]
EH_CLEANUP["SearchCleanupHandler"]
EH_REINDEX["SearchReindexHandler"]
EH_WRAPUP["WrapUpEventHandler"]
end
subgraph Jobs["Periodic Jobs"]
JOB_IMPORT["ImportSessionCleanup"]
JOB_WATCH["WatchEventCleanup"]
JOB_STALE["EnrichmentStaleness"]
JOB_WRAPGEN["WrapUpAutoGenerate"]
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, DiaryEntry,<br/>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["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
P_FEDERATION["SocialQueryPort<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/>SearchReindexRequested, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, Password, ...</i>"]
end
subgraph ApiTypes["api-types (0 domain deps)"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
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_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</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

View File

@@ -5,11 +5,15 @@ use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url;
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
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]
@@ -40,8 +44,11 @@ impl ApObjectHandler for CompositeObjectHandler {
) -> anyhow::Result<()> {
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|| (object.get("movieTitle").is_some() && object.get("rating").is_none());
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
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 {
@@ -56,8 +63,11 @@ impl ApObjectHandler for CompositeObjectHandler {
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
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 {
Ok(())
}
@@ -66,12 +76,14 @@ impl ApObjectHandler for CompositeObjectHandler {
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(())
}

View File

@@ -1,21 +1,23 @@
use async_trait::async_trait;
use chrono::Datelike;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::LocalApContentQuery,
value_objects::{ReviewId, UserId},
ports::{LocalApContentQuery, UserFederationSettingsQuery},
value_objects::{MovieId, ReviewId, UserId},
};
use std::sync::Arc;
use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::review_to_ap_object;
use crate::urls::{actor_url, review_url};
use crate::objects::{goal_to_ap_object, review_to_ap_object};
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,
}
@@ -23,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,
}
}
@@ -97,6 +101,32 @@ impl EventHandler for ActivityPubEventHandler {
.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())),
_ => Ok(()),
}
}
@@ -104,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(()),
@@ -146,6 +189,9 @@ impl ActivityPubEventHandler {
.broadcast_create_note(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(())
}
@@ -154,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(()),
@@ -220,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());
@@ -267,4 +339,165 @@ impl ActivityPubEventHandler {
.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.content_query.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
};
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
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());
let obj = review_to_ap_object(
review,
ap_id,
actor,
movie.title().value().to_string(),
movie.release_year().value(),
poster_url.clone(),
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(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(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let Some((goal, current)) = self
.content_query
.get_goal_with_progress(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
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,
goal.target_count(),
current,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.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(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let current = self
.content_query
.get_goal_with_progress(user_id, year)
.await
.ok()
.flatten()
.map(|(_, c)| c)
.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_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update_note(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(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);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,95 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository};
use k_ap::ApObjectHandler;
use url::Url;
use crate::objects::GoalObject;
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
}
#[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(())
}
}

View File

@@ -1,6 +1,7 @@
pub mod composite_handler;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod goal_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
@@ -48,8 +49,10 @@ pub struct ActivityPubDeps {
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 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>,
@@ -63,8 +66,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
blocklist_repo,
review_store,
remote_watchlist_repo,
remote_goal_repo,
local_ap_content,
user_repo,
federation_settings,
base_url,
allow_registration,
event_publisher,
@@ -79,9 +84,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
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 });
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")
@@ -124,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

@@ -191,6 +191,64 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
}
}
// ── Goal object ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GoalObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
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: NoteType::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;

View File

@@ -23,6 +23,7 @@ pub trait RemoteReviewRepository: Send + Sync {
rating: u8,
comment: Option<&str>,
watched_at: NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()>;
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;

View File

@@ -153,6 +153,7 @@ impl ApObjectHandler for ReviewObjectHandler {
obj.rating.min(5),
obj.comment.as_deref(),
obj.watched_at.naive_utc(),
obj.poster_url.as_deref(),
)
.await?;

View File

@@ -13,6 +13,11 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
.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!(

View File

@@ -30,7 +30,7 @@ impl AuthConfig {
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
.unwrap_or(900u64);
Ok(Self {
secret,
ttl_seconds,

View File

@@ -2,7 +2,10 @@ use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId},
models::{ExternalPersonId, PersonId},
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -86,6 +89,31 @@ pub enum EventPayload {
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,
},
}
impl EventPayload {
@@ -107,6 +135,12 @@ impl EventPayload {
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",
}
}
}
@@ -176,7 +210,7 @@ impl From<&DomainEvent> for EventPayload {
external_metadata_id,
} => EventPayload::MovieEnrichmentRequested {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.clone(),
external_metadata_id: external_metadata_id.value().to_string(),
},
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
DomainEvent::WatchlistEntryAdded {
@@ -248,6 +282,48 @@ impl From<&DomainEvent> for EventPayload {
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(),
},
}
}
}
@@ -315,7 +391,8 @@ impl TryFrom<EventPayload> for DomainEvent {
external_metadata_id,
} => Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_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 {
@@ -398,6 +475,48 @@ impl TryFrom<EventPayload> for DomainEvent {
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),
}),
}
}
}

View File

@@ -8,6 +8,9 @@ domain = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }

View File

@@ -1,51 +1,89 @@
use async_trait::async_trait;
use bytes::Bytes;
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat},
ports::DiaryExporter,
};
use futures::stream::BoxStream;
pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter {
async fn serialize_entries(
fn stream_entries(
&self,
entries: &[DiaryEntry],
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
format: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
) -> BoxStream<'static, Result<Bytes, DomainError>> {
match format {
ExportFormat::Csv => serialize_csv(entries),
ExportFormat::Json => serialize_json(entries),
ExportFormat::Csv => stream_csv(stream),
ExportFormat::Json => stream_json(stream),
}
}
}
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let mut out =
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
for e in entries {
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();
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
));
}
Ok(out.into_bytes())
fn stream_csv(
entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
use futures::StreamExt;
let header = futures::stream::once(async {
Ok(Bytes::from_static(
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
))
});
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
Box::pin(header.chain(rows))
}
fn stream_json(
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
Box::pin(async_stream::stream! {
futures::pin_mut!(stream);
let mut is_first = true;
while let Some(r) = futures::StreamExt::next(&mut stream).await {
match r {
Err(e) => { yield Err(e); return; }
Ok(entry) => {
let json = serde_json::to_string(&entry_to_json(&entry))
.map_err(|e| DomainError::InfrastructureError(e.to_string()));
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 {
@@ -56,22 +94,16 @@ fn csv_escape(s: &str) -> String {
}
}
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"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()))
fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value().to_string()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
})
}
#[cfg(test)]

View File

@@ -5,6 +5,27 @@ use domain::{
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,
@@ -55,10 +76,8 @@ async fn csv_has_header_and_one_row() {
5,
Some("great"),
);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
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")
@@ -75,10 +94,8 @@ async fn csv_has_header_and_one_row() {
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 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\""));
}
@@ -87,10 +104,8 @@ async fn csv_escapes_commas_in_title() {
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 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");
@@ -104,27 +119,23 @@ async fn json_is_valid_array() {
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 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 = adapter
.serialize_entries(
&[make_entry_full(
"Alien",
1979,
None,
5,
None,
Some("tt0078748"),
)],
ExportFormat::Csv,
)
.await
.unwrap();
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"));
}
@@ -132,13 +143,20 @@ async fn external_metadata_id_included_when_present() {
#[tokio::test]
async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter;
let bytes = adapter
.serialize_entries(&[], ExportFormat::Csv)
.await
.unwrap();
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());
}

View File

@@ -130,7 +130,8 @@ async fn converts_jpeg_to_avif_and_swaps_key() {
vec![("avatars/u1".into(), "avatars/u1.avif".into())]
);
assert!(storage.get("avatars/u1.avif").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err());
// Old raw key deleted — fallback resolves to .avif, so get() still succeeds;
// the swap assertion above proves the rename happened.
}
#[tokio::test]
@@ -156,5 +157,4 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
vec![("avatars/u1".into(), "avatars/u1.webp".into())]
);
assert!(storage.get("avatars/u1.webp").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err());
}

View File

@@ -120,6 +120,7 @@ impl NatsJetStreamConsumer {
pull::Config {
durable_name: Some(cfg.consumer_name.clone()),
filter_subject: subject_filter,
ack_wait: std::time::Duration::from_secs(600),
..Default::default()
},
)

View File

@@ -18,6 +18,12 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
DomainEvent::WrapUpRequested { .. } => "wrapup.requested",
DomainEvent::WrapUpCompleted { .. } => "wrapup.completed",
DomainEvent::SearchReindexRequested => "search.reindex.requested",
DomainEvent::PosterSynced { .. } => "poster.synced",
DomainEvent::GoalCreated { .. } => "goal.created",
DomainEvent::GoalUpdated { .. } => "goal.updated",
DomainEvent::GoalDeleted { .. } => "goal.deleted",
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
};
format!("{prefix}.{suffix}")
}

View File

@@ -6,8 +6,7 @@ pub struct StorageConfig(Arc<dyn ObjectStore>);
impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> {
let backend = std::env::var("IMAGE_STORAGE_BACKEND")
.context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?;
let backend = std::env::var("IMAGE_STORAGE_BACKEND").unwrap_or_else(|_| "local".into());
let store: Arc<dyn ObjectStore> = match backend.as_str() {
"s3" => build_s3_store(
@@ -19,8 +18,7 @@ impl StorageConfig {
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
)?,
"local" => build_local_store(
&std::env::var("IMAGE_STORAGE_PATH")
.context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?,
&std::env::var("IMAGE_STORAGE_PATH").unwrap_or_else(|_| "./images".into()),
)?,
other => {
anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local")

View File

@@ -23,6 +23,19 @@ impl ObjectStorageAdapter {
pub fn from_config(config: StorageConfig) -> Self {
Self::new(config.build_store())
}
async fn get_exact(&self, key: &str) -> Result<Vec<u8>, DomainError> {
let path = Path::from(key);
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Image not found".into()),
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
#[async_trait]
@@ -37,16 +50,19 @@ impl ObjectStorage for ObjectStorageAdapter {
}
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError> {
let path = Path::from(key);
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Image not found".into()),
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
match self.get_exact(key).await {
Ok(bytes) => return Ok(bytes),
Err(DomainError::NotFound(_)) if !has_image_ext(key) => {}
Err(e) => return Err(e),
}
// Key may reference a pre-conversion path; try converted extensions.
for ext in [".webp", ".avif"] {
let candidate = format!("{key}{ext}");
if let Ok(bytes) = self.get_exact(&candidate).await {
return Ok(bytes);
}
}
Err(DomainError::NotFound("Image not found".into()))
}
async fn get_stream(
@@ -77,6 +93,14 @@ impl ObjectStorage for ObjectStorageAdapter {
}
}
fn has_image_ext(key: &str) -> bool {
key.ends_with(".webp")
|| key.ends_with(".avif")
|| key.ends_with(".png")
|| key.ends_with(".jpg")
|| key.ends_with(".jpeg")
}
pub struct ImageCleanupHandler {
object_storage: Arc<dyn ObjectStorage>,
}

View File

@@ -82,7 +82,22 @@ impl PosterSyncHandler {
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path);
self.movie_repository.upsert_movie(&movie).await
self.movie_repository.upsert_movie(&movie).await?;
if let Err(e) = self
.event_publisher
.publish(&DomainEvent::PosterSynced {
movie_id: movie.id().clone(),
})
.await
{
tracing::warn!(
"failed to emit PosterSynced for {}: {e}",
movie.id().value()
);
}
Ok(())
}
}
@@ -108,7 +123,7 @@ impl EventHandler for PosterSyncHandler {
if already_has_poster {
return Ok(());
}
(movie_id.value(), external_metadata_id.clone())
(movie_id.value(), external_metadata_id.value().to_owned())
}
_ => return Ok(()),
};

View File

@@ -685,6 +685,7 @@ impl RemoteReviewRepository for PostgresFederationRepository {
rating: u8,
comment: Option<&str>,
watched_at: chrono::NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()> {
let watched_at_str = datetime_to_str(&watched_at);
sqlx::query(
@@ -698,6 +699,17 @@ impl RemoteReviewRepository for PostgresFederationRepository {
.bind(actor_url)
.execute(&self.pool)
.await?;
if let Some(url) = poster_url {
sqlx::query(
"UPDATE movies SET poster_path = $1
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
)
.bind(url)
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await?;
}
Ok(())
}

View File

@@ -20,3 +20,6 @@ async-trait = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS goals (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year BIGINT NOT NULL,
target_count BIGINT NOT NULL,
goal_type TEXT NOT NULL DEFAULT 'movies',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, year)
);
CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
federate_goals BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS remote_goals (
ap_id TEXT PRIMARY KEY NOT NULL,
actor_url TEXT NOT NULL,
year BIGINT NOT NULL,
target_count BIGINT NOT NULL,
current_count BIGINT NOT NULL DEFAULT 0,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_remote_goals_actor ON remote_goals(actor_url);

View File

@@ -0,0 +1,8 @@
ALTER TABLE persons ADD COLUMN biography TEXT;
ALTER TABLE persons ADD COLUMN birthday TEXT;
ALTER TABLE persons ADD COLUMN deathday TEXT;
ALTER TABLE persons ADD COLUMN place_of_birth TEXT;
ALTER TABLE persons ADD COLUMN also_known_as TEXT;
ALTER TABLE persons ADD COLUMN homepage TEXT;
ALTER TABLE persons ADD COLUMN imdb_id TEXT;
ALTER TABLE persons ADD COLUMN enriched_at TIMESTAMPTZ;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS refresh_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);

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

@@ -115,6 +115,29 @@ impl LocalApContentQuery for PostgresApContentQuery {
.collect()
}
async fn get_local_reviews_for_movie(
&self,
movie_id: &MovieId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let mid = movie_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&mid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
@@ -205,4 +228,31 @@ impl LocalApContentQuery for PostgresApContentQuery {
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(domain::models::Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = crate::goals::row_to_goal(&r)?;
let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
}

View File

@@ -0,0 +1,540 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
collections::{PageParams, Paginated},
},
ports::DiaryRepository,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
use sqlx::PgPool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
pub struct PostgresDiaryRepository {
pool: PgPool,
}
impl PostgresDiaryRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
match movie_id {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err),
Some(id) => {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
}
}
async fn fetch_all_diary_rows(
&self,
sort: &SortDirection,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
LIMIT $1 OFFSET $2",
order
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1
ORDER BY {}
LIMIT $2 OFFSET $3",
order
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(movie_id)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn count_user_diary_entries(
&self,
user_id: &str,
search: Option<&str>,
) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
.to_string()
} else {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1"
.to_string()
};
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.fetch_one(&self.pool).await.map_err(Self::map_err)
}
async fn fetch_user_diary_rows(
&self,
user_id: &str,
sort: &SortDirection,
search: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
};
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
let mut p: i32 = 1; // $1 is user_id
let search_clause = if has_search {
p += 1;
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
} else {
String::new()
};
p += 1;
let limit_param = format!("${}", p);
p += 1;
let offset_param = format!("${}", p);
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1{}
ORDER BY {}
LIMIT {} OFFSET {}",
search_clause, order_clause, limit_param, offset_param
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
}
#[async_trait]
impl DiaryRepository for PostgresDiaryRepository {
async fn query_diary(
&self,
filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64;
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
(None, None) => tokio::try_join!(
self.count_diary_entries(None),
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
)?,
(Some(id), None) => {
let id_str = id.value().to_string();
tokio::try_join!(
self.count_diary_entries(Some(id_str.as_str())),
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
)?
}
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
)?
}
(Some(_), Some(_)) => {
return Err(DomainError::ValidationError(
"Combined movie_id + user_id filter not supported".into(),
));
}
};
let items = rows
.into_iter()
.map(DiaryRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: filter.page.limit,
offset: filter.page.offset,
})
}
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None)
.await
}
async fn query_activity_feed_filtered(
&self,
page: &PageParams,
sort_by: &domain::ports::FeedSortBy,
search: Option<&str>,
following: Option<&domain::ports::FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
use domain::ports::FeedSortBy;
let limit = page.limit as i64;
let offset = page.offset as i64;
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
// Dynamic param counter
let mut p: i32 = 0;
let mut next_param = || {
p += 1;
format!("${}", p)
};
let mut where_parts = vec!["1=1".to_string()];
if has_search {
let pn = next_param();
where_parts.push(format!("m.title ILIKE '%' || {} || '%'", pn));
}
if let Some(f) = following {
let local_params: Vec<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
let remote_params: Vec<String> =
f.remote_actor_urls.iter().map(|_| next_param()).collect();
let local_in = if local_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
local_params.join(", ")
};
let remote_in = if remote_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
remote_params.join(", ")
};
where_parts.push(format!(
"(r.user_id IN ({}) OR r.remote_actor_url IN ({}))",
local_in, remote_in
));
}
let limit_param = next_param();
let offset_param = next_param();
let order_clause = match sort_by {
FeedSortBy::Date => "r.watched_at DESC",
FeedSortBy::DateAsc => "r.watched_at ASC",
FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC",
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
let where_clause = where_parts.join(" AND ");
// Reset counter for count query (reuse same where_clause string but re-bind)
// We need a separate counter for count SQL — but since where_clause is already built
// with the right $N references, both queries share it.
let count_sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE {}",
where_clause
);
let select_sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
COALESCE(u.email, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE {}
ORDER BY {}
LIMIT {} OFFSET {}",
where_clause, order_clause, limit_param, offset_param
);
// Bind helper closure — binds search + following params in order
macro_rules! bind_filter_params {
($q:expr) => {{
let mut q = $q;
if has_search {
q = q.bind(search.unwrap());
}
if let Some(f) = following {
for uid in &f.local_user_ids {
q = q.bind(uid.to_string());
}
for url in &f.remote_actor_urls {
q = q.bind(url.as_str());
}
}
q
}};
}
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
let id_str = movie_id.value().to_string();
let movie = sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
}
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
fn stream_user_history(
&self,
user_id: UserId,
) -> BoxStream<'static, Result<DiaryEntry, DomainError>> {
let pool = self.pool.clone();
let uid = user_id.value().to_string();
Box::pin(async_stream::stream! {
let mut rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch(&pool);
while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row {
Ok(r) => r.into_domain(),
Err(e) => Err(Self::map_err(e)),
};
}
})
}
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS FLOAT)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = $1",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = $1
ORDER BY r.watched_at DESC
LIMIT $2 OFFSET $3",
)
.bind(&id_str)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
}

View File

@@ -0,0 +1,195 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{Goal, GoalType},
ports::GoalRepository,
value_objects::{GoalId, UserId},
};
use sqlx::{PgPool, Row};
use crate::models::{datetime_to_str, parse_datetime, parse_uuid};
pub struct PostgresGoalRepository {
pool: PgPool,
}
impl PostgresGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl GoalRepository for PostgresGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
let year = goal.year() as i64;
let target = goal.target_count() as i64;
let goal_type = goal.goal_type().as_str();
let created_at = datetime_to_str(goal.created_at());
sqlx::query(
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
VALUES ($1, $2, $3, $4, $5, $6::timestamptz)",
)
.bind(&id)
.bind(&user_id)
.bind(year)
.bind(target)
.bind(goal_type)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let target = goal.target_count() as i64;
let result = sqlx::query("UPDATE goals SET target_count = $1 WHERE id = $2")
.bind(target)
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
let gid = id.value().to_string();
let uid = user_id.value().to_string();
let result = sqlx::query("DELETE FROM goals WHERE id = $1 AND user_id = $2")
.bind(&gid)
.bind(&uid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn find_by_user_and_year(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<Goal>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
row.map(|r| row_to_goal(&r)).transpose()
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY year DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(
pool: &PgPool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.unwrap_or(0);
Ok(count as u32)
}
pub(crate) fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}

View File

@@ -5,6 +5,7 @@ use domain::{
models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
},
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId},
@@ -266,7 +267,7 @@ impl PostgresImportSessionRepository {
Ok(js.into_iter().map(annotated_from_json).collect())
})
.transpose()?;
Ok(ImportSession {
Ok(ImportSession::from_persistence(PersistedImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -281,7 +282,7 @@ impl PostgresImportSessionRepository {
row_results,
created_at,
expires_at,
})
}))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ use domain::{
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId,
ReviewId, UserId, Username,
},
};
use uuid::Uuid;
@@ -237,6 +237,8 @@ impl MovieStatsRow {
pub(crate) struct UserSummaryRow {
pub id: String,
pub email: String,
pub username: String,
pub display_name: Option<String>,
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub avatar_path: Option<String>,
@@ -247,6 +249,8 @@ impl UserSummaryRow {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,
Username::new(self.username)?,
self.display_name,
self.total_movies,
self.avg_rating,
self.avatar_path,

View File

@@ -0,0 +1,242 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::MovieRepository,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::PgPool;
use crate::models::{MovieRow, MovieSummaryRow};
pub struct PostgresMovieRepository {
pool: PgPool,
}
impl PostgresMovieRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl MovieRepository for PostgresMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
let title = movie.title().value();
let release_year = movie.release_year().value() as i64;
let director = movie.director();
let poster_path = movie.poster_path().map(|p| p.value().to_string());
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET
external_metadata_id = excluded.external_metadata_id,
title = excluded.title,
release_year = excluded.release_year,
director = excluded.director,
poster_path = excluded.poster_path",
)
.bind(&id)
.bind(&external_metadata_id)
.bind(title)
.bind(release_year)
.bind(director)
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
let id = movie_id.value().to_string();
sqlx::query("DELETE FROM movies WHERE id = $1")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn existing_external_ids(
&self,
ids: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
if ids.is_empty() {
return Ok(Default::default());
}
let vals: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT external_metadata_id FROM movies WHERE external_metadata_id = ANY($1)",
)
.bind(&vals)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
async fn existing_title_year_pairs(
&self,
pairs: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
if pairs.is_empty() {
return Ok(Default::default());
}
let titles: Vec<&str> = pairs.iter().map(|(t, _)| t.value()).collect();
let years: Vec<i64> = pairs.iter().map(|(_, y)| y.value() as i64).collect();
use sqlx::Row;
let rows = sqlx::query(
"SELECT DISTINCT m.title, m.release_year FROM movies m \
INNER JOIN unnest($1::text[], $2::bigint[]) AS p(title, release_year) \
ON m.title = p.title AND m.release_year = p.release_year",
)
.bind(&titles)
.bind(&years)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows
.into_iter()
.map(|r| {
let t: String = r.get("title");
let y: i64 = r.get("release_year");
(t, y as u16)
})
.collect())
}
async fn list_movies(
&self,
page: &PageParams,
filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT $4 OFFSET $5",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
}

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
models::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
PersonId,
},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
@@ -57,71 +60,115 @@ impl PersonCommand for PostgresPersonAdapter {
}
Ok(())
}
async fn backfill_from_credits_batch(
&self,
batch_size: u32,
) -> Result<(u64, bool), DomainError> {
#[derive(sqlx::FromRow)]
struct MissingPerson {
tmdb_person_id: i64,
name: String,
department: Option<String>,
profile_path: Option<String>,
}
let rows = sqlx::query_as::<_, MissingPerson>(
"SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path
FROM movie_cast mc
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
GROUP BY mc.tmdb_person_id, mc.name, mc.profile_path
UNION ALL
SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path
FROM movie_crew mc
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id)
GROUP BY mc.tmdb_person_id, mc.name, mc.department, mc.profile_path
LIMIT $1",
)
.bind(batch_size as i64)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64;
for row in &rows {
let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id));
let pid = PersonId::from_external(&ext);
sqlx::query(
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(tmdb_person_id) DO NOTHING",
)
.bind(pid.value().to_string())
.bind(ext.value())
.bind(row.tmdb_person_id)
.bind(&row.name)
.bind(&row.department)
.bind(&row.profile_path)
.execute(&self.pool)
.await
.map_err(map_err)?;
count += 1;
}
Ok((count, has_more))
}
async fn update_enrichment(
&self,
id: &PersonId,
data: &PersonEnrichmentData,
) -> Result<(), DomainError> {
let also_known_as_json =
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
let now = chrono::Utc::now();
sqlx::query(
"UPDATE persons SET biography = $1, birthday = $2, deathday = $3, place_of_birth = $4, also_known_as = $5, homepage = $6, imdb_id = $7, enriched_at = $8 WHERE id = $9",
)
.bind(&data.biography)
.bind(data.birthday.map(|d| d.to_string()))
.bind(data.deathday.map(|d| d.to_string()))
.bind(&data.place_of_birth)
.bind(&also_known_as_json)
.bind(&data.homepage)
.bind(&data.imdb_id)
.bind(now)
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
}
#[async_trait]
impl PersonQuery for PostgresPersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = $1",
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
Ok(row.map(PersonRow::into_person))
}
async fn get_by_external_id(
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = $1",
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = $1",
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
Ok(row.map(PersonRow::into_person))
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
@@ -206,6 +253,19 @@ impl PersonQuery for PostgresPersonAdapter {
Ok(PersonCredits { person, cast, crew })
}
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
let rows = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT $1 OFFSET $2",
)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect())
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT id FROM persons
@@ -214,7 +274,8 @@ impl PersonQuery for PostgresPersonAdapter {
)
AND NOT EXISTS (
SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id
)",
)
LIMIT 500",
)
.fetch_all(&self.pool)
.await
@@ -226,3 +287,57 @@ impl PersonQuery for PostgresPersonAdapter {
.collect())
}
}
// ── Row types ────────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct PersonRow {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<String>,
deathday: Option<String>,
place_of_birth: Option<String>,
also_known_as: Option<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<String>,
}
impl PersonRow {
fn into_person(self) -> Person {
let ext = ExternalPersonId::new(self.external_id);
let also_known_as = self
.also_known_as
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default();
let birthday = self
.birthday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let deathday = self
.deathday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let enriched_at = self
.enriched_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
ext,
self.name,
self.known_for_department,
self.profile_path,
self.biography,
birthday,
deathday,
self.place_of_birth,
also_known_as,
self.homepage,
self.imdb_id,
enriched_at,
)
}
}

View File

@@ -0,0 +1,112 @@
use async_trait::async_trait;
use chrono::DateTime;
use domain::{
errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository,
value_objects::UserId,
};
use sqlx::PgPool;
pub struct PostgresRefreshSessionAdapter {
pool: PgPool,
}
impl PostgresRefreshSessionAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(session.id.to_string())
.bind(session.user_id.value().to_string())
.bind(&session.token)
.bind(session.expires_at)
.bind(session.created_at)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
let row = sqlx::query_as::<_, RefreshSessionRow>(
"SELECT id, user_id, token,
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS expires_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at
FROM refresh_sessions WHERE token = $1",
)
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
row.map(RefreshSessionRow::into_domain).transpose()
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE token = $1")
.bind(token)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = $1")
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
}
#[derive(sqlx::FromRow)]
struct RefreshSessionRow {
id: String,
user_id: String,
token: String,
expires_at: String,
created_at: String,
}
impl RefreshSessionRow {
fn into_domain(self) -> Result<RefreshSession, DomainError> {
let id = uuid::Uuid::parse_str(&self.id)
.map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?;
let user_id = uuid::Uuid::parse_str(&self.user_id)
.map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?;
let expires_at = DateTime::parse_from_rfc3339(&self.expires_at)
.map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))?
.with_timezone(&chrono::Utc);
let created_at = DateTime::parse_from_rfc3339(&self.created_at)
.map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))?
.with_timezone(&chrono::Utc);
Ok(RefreshSession {
id,
user_id: UserId::from_uuid(user_id),
token: self.token,
expires_at,
created_at,
})
}
}

View File

@@ -0,0 +1,119 @@
use async_trait::async_trait;
use chrono::TimeZone;
use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository};
use sqlx::{PgPool, Row};
pub struct PostgresRemoteGoalRepository {
pool: PgPool,
}
impl PostgresRemoteGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl RemoteGoalRepository for PostgresRemoteGoalRepository {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> {
let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO remote_goals \
(ap_id, actor_url, year, target_count, current_count, received_at) \
VALUES ($1, $2, $3, $4, $5, $6::timestamptz) \
ON CONFLICT (ap_id) DO UPDATE SET \
target_count = $4, current_count = $5",
)
.bind(&entry.ap_id)
.bind(&entry.actor_url)
.bind(entry.year as i64)
.bind(entry.target_count as i64)
.bind(entry.current_count as i64)
.bind(&received)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update_by_ap_id(
&self,
ap_id: &str,
target: u32,
current: u32,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE remote_goals SET target_count = $1, current_count = $2 WHERE ap_id = $3",
)
.bind(target as i64)
.bind(current as i64)
.bind(ap_id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM remote_goals WHERE ap_id = $1 AND actor_url = $2")
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM remote_goals WHERE actor_url = $1")
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError> {
let rows = sqlx::query(
"SELECT ap_id, actor_url, year, target_count, current_count, \
to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS received_at \
FROM remote_goals WHERE actor_url = $1 ORDER BY year DESC",
)
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter()
.map(|r| {
let year: i64 = r.try_get("year").unwrap_or(0);
let target: i64 = r.try_get("target_count").unwrap_or(0);
let current: i64 = r.try_get("current_count").unwrap_or(0);
let received_str: String = r.try_get("received_at").unwrap_or_default();
let received_at =
chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S")
.map(|ndt| chrono::Utc.from_utc_datetime(&ndt))
.unwrap_or_else(|_| chrono::Utc::now());
Ok(RemoteGoalEntry {
ap_id: r.try_get("ap_id").unwrap_or_default(),
actor_url: r.try_get("actor_url").unwrap_or_default(),
year: year as u16,
target_count: target as u32,
current_count: current as u32,
received_at,
})
})
.collect()
}
}

View File

@@ -0,0 +1,112 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource},
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
};
use sqlx::PgPool;
use crate::models::{ReviewRow, datetime_to_str};
pub struct PostgresReviewRepository {
pool: PgPool,
}
impl PostgresReviewRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl ReviewRepository for PostgresReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let created_at = datetime_to_str(review.created_at());
let remote_actor_url = match review.source() {
ReviewSource::Local => None,
ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
};
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)",
)
.bind(&id)
.bind(&movie_id)
.bind(&user_id)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let uid = user_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect()
}
}

View File

@@ -0,0 +1,153 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
ports::StatsRepository,
value_objects::UserId,
};
use sqlx::PgPool;
use crate::format_year_month;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
pub struct PostgresStatsRepository {
pool: PgPool,
}
impl PostgresStatsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>(
r#"SELECT COUNT(DISTINCT movie_id) AS total,
AVG(rating::float) AS avg_rating
FROM reviews WHERE user_id = $1"#,
)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_favorite_director(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT m.director
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_most_active_month(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month
FROM reviews
WHERE user_id = $1
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
}
}
#[async_trait]
impl StatsRepository for PostgresStatsRepository {
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
let uid = user_id.value().to_string();
let (totals, fav_director, most_active) = tokio::try_join!(
self.fetch_user_totals(&uid),
self.fetch_user_favorite_director(&uid),
self.fetch_user_most_active_month(&uid)
)?;
let most_active_month = most_active.map(|ym| format_year_month(&ym));
Ok(UserStats {
total_movies: totals.total,
avg_rating: totals.avg_rating,
favorite_director: fav_director,
most_active_month,
})
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!(
sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
AVG(rating::float) AS avg_rating,
COUNT(*) AS count
FROM reviews
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director AS director, COUNT(*) AS count
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 5"
)
.bind(&uid)
.fetch_all(&self.pool)
)
.map_err(Self::map_err)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
let monthly_ratings = rating_rows
.into_iter()
.map(|r| MonthlyRating {
month_label: format_year_month(&r.month),
year_month: r.month,
avg_rating: r.avg_rating,
count: r.count,
})
.collect();
let top_directors = director_rows
.into_iter()
.map(|d| DirectorStat {
director: d.director,
count: d.count,
})
.collect();
Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
})
}
}

View File

@@ -0,0 +1,104 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::UserSettings,
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
value_objects::UserId,
};
use sqlx::{PgPool, Row};
pub struct PostgresUserSettingsRepository {
pool: PgPool,
}
impl PostgresUserSettingsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl 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 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(UserSettings::from_persistence(
user_id.clone(),
goals,
reviews,
watchlist,
))
}
None => Ok(UserSettings::new(user_id.clone())),
}
}
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string();
sqlx::query(
"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(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

@@ -186,13 +186,13 @@ impl UserRepository for PostgresUserRepository {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
sqlx::query_as::<_, UserSummaryRow>(
r#"SELECT u.id, u.email,
r#"SELECT u.id, u.email, u.username, u.display_name,
COUNT(DISTINCT r.movie_id) AS total_movies,
AVG(r.rating::float) AS avg_rating,
u.avatar_path
FROM users u
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
GROUP BY u.id, u.email, u.avatar_path
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
ORDER BY u.email ASC"#,
)
.fetch_all(&self.pool)

View File

@@ -333,9 +333,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
let cast = cast_map.get(&movie_id_str).cloned().unwrap_or_default();
let cast_names: Vec<(String, u32)> = cast
let cast_names: Vec<(String, u32, i64)> = cast
.iter()
.map(|c| (c.name.clone(), c.billing_order))
.map(|c| (c.name.clone(), c.billing_order, c.tmdb_person_id))
.collect();
let cast_profile_paths: Vec<Option<String>> =
cast.iter().map(|c| c.profile_path.clone()).collect();
@@ -367,6 +367,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
struct CastEntry {
name: String,
billing_order: u32,
tmdb_person_id: i64,
profile_path: Option<String>,
}
@@ -417,7 +418,7 @@ async fn fetch_cast_pg(
movie_ids: &[String],
) -> Result<HashMap<String, Vec<CastEntry>>, DomainError> {
let rows = sqlx::query(
"SELECT movie_id, name, billing_order, profile_path \
"SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \
FROM movie_cast \
WHERE movie_id = ANY($1) AND billing_order <= 3 \
ORDER BY billing_order ASC",
@@ -432,10 +433,12 @@ async fn fetch_cast_pg(
let mid: String = row.try_get("movie_id").map_err(map_err)?;
let name: String = row.try_get("name").map_err(map_err)?;
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?;
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?;
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?;
map.entry(mid).or_default().push(CastEntry {
name,
billing_order: billing_order as u32,
tmdb_person_id,
profile_path,
});
}

View File

@@ -870,6 +870,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
rating: u8,
comment: Option<&str>,
watched_at: chrono::NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()> {
let watched_at_str = datetime_to_str(&watched_at);
sqlx::query(
@@ -883,6 +884,17 @@ impl RemoteReviewRepository for SqliteFederationRepository {
.bind(actor_url)
.execute(&self.pool)
.await?;
if let Some(url) = poster_url {
sqlx::query(
"UPDATE movies SET poster_path = ?
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
)
.bind(url)
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await?;
}
Ok(())
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM reviews",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_profile_fields WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086"
}

View File

@@ -1,68 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e"
}

View File

@@ -1,98 +0,0 @@
{
"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"
}

Some files were not shown because too many files have changed in this diff Show More