5.6 KiB
5.6 KiB
User Film Import, Navbar & Notifications
Date: 2026-03-29 Status: Approved
Overview
Add a navbar for authenticated users with a user dropdown (import films, logout) and a notifications dropdown (with unread count badge and page title update). Users can import their Letterboxd CSV to sync films and actors via async processing, with files stored on a remote SeaweedFS instance.
Data Model
New Entities
UserMovie — join table User <-> Movie
id(int, PK)user(ManyToOne -> User)movie(ManyToOne -> Movie)- Unique constraint on
(user, movie)
Import — tracks a CSV import job
id(int, PK)user(ManyToOne -> User)filePath(string) — path on SeaweedFSstatus(string, enum:pending,processing,completed,failed)totalBatches(int, default 0)processedBatches(int, default 0)totalFilms(int, default 0)failedFilms(int, default 0)createdAt(datetime)completedAt(datetime, nullable)
Notification — user notifications
id(int, PK)user(ManyToOne -> User)message(string)read(bool, default false)createdAt(datetime)
Modified Entities
User — add OneToMany relations to UserMovie, Import, Notification.
File Storage (SeaweedFS)
- Library:
league/flysystem-aws-s3-v3with Flysystem S3 adapter - Endpoint:
s3.lclr.dev - Bucket:
ltbxd-actorle - Credentials: Symfony Secrets (
S3_ACCESS_KEY,S3_SECRET_KEY) - File path pattern:
imports/{userId}/{importId}.csv - No local/Docker SeaweedFS — always the remote instance, including in dev.
Async Processing (Messenger)
Messages
ProcessImportMessage(importId)
- Dispatched by the upload controller.
- Single entry point for import processing.
ImportFilmsBatchMessage(importId, offset, limit)
- Dispatched by
ProcessImportMessageHandler. - One per batch of 50 films.
Handler: ProcessImportMessageHandler
- Fetch
Importentity - Download CSV from SeaweedFS via Flysystem
- Parse the file: save to a temp file, then use
LtbxdGateway->parseFile()(which expects a local path), then delete the temp file - Calculate
totalFilms,totalBatches(batches of 50), update the Import - Dispatch N
ImportFilmsBatchMessage(importId, offset, limit)messages - Set Import status to
processing
Handler: ImportFilmsBatchMessageHandler
- Fetch Import, download CSV from SeaweedFS, read slice [offset, offset+limit]
- For each film in the slice:
- Look up by
ltbxdRefin DB; if missing, callTMDBGateway->searchMovie()and create Movie - Fetch actors via TMDB, create missing Actor/MovieRole entries
- Create
UserMovielink if it doesn't exist
- Look up by
- Atomically increment
processedBatches(UPDATE ... SET processed_batches = processed_batches + 1) to avoid race conditions with multiple workers - If
processedBatches == totalBatches: set Import tocompleted, setcompletedAt, create Notification ("Import terminé : X/Y films importés") - On per-film error: log and continue, increment
failedFilms
Error Handling
ProcessImportMessageHandlerfailure (SeaweedFS down, invalid CSV): set Import tofailed, create error Notification.ImportFilmsBatchMessageHandlerper-film failure: log, skip film, incrementfailedFilms, continue.- Messenger retry: default config (3 retries with backoff), then failure transport.
Extracted Services
The logic currently embedded in SyncFilmsCommand and SyncActorsCommand is extracted into reusable services:
FilmImporter— given a parsed CSV row, finds or creates a Movie entity via TMDB lookup.ActorSyncer— given a Movie, fetches cast from TMDB and creates missing Actor/MovieRole entries.
The existing commands are refactored to use these services.
API Endpoints
POST /api/imports (authenticated)
- Input: CSV file as multipart form data
- Validation:
.csvextension, max 5 MB - Action: Upload to SeaweedFS, create Import entity (status
pending), dispatchProcessImportMessage - Response:
201 { id, status: "pending" }
GET /api/notifications (authenticated)
- Response:
200 { unreadCount: N, notifications: [{ id, message, read, createdAt }] } - Sorted by
createdAtdesc, limited to 20 most recent
POST /api/notifications/read (authenticated)
- Action: Mark all notifications for the authenticated user as read
- Response:
204
Frontend
Navbar (Twig + Stimulus)
- Added in the main layout (
base.html.twigor partial), visible only when authenticated. - Right side: notification icon (bell) + user icon.
User Dropdown (dropdown_controller Stimulus)
- Click user icon -> toggle dropdown menu
- Entries: "Importer ses films", "Se deconnecter"
- Click outside -> close
Notifications Dropdown (notifications_controller Stimulus)
- Click bell icon -> dropdown listing recent notifications
- Polling every 30s on
GET /api/notificationsreturning notifications + unread count - On dropdown open: call
POST /api/notifications/readto mark as read - Badge (red, unread count) updates on each poll
document.titleupdates:(N) Actorleif unread > 0,Actorleotherwise
Import Modal (import_modal_controller Stimulus)
- Click "Importer ses films" -> show modal (HTML in DOM, toggle
hidden) - File input (accept
.csv) - "Importer" button ->
POST /api/importsvia fetch (multipart) - On success: "Import lance !" message, close modal
- Client-side validation:
.csvextension only
Out of Scope
- Modifying game logic based on user's imported films (future: game config page)
- Mercure/WebSocket for real-time notifications (polling is sufficient)
- Docker SeaweedFS for local dev