Compare commits

...

20 Commits

Author SHA1 Message Date
thibaud-leclere
5fc6b4a53b feat: add Messenger worker Docker service
Some checks failed
Build and Push Docker Images / Build app image (push) Failing after 37s
Build and Push Docker Images / Build node image (push) Has been cancelled
Build and Push Docker Images / Build database image (push) Has been cancelled
Reuses the app image with messenger:consume command.
Restarts automatically, with 1h time limit and 256M memory limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:30:23 +02:00
thibaud-leclere
6edc122ff6 fix: address code review issues
- Rename `read` column to `is_read` (PostgreSQL reserved word)
- Wrap navbar + modal in parent div for Stimulus controller scope
- Set temporary filePath before first flush in ImportController
- Use RETURNING clause for atomic incrementProcessedBatches
- Return proper empty 204 response in NotificationController

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:27:57 +02:00
thibaud-leclere
b0024bbcf5 feat: add navbar, dropdown, modal and notification styles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:23:57 +02:00
thibaud-leclere
300699fa82 feat: add Stimulus import modal controller 2026-03-29 10:23:19 +02:00
thibaud-leclere
c9880baddb feat: add Stimulus notifications controller with polling 2026-03-29 10:22:58 +02:00
thibaud-leclere
1ea07a2438 feat: add Stimulus dropdown controller 2026-03-29 10:22:37 +02:00
thibaud-leclere
a348de01b0 feat: add navbar with user dropdown and import modal 2026-03-29 10:22:20 +02:00
thibaud-leclere
4f8eb5f3dc feat: add notification API endpoints 2026-03-29 10:21:30 +02:00
thibaud-leclere
2cfbe191cf feat: add POST /api/imports endpoint 2026-03-29 10:21:02 +02:00
thibaud-leclere
4955c5bde9 feat: add ImportFilmsBatchMessageHandler 2026-03-29 10:20:24 +02:00
thibaud-leclere
98be393e3c feat: add ProcessImportMessageHandler 2026-03-29 10:19:31 +02:00
thibaud-leclere
2d768e8b52 feat: add Messenger messages for import processing 2026-03-29 10:18:35 +02:00
thibaud-leclere
dedc41e237 refactor: extract ActorSyncer service from SyncActorsCommand 2026-03-29 10:18:00 +02:00
thibaud-leclere
bbbfb895af refactor: extract FilmImporter service from SyncFilmsCommands 2026-03-29 10:17:17 +02:00
thibaud-leclere
1bf8afd88e feat: add Notification entity 2026-03-29 10:16:29 +02:00
thibaud-leclere
7be4de6967 feat: add Import entity with batch tracking 2026-03-29 10:11:30 +02:00
thibaud-leclere
5f7ddcd3cc feat: add UserMovie join entity 2026-03-29 10:10:27 +02:00
thibaud-leclere
5d16d28c59 feat: install Flysystem S3 and configure SeaweedFS storage
Add league/flysystem-bundle and league/flysystem-aws-s3-v3 packages and
configure the default.storage adapter to use AWS S3Client pointed at the
SeaweedFS endpoint (s3.lclr.dev) with path-style endpoints and secret-based
credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:08:56 +02:00
thibaud-leclere
def97304a9 docs: add implementation plan for user import & notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:06:32 +02:00
thibaud-leclere
720e8e0cf9 docs: add design spec for user film import, navbar & notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:00:46 +02:00
36 changed files with 4840 additions and 144 deletions

View File

@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['menu', 'trigger'];
connect() {
this._closeOnClickOutside = this._closeOnClickOutside.bind(this);
document.addEventListener('click', this._closeOnClickOutside);
}
disconnect() {
document.removeEventListener('click', this._closeOnClickOutside);
}
toggle() {
this.menuTarget.hidden = !this.menuTarget.hidden;
}
_closeOnClickOutside(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.hidden = true;
}
}
}

View File

@@ -0,0 +1,59 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['overlay', 'fileInput', 'feedback', 'submitBtn'];
open() {
this.overlayTarget.hidden = false;
}
close() {
this.overlayTarget.hidden = true;
this.fileInputTarget.value = '';
this.feedbackTarget.hidden = true;
}
async submit() {
const file = this.fileInputTarget.files[0];
if (!file) {
this._showFeedback('Veuillez sélectionner un fichier.', true);
return;
}
if (!file.name.endsWith('.csv')) {
this._showFeedback('Seuls les fichiers CSV sont acceptés.', true);
return;
}
this.submitBtnTarget.disabled = true;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/imports', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const data = await response.json();
this._showFeedback(data.error || 'Une erreur est survenue.', true);
return;
}
this._showFeedback('Import lancé !', false);
setTimeout(() => this.close(), 1500);
} catch (e) {
this._showFeedback('Une erreur est survenue.', true);
} finally {
this.submitBtnTarget.disabled = false;
}
}
_showFeedback(message, isError) {
this.feedbackTarget.textContent = message;
this.feedbackTarget.className = isError ? 'modal-feedback error' : 'modal-feedback success';
this.feedbackTarget.hidden = false;
}
}

View File

@@ -0,0 +1,77 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['badge', 'list'];
connect() {
this._poll();
this._interval = setInterval(() => this._poll(), 30000);
}
disconnect() {
clearInterval(this._interval);
}
async _poll() {
try {
const response = await fetch('/api/notifications');
if (!response.ok) return;
const data = await response.json();
this._updateBadge(data.unreadCount);
this._updateTitle(data.unreadCount);
this._updateList(data.notifications);
} catch (e) {
// silently ignore polling errors
}
}
_updateBadge(count) {
if (count > 0) {
this.badgeTarget.textContent = count;
this.badgeTarget.hidden = false;
} else {
this.badgeTarget.hidden = true;
}
}
_updateTitle(count) {
const base = 'Actorle';
document.title = count > 0 ? `(${count}) ${base}` : base;
}
_updateList(notifications) {
if (notifications.length === 0) {
this.listTarget.innerHTML = '<p class="dropdown-empty">Aucune notification</p>';
return;
}
this.listTarget.innerHTML = notifications.map(n => `
<div class="notification-item ${n.read ? '' : 'notification-unread'}">
<p>${this._escapeHtml(n.message)}</p>
<time>${this._formatDate(n.createdAt)}</time>
</div>
`).join('');
}
async markRead() {
await fetch('/api/notifications/read', { method: 'POST' });
this._poll();
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
_formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
}

View File

@@ -125,3 +125,238 @@ body {
.auth-link a {
color: #2563eb;
}
/* Navbar */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 24px;
background: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 700;
font-size: 18px;
color: #1f2937;
text-decoration: none;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.navbar-item {
position: relative;
}
.navbar-icon {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
color: #4b5563;
position: relative;
display: flex;
align-items: center;
}
.navbar-icon:hover {
background: #f3f4f6;
}
/* Badge */
.badge {
position: absolute;
top: 2px;
right: 2px;
background: #dc2626;
color: white;
font-size: 11px;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* Dropdown */
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 200;
padding: 4px 0;
margin-top: 4px;
}
.dropdown-header {
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.dropdown-item {
display: block;
width: 100%;
padding: 8px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #1f2937;
text-decoration: none;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-empty {
padding: 12px 16px;
color: #9ca3af;
font-size: 13px;
margin: 0;
}
/* Notifications */
.notifications-list {
max-height: 300px;
overflow-y: auto;
}
.notification-item {
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item p {
margin: 0 0 2px;
font-size: 13px;
color: #1f2937;
}
.notification-item time {
font-size: 11px;
color: #9ca3af;
}
.notification-unread {
background: #eff6ff;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
line-height: 1;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 16px;
color: #4b5563;
font-size: 14px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
text-align: right;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-primary:disabled {
background: #93c5fd;
cursor: not-allowed;
}
.modal-feedback {
margin-top: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.modal-feedback.error {
background: #fef2f2;
color: #dc2626;
}
.modal-feedback.success {
background: #f0fdf4;
color: #16a34a;
}

View File

@@ -10,6 +10,8 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"league/flysystem-aws-s3-v3": "*",
"league/flysystem-bundle": "*",
"pentatrion/vite-bundle": "^8.2",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",

1062
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,4 +15,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\React\ReactBundle::class => ['all' => true],
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,19 @@
flysystem:
storages:
default.storage:
adapter: 'aws'
options:
client: 's3_client'
bucket: 'ltbxd-actorle'
services:
s3_client:
class: Aws\S3\S3Client
arguments:
- endpoint: 'https://s3.lclr.dev'
credentials:
key: '%env(secret:S3_ACCESS_KEY)%'
secret: '%env(secret:S3_SECRET_KEY)%'
region: 'us-east-1'
version: 'latest'
use_path_style_endpoint: true

View File

@@ -25,5 +25,5 @@ framework:
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async
App\Message\ProcessImportMessage: async
App\Message\ImportFilmsBatchMessage: async

View File

@@ -1487,6 +1487,22 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* preload_attributes?: list<scalar|null|Param>,
* }>,
* }
* @psalm-type FlysystemConfig = array{
* storages?: array<string, array{ // Default: []
* adapter: scalar|null|Param,
* options?: list<mixed>,
* visibility?: scalar|null|Param, // Default: null
* directory_visibility?: scalar|null|Param, // Default: null
* retain_visibility?: bool|null|Param, // Default: null
* case_sensitive?: bool|Param, // Default: true
* disable_asserts?: bool|Param, // Default: false
* public_url?: list<scalar|null|Param>,
* path_normalizer?: scalar|null|Param, // Default: null
* public_url_generator?: scalar|null|Param, // Default: null
* temporary_url_generator?: scalar|null|Param, // Default: null
* read_only?: bool|Param, // Default: false
* }>,
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1502,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* flysystem?: FlysystemConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1520,6 +1537,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* maker?: MakerConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* flysystem?: FlysystemConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1536,6 +1554,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* flysystem?: FlysystemConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1553,6 +1572,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* flysystem?: FlysystemConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -18,6 +18,14 @@ services:
database:
condition: service_healthy
messenger:
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
command: ["php", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M"]
restart: unless-stopped
depends_on:
database:
condition: service_healthy
database:
build:
context: docker/database

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
# 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 SeaweedFS
- `status` (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-v3` with 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`
1. Fetch `Import` entity
2. Download CSV from SeaweedFS via Flysystem
3. Parse the file: save to a temp file, then use `LtbxdGateway->parseFile()` (which expects a local path), then delete the temp file
4. Calculate `totalFilms`, `totalBatches` (batches of 50), update the Import
5. Dispatch N `ImportFilmsBatchMessage(importId, offset, limit)` messages
6. Set Import status to `processing`
### Handler: `ImportFilmsBatchMessageHandler`
1. Fetch Import, download CSV from SeaweedFS, read slice [offset, offset+limit]
2. For each film in the slice:
- Look up by `ltbxdRef` in DB; if missing, call `TMDBGateway->searchMovie()` and create Movie
- Fetch actors via TMDB, create missing Actor/MovieRole entries
- Create `UserMovie` link if it doesn't exist
3. Atomically increment `processedBatches` (`UPDATE ... SET processed_batches = processed_batches + 1`) to avoid race conditions with multiple workers
4. If `processedBatches == totalBatches`: set Import to `completed`, set `completedAt`, create Notification ("Import terminé : X/Y films importés")
5. On per-film error: log and continue, increment `failedFilms`
### Error Handling
- `ProcessImportMessageHandler` failure (SeaweedFS down, invalid CSV): set Import to `failed`, create error Notification.
- `ImportFilmsBatchMessageHandler` per-film failure: log, skip film, increment `failedFilms`, 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:** `.csv` extension, max 5 MB
- **Action:** Upload to SeaweedFS, create Import entity (status `pending`), dispatch `ProcessImportMessage`
- **Response:** `201 { id, status: "pending" }`
### `GET /api/notifications` (authenticated)
- **Response:** `200 { unreadCount: N, notifications: [{ id, message, read, createdAt }] }`
- Sorted by `createdAt` desc, 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.twig` or 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/notifications` returning notifications + unread count
- On dropdown open: call `POST /api/notifications/read` to mark as read
- Badge (red, unread count) updates on each poll
- `document.title` updates: `(N) Actorle` if unread > 0, `Actorle` otherwise
### 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/imports` via fetch (multipart)
- On success: "Import lance !" message, close modal
- Client-side validation: `.csv` extension 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

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260329000001 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE user_movie (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, movie_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_A6B68B33A76ED395 ON user_movie (user_id)');
$this->addSql('CREATE INDEX IDX_A6B68B338F93B6FC ON user_movie (movie_id)');
$this->addSql('CREATE UNIQUE INDEX user_movie_unique ON user_movie (user_id, movie_id)');
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B33A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B338F93B6FC FOREIGN KEY (movie_id) REFERENCES movie (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B33A76ED395');
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B338F93B6FC');
$this->addSql('DROP TABLE user_movie');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260329000002 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE import (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, file_path VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, total_batches INT NOT NULL, processed_batches INT NOT NULL, total_films INT NOT NULL, failed_films INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_9D4ECE1DA76ED395 ON import (user_id)');
$this->addSql('COMMENT ON COLUMN import.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE import ADD CONSTRAINT FK_9D4ECE1DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE import DROP CONSTRAINT FK_9D4ECE1DA76ED395');
$this->addSql('DROP TABLE import');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260329000003 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE notification (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, message VARCHAR(255) NOT NULL, is_read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_BF5476CAA76ED395 ON notification (user_id)');
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');
$this->addSql('DROP TABLE notification');
}
}

View File

@@ -2,11 +2,9 @@
namespace App\Command;
use App\Entity\Actor;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use App\Service\ActorSyncer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -16,7 +14,7 @@ use Symfony\Component\Console\Output\OutputInterface;
readonly class SyncActorsCommand
{
public function __construct(
private TMDBGateway $TMDBGateway,
private ActorSyncer $actorSyncer,
private EntityManagerInterface $em,
) {}
@@ -24,39 +22,13 @@ readonly class SyncActorsCommand
{
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
try {
$creditsContext = $this->TMDBGateway->getMovieCredits($film->getTmdbId());
$output->writeln('Syncing cast for '.$film->getTitle());
$this->actorSyncer->syncActorsForMovie($film);
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
continue;
}
if (!empty($creditsContext->cast)) {
$output->writeln('Syncing cast for '.$film->getTitle());
}
foreach ($creditsContext->cast as $actorModel) {
// Get existing or create new
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
if (!$actor instanceof Actor) {
$output->writeln('* New actor found: '.$actorModel->name);
$actor = new Actor()
->setPopularity($actorModel->popularity)
->setName($actorModel->name)
->setTmdbId($actorModel->id)
;
$this->em->persist($actor);
}
// Get or create the role
if (0 < $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $film])) {
$actor->addMovieRole(new MovieRole()
->setMovie($film)
->setCharacter($actorModel->character)
);
}
}
$this->em->flush();
}

View File

@@ -2,10 +2,9 @@
namespace App\Command;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Gateway\TMDBGateway;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -16,7 +15,7 @@ readonly class SyncFilmsCommands
{
public function __construct(
private LtbxdGateway $ltbxdGateway,
private TMDBGateway $TMDBGateway,
private FilmImporter $filmImporter,
private EntityManagerInterface $em,
) {}
@@ -32,31 +31,17 @@ readonly class SyncFilmsCommands
$i = 0;
foreach ($ltbxdMovies as $ltbxdMovie) {
// If the movie already exists, skip
if (0 < $this->em->getRepository(Movie::class)->count(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()])) {
continue;
}
// Search movie on TMDB
try {
$film = $this->TMDBGateway->searchMovie($ltbxdMovie->getName());
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
if ($movie) {
$output->writeln('* Found '.$ltbxdMovie->getName());
}
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
if ($film) {
$output->writeln('* Found '.$ltbxdMovie->getName());
$filmEntity = new Movie()
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
->setTitle($ltbxdMovie->getName())
->setTmdbId($film->getId())
;
$this->em->persist($filmEntity);
}
++$i;
if (0 === $i % 50) {
$this->em->flush();

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Import;
use App\Entity\User;
use App\Message\ProcessImportMessage;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ImportController extends AbstractController
{
#[Route('/api/imports', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function create(
Request $request,
FilesystemOperator $defaultStorage,
EntityManagerInterface $em,
MessageBusInterface $bus,
): JsonResponse {
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file provided.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ('csv' !== $file->getClientOriginalExtension()) {
return $this->json(['error' => 'Only CSV files are accepted.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($file->getSize() > 5 * 1024 * 1024) {
return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
$import = new Import();
$import->setUser($user);
$import->setFilePath('pending');
$em->persist($import);
$em->flush();
$filePath = sprintf('imports/%d/%d.csv', $user->getId(), $import->getId());
$defaultStorage->write($filePath, file_get_contents($file->getPathname()));
$import->setFilePath($filePath);
$em->flush();
$bus->dispatch(new ProcessImportMessage($import->getId()));
return $this->json([
'id' => $import->getId(),
'status' => $import->getStatus(),
], Response::HTTP_CREATED);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationController extends AbstractController
{
#[Route('/api/notifications', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function index(NotificationRepository $notificationRepository): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$notifications = $notificationRepository->findRecentForUser($user);
$unreadCount = $notificationRepository->countUnreadForUser($user);
return $this->json([
'unreadCount' => $unreadCount,
'notifications' => array_map(fn ($n) => [
'id' => $n->getId(),
'message' => $n->getMessage(),
'read' => $n->isRead(),
'createdAt' => $n->getCreatedAt()->format('c'),
], $notifications),
]);
}
#[Route('/api/notifications/read', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function markRead(NotificationRepository $notificationRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$notificationRepository->markAllReadForUser($user);
return new Response('', Response::HTTP_NO_CONTENT);
}
}

153
src/Entity/Import.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ImportRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ImportRepository::class)]
class Import
{
public const string STATUS_PENDING = 'pending';
public const string STATUS_PROCESSING = 'processing';
public const string STATUS_COMPLETED = 'completed';
public const string STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $filePath = null;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column]
private int $totalBatches = 0;
#[ORM\Column]
private int $processedBatches = 0;
#[ORM\Column]
private int $totalFilms = 0;
#[ORM\Column]
private int $failedFilms = 0;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getFilePath(): ?string
{
return $this->filePath;
}
public function setFilePath(string $filePath): static
{
$this->filePath = $filePath;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getTotalBatches(): int
{
return $this->totalBatches;
}
public function setTotalBatches(int $totalBatches): static
{
$this->totalBatches = $totalBatches;
return $this;
}
public function getProcessedBatches(): int
{
return $this->processedBatches;
}
public function setProcessedBatches(int $processedBatches): static
{
$this->processedBatches = $processedBatches;
return $this;
}
public function getTotalFilms(): int
{
return $this->totalFilms;
}
public function setTotalFilms(int $totalFilms): static
{
$this->totalFilms = $totalFilms;
return $this;
}
public function getFailedFilms(): int
{
return $this->failedFilms;
}
public function setFailedFilms(int $failedFilms): static
{
$this->failedFilms = $failedFilms;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): static
{
$this->completedAt = $completedAt;
return $this;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NotificationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $message = null;
#[ORM\Column(name: 'is_read')]
private bool $read = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function isRead(): bool
{
return $this->read;
}
public function setRead(bool $read): static
{
$this->read = $read;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

55
src/Entity/UserMovie.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserMovieRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserMovieRepository::class)]
#[ORM\UniqueConstraint(name: 'user_movie_unique', columns: ['user_id', 'movie_id'])]
class UserMovie
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(targetEntity: Movie::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Movie $movie = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getMovie(): ?Movie
{
return $this->movie;
}
public function setMovie(?Movie $movie): static
{
$this->movie = $movie;
return $this;
}
}

View File

@@ -20,13 +20,13 @@ readonly class LtbxdGateway
* @return LtbxdMovie[]
* @throws GatewayException
*/
public function parseFile(): array
public function parseFileFromPath(string $path): array
{
if (!file_exists($this->fileDir)) {
throw new GatewayException(sprintf('Could not find file %s', $this->fileDir));
if (!file_exists($path)) {
throw new GatewayException(sprintf('Could not find file %s', $path));
}
$fileContent = file_get_contents($this->fileDir);
$fileContent = file_get_contents($path);
try {
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
@@ -34,4 +34,13 @@ readonly class LtbxdGateway
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
}
}
/**
* @return LtbxdMovie[]
* @throws GatewayException
*/
public function parseFile(): array
{
return $this->parseFileFromPath($this->fileDir);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Message;
readonly class ImportFilmsBatchMessage
{
public function __construct(
public int $importId,
public int $offset,
public int $limit,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Message;
readonly class ProcessImportMessage
{
public function __construct(
public int $importId,
) {}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\Import;
use App\Entity\Notification;
use App\Entity\UserMovie;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage;
use App\Repository\ImportRepository;
use App\Service\ActorSyncer;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ImportFilmsBatchMessageHandler
{
public function __construct(
private EntityManagerInterface $em,
private FilesystemOperator $defaultStorage,
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private ActorSyncer $actorSyncer,
private ImportRepository $importRepository,
private LoggerInterface $logger,
) {}
public function __invoke(ImportFilmsBatchMessage $message): void
{
$import = $this->em->getRepository(Import::class)->find($message->importId);
if (!$import) {
$this->logger->error('Import not found', ['importId' => $message->importId]);
return;
}
$csvContent = $this->defaultStorage->read($import->getFilePath());
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
file_put_contents($tmpFile, $csvContent);
try {
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
} finally {
unlink($tmpFile);
}
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit);
$user = $import->getUser();
foreach ($batch as $ltbxdMovie) {
try {
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
if (!$movie) {
$this->importRepository->incrementFailedFilms($import);
continue;
}
$this->actorSyncer->syncActorsForMovie($movie);
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
'user' => $user,
'movie' => $movie,
]);
if (!$existingLink) {
$userMovie = new UserMovie();
$userMovie->setUser($user);
$userMovie->setMovie($movie);
$this->em->persist($userMovie);
}
$this->em->flush();
} catch (\Throwable $e) {
$this->logger->warning('Failed to import film', [
'film' => $ltbxdMovie->getName(),
'importId' => $import->getId(),
'error' => $e->getMessage(),
]);
$this->importRepository->incrementFailedFilms($import);
}
}
$processedBatches = $this->importRepository->incrementProcessedBatches($import);
if ($processedBatches >= $import->getTotalBatches()) {
// Refresh the entity to get updated failedFilms from DB
$this->em->refresh($import);
$import->setStatus(Import::STATUS_COMPLETED);
$import->setCompletedAt(new \DateTimeImmutable());
$this->em->flush();
$imported = $import->getTotalFilms() - $import->getFailedFilms();
$notification = new Notification();
$notification->setUser($user);
$notification->setMessage(sprintf(
'Import terminé : %d/%d films importés.',
$imported,
$import->getTotalFilms()
));
$this->em->persist($notification);
$this->em->flush();
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\Import;
use App\Entity\Notification;
use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage;
use App\Message\ProcessImportMessage;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
readonly class ProcessImportMessageHandler
{
private const int BATCH_SIZE = 50;
public function __construct(
private EntityManagerInterface $em,
private FilesystemOperator $defaultStorage,
private LtbxdGateway $ltbxdGateway,
private MessageBusInterface $bus,
private LoggerInterface $logger,
) {}
public function __invoke(ProcessImportMessage $message): void
{
$import = $this->em->getRepository(Import::class)->find($message->importId);
if (!$import) {
$this->logger->error('Import not found', ['importId' => $message->importId]);
return;
}
try {
$csvContent = $this->defaultStorage->read($import->getFilePath());
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
file_put_contents($tmpFile, $csvContent);
try {
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
} finally {
unlink($tmpFile);
}
$totalFilms = count($ltbxdMovies);
$totalBatches = (int) ceil($totalFilms / self::BATCH_SIZE);
$import->setTotalFilms($totalFilms);
$import->setTotalBatches($totalBatches);
$import->setStatus(Import::STATUS_PROCESSING);
$this->em->flush();
for ($i = 0; $i < $totalBatches; $i++) {
$this->bus->dispatch(new ImportFilmsBatchMessage(
importId: $import->getId(),
offset: $i * self::BATCH_SIZE,
limit: self::BATCH_SIZE,
));
}
} catch (\Throwable $e) {
$this->logger->error('Import processing failed', [
'importId' => $import->getId(),
'error' => $e->getMessage(),
]);
$import->setStatus(Import::STATUS_FAILED);
$this->em->flush();
$notification = new Notification();
$notification->setUser($import->getUser());
$notification->setMessage('L\'import a échoué.');
$this->em->persist($notification);
$this->em->flush();
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Import;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Import>
*/
class ImportRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Import::class);
}
public function incrementProcessedBatches(Import $import): int
{
return (int) $this->getEntityManager()->getConnection()->fetchOne(
'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id RETURNING processed_batches',
['id' => $import->getId()]
);
}
public function incrementFailedFilms(Import $import): void
{
$this->getEntityManager()->getConnection()->executeStatement(
'UPDATE import SET failed_films = failed_films + 1 WHERE id = :id',
['id' => $import->getId()]
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return Notification[]
*/
public function findRecentForUser(User $user, int $limit = 20): array
{
return $this->createQueryBuilder('n')
->andWhere('n.user = :user')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countUnreadForUser(User $user): int
{
return $this->count(['user' => $user, 'read' => false]);
}
public function markAllReadForUser(User $user): void
{
$this->createQueryBuilder('n')
->update()
->set('n.read', ':true')
->where('n.user = :user')
->andWhere('n.read = :false')
->setParameter('true', true)
->setParameter('false', false)
->setParameter('user', $user)
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\UserMovie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserMovie>
*/
class UserMovieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserMovie::class);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use Doctrine\ORM\EntityManagerInterface;
readonly class ActorSyncer
{
public function __construct(
private TMDBGateway $tmdbGateway,
private EntityManagerInterface $em,
) {}
/**
* Fetch credits from TMDB for the given movie and create missing Actor/MovieRole entries.
*
* @throws GatewayException
*/
public function syncActorsForMovie(Movie $movie): void
{
$creditsContext = $this->tmdbGateway->getMovieCredits($movie->getTmdbId());
foreach ($creditsContext->cast as $actorModel) {
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
if (!$actor instanceof Actor) {
$actor = new Actor()
->setPopularity($actorModel->popularity)
->setName($actorModel->name)
->setTmdbId($actorModel->id);
$this->em->persist($actor);
}
$existingRole = $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $movie]);
if (0 === $existingRole) {
$role = new MovieRole()
->setMovie($movie)
->setActor($actor)
->setCharacter($actorModel->character);
$this->em->persist($role);
}
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use App\Model\Ltbxd\LtbxdMovie;
use Doctrine\ORM\EntityManagerInterface;
readonly class FilmImporter
{
public function __construct(
private TMDBGateway $tmdbGateway,
private EntityManagerInterface $em,
) {}
/**
* Find an existing Movie by ltbxdRef or create a new one via TMDB.
* Returns null if the movie is not found on TMDB.
*
* @throws GatewayException
*/
public function importFromLtbxdMovie(LtbxdMovie $ltbxdMovie): ?Movie
{
$existing = $this->em->getRepository(Movie::class)->findOneBy(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()]);
if ($existing) {
return $existing;
}
$tmdbMovie = $this->tmdbGateway->searchMovie($ltbxdMovie->getName());
if (!$tmdbMovie) {
return null;
}
$movie = new Movie()
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
->setTitle($ltbxdMovie->getName())
->setTmdbId($tmdbMovie->getId());
$this->em->persist($movie);
return $movie;
}
}

View File

@@ -35,6 +35,19 @@
"migrations/.gitignore"
]
},
"league/flysystem-bundle": {
"version": "3.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
},
"files": [
"config/packages/flysystem.yaml",
"var/storage/.gitignore"
]
},
"pentatrion/vite-bundle": {
"version": "8.2",
"recipe": {

View File

@@ -0,0 +1,61 @@
{% if app.user %}
<div data-controller="import-modal">
<nav class="navbar" data-controller="notifications">
<div class="navbar-left">
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
</div>
<div class="navbar-right">
{# Notifications #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="badge" data-notifications-target="badge" hidden></span>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<div class="dropdown-header">Notifications</div>
<div data-notifications-target="list" class="notifications-list">
<p class="dropdown-empty">Aucune notification</p>
</div>
</div>
</div>
{# User menu #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<button class="dropdown-item" data-action="click->import-modal#open">Importer ses films</button>
<a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a>
</div>
</div>
</div>
</nav>
{# Import Modal #}
<div class="modal-overlay" data-import-modal-target="overlay" hidden>
<div class="modal">
<div class="modal-header">
<h2>Importer ses films</h2>
<button class="modal-close" data-action="click->import-modal#close">&times;</button>
</div>
<div class="modal-body">
<p>Importez votre fichier <code>watched.csv</code> exporté depuis Letterboxd.</p>
<input type="file" accept=".csv" data-import-modal-target="fileInput">
<div data-import-modal-target="feedback" class="modal-feedback" hidden></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" data-action="click->import-modal#submit" data-import-modal-target="submitBtn">
Importer
</button>
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -13,6 +13,7 @@
{% endblock %}
</head>
<body>
{% include '_navbar.html.twig' %}
{% block body %}{% endblock %}
</body>
</html>