Compare commits
6 Commits
353ffddeea
...
116d7b409e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116d7b409e | ||
|
|
369893a77e | ||
|
|
8c73a22eff | ||
|
|
087b063f1f | ||
|
|
0e3b17bb7d | ||
|
|
246d6fc740 |
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Actorle
|
||||
|
||||
Un jeu de devinettes inspiré de Wordle, mais autour des acteurs de cinéma. Le joueur doit deviner le nom d'un acteur lettre par lettre, en s'aidant d'indices liés aux films, personnages et récompenses des acteurs.
|
||||
|
||||
## Principe du jeu
|
||||
|
||||
1. Le joueur lance une partie. Le système choisit un acteur aléatoire (filtré par popularité TMDB).
|
||||
2. Pour chaque lettre du nom de l'acteur, un **acteur-indice** est attribué, dont le nom commence par cette lettre.
|
||||
3. Chaque ligne de la grille propose un indice sur l'acteur-indice : un **film** dans lequel il a joué, un **personnage** qu'il a incarné, ou une **récompense** qu'il a reçue.
|
||||
4. Le joueur utilise ces indices pour reconstituer le nom de l'acteur principal.
|
||||
|
||||
Les données proviennent de la filmographie Letterboxd de l'utilisateur : seuls les acteurs issus de ses films regardés sont utilisés pour générer les grilles.
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologies |
|
||||
|------------|-----------------------------------------------------------------|
|
||||
| Backend | PHP 8.4, Symfony 8.0, Doctrine ORM, Symfony Messenger |
|
||||
| Frontend | React 19, Stimulus, Turbo, Vite 6 |
|
||||
| Base | PostgreSQL 16 |
|
||||
| Stockage | FlySystem (S3-compatible) |
|
||||
| Serveur | FrankenPHP (dev), Caddy (prod) |
|
||||
| Infra | Docker Compose |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── Controller/ # Contrôleurs (Game, Import, Auth)
|
||||
├── Entity/ # Entités Doctrine (Game, Actor, Movie, Award...)
|
||||
├── Provider/ # Fournisseurs de données (GameGridProvider)
|
||||
├── Import/ # Import de données externes (FilmImporter, ActorSyncer, AwardImporter)
|
||||
├── Repository/ # Requêtes Doctrine
|
||||
├── Gateway/ # Intégrations externes (TMDB, Wikidata, Letterboxd)
|
||||
├── Message/ # Messages async (ProcessImport, ImportFilmsBatch)
|
||||
├── MessageHandler/ # Handlers Symfony Messenger
|
||||
├── EventListener/ # Abandon auto des parties anonymes au login
|
||||
├── Form/ # Formulaires (inscription)
|
||||
└── Model/ # DTOs (TMDB, Letterboxd)
|
||||
```
|
||||
|
||||
## Intégrations externes
|
||||
|
||||
- **TMDB API** : recherche de films, récupération des castings et crédits
|
||||
- **Wikidata SPARQL** : récupération des récompenses des acteurs (Oscars, BAFTA, etc.)
|
||||
- **Letterboxd** : import de la filmographie via export CSV
|
||||
|
||||
## Import de films
|
||||
|
||||
1. L'utilisateur exporte son historique depuis Letterboxd (CSV).
|
||||
2. Il upload le fichier via l'interface.
|
||||
3. Un message async découpe l'import en **batchs de 50 films**.
|
||||
4. Chaque batch recherche les films sur TMDB, synchronise les acteurs et importe leurs récompenses depuis Wikidata.
|
||||
5. L'avancement est consultable en temps réel via l'API (`GET /api/imports/latest`).
|
||||
|
||||
## Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Docker et Docker Compose
|
||||
- Un token API TMDB
|
||||
|
||||
### Lancement en développement
|
||||
|
||||
```bash
|
||||
# Démarrer les conteneurs (app, messenger, database, node)
|
||||
make dev:up
|
||||
|
||||
# Installer les dépendances npm
|
||||
make node:install
|
||||
|
||||
# Exécuter les migrations
|
||||
make db:migrate
|
||||
```
|
||||
|
||||
L'application est accessible sur `http://localhost`.
|
||||
Le serveur Vite (HMR) tourne sur le port `5173`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Les secrets (token TMDB, etc.) se gèrent via Symfony Secrets :
|
||||
|
||||
```bash
|
||||
make symfony:secrets-set NAME="TMDB_API_TOKEN"
|
||||
```
|
||||
|
||||
Variables d'environnement principales :
|
||||
|
||||
| Variable | Description |
|
||||
|---------------------------|------------------------------------|
|
||||
| `APP_ENV` | Environnement (`dev` / `prod`) |
|
||||
| `APP_SECRET` | Secret Symfony |
|
||||
| `TMDB_API_TOKEN` | Token API TMDB (via secrets) |
|
||||
| `POSTGRES_HOST` | Hôte PostgreSQL |
|
||||
| `POSTGRES_USER` | Utilisateur PostgreSQL |
|
||||
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL |
|
||||
| `POSTGRES_DB` | Nom de la base |
|
||||
| `MESSENGER_TRANSPORT_DSN` | DSN du transport Messenger |
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
make dev:up # Démarrer le dev
|
||||
make dev:down # Arrêter le dev
|
||||
make dev:logs # Logs en temps réel
|
||||
make dev:shell # Shell dans le conteneur app
|
||||
make db:migrate # Exécuter les migrations
|
||||
make db:migration # Générer une migration
|
||||
make db:reset # Reset complet de la base
|
||||
make test # Lancer les tests PHPUnit
|
||||
make node:build # Build des assets pour la prod
|
||||
make help # Afficher toutes les commandes
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
|
||||
```bash
|
||||
# Build et push des images vers le registry
|
||||
make docker:build
|
||||
make docker:push
|
||||
|
||||
# Sur le serveur de production
|
||||
make prod:up
|
||||
```
|
||||
|
||||
Les images sont hébergées sur le registry Gitea `git.lclr.dev`.
|
||||
|
||||
## Services Docker
|
||||
|
||||
| Service | Description |
|
||||
|-------------|-----------------------------------------------------|
|
||||
| `app` | Application FrankenPHP (dev) / Caddy (prod) |
|
||||
| `messenger` | Worker Symfony Messenger (consomme la queue `async`) |
|
||||
| `database` | PostgreSQL 16 |
|
||||
| `node` | Vite dev server avec HMR (dev uniquement) |
|
||||
@@ -56,11 +56,11 @@ export default class extends Controller {
|
||||
this.importBtnTarget.disabled = true;
|
||||
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
||||
|
||||
const progress = data.totalBatches > 0
|
||||
? Math.round((data.processedBatches / data.totalBatches) * 100)
|
||||
const progress = data.totalFilms > 0
|
||||
? Math.round((data.processedFilms / data.totalFilms) * 100)
|
||||
: 0;
|
||||
|
||||
this._setStatus(`${progress}% — ${data.totalFilms} films`, 'active');
|
||||
this._setStatus(`${progress}% — ${data.processedFilms}/${data.totalFilms} films`, 'active');
|
||||
}
|
||||
|
||||
_showCompleted(data) {
|
||||
|
||||
30
migrations/Version20260401000002.php
Normal file
30
migrations/Version20260401000002.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260401000002 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add processed_films column to import table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE import ADD processed_films INT NOT NULL DEFAULT 0');
|
||||
$this->addSql('ALTER TABLE import DROP COLUMN total_batches');
|
||||
$this->addSql('ALTER TABLE import DROP COLUMN processed_batches');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE import DROP COLUMN processed_films');
|
||||
$this->addSql('ALTER TABLE import ADD total_batches INT NOT NULL DEFAULT 0');
|
||||
$this->addSql('ALTER TABLE import ADD processed_batches INT NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace App\Controller;
|
||||
use App\Entity\Game;
|
||||
use App\Entity\User;
|
||||
use App\Repository\GameRepository;
|
||||
use App\Service\GameGridGenerator;
|
||||
use App\Provider\GameGridProvider;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -19,7 +19,7 @@ class GameController extends AbstractController
|
||||
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||
public function start(
|
||||
Request $request,
|
||||
GameGridGenerator $generator,
|
||||
GameGridProvider $generator,
|
||||
GameRepository $gameRepository,
|
||||
): Response {
|
||||
$this->validateCsrfToken('game_start', $request);
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace App\Controller;
|
||||
use App\Entity\Game;
|
||||
use App\Entity\User;
|
||||
use App\Repository\GameRepository;
|
||||
use App\Service\GameGridGenerator;
|
||||
use App\Provider\GameGridProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -19,7 +19,7 @@ class HomepageController extends AbstractController
|
||||
public function index(
|
||||
Request $request,
|
||||
GameRepository $gameRepository,
|
||||
GameGridGenerator $gridGenerator,
|
||||
GameGridProvider $gridGenerator,
|
||||
): Response {
|
||||
/** @var User|null $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
@@ -37,9 +37,8 @@ class ImportController extends AbstractController
|
||||
'id' => $import->getId(),
|
||||
'status' => $import->getStatus(),
|
||||
'totalFilms' => $import->getTotalFilms(),
|
||||
'processedFilms' => $import->getProcessedFilms(),
|
||||
'failedFilms' => $import->getFailedFilms(),
|
||||
'processedBatches' => $import->getProcessedBatches(),
|
||||
'totalBatches' => $import->getTotalBatches(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,15 +30,12 @@ class Import
|
||||
#[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 $processedFilms = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $failedFilms = 0;
|
||||
|
||||
@@ -91,25 +88,14 @@ class Import
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalBatches(): int
|
||||
public function getProcessedFilms(): int
|
||||
{
|
||||
return $this->totalBatches;
|
||||
return $this->processedFilms;
|
||||
}
|
||||
|
||||
public function setTotalBatches(int $totalBatches): static
|
||||
public function setProcessedFilms(int $processedFilms): static
|
||||
{
|
||||
$this->totalBatches = $totalBatches;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProcessedBatches(): int
|
||||
{
|
||||
return $this->processedBatches;
|
||||
}
|
||||
|
||||
public function setProcessedBatches(int $processedBatches): static
|
||||
{
|
||||
$this->processedBatches = $processedBatches;
|
||||
$this->processedFilms = $processedFilms;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,23 @@ class WikidataGateway
|
||||
*/
|
||||
public function getAwards(Actor $actor): array
|
||||
{
|
||||
$sparql = $this->buildQuery($actor->getName());
|
||||
return $this->getAwardsForActors([$actor])[$actor->getName()] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch awards for multiple actors in a single SPARQL query.
|
||||
*
|
||||
* @param list<Actor> $actors
|
||||
*
|
||||
* @return array<string, list<array{name: string, year: int}>>
|
||||
*/
|
||||
public function getAwardsForActors(array $actors): array
|
||||
{
|
||||
if ([] === $actors) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sparql = $this->buildBatchQuery($actors);
|
||||
|
||||
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
|
||||
'query' => [
|
||||
@@ -33,19 +49,20 @@ class WikidataGateway
|
||||
'Accept' => 'application/sparql-results+json',
|
||||
'User-Agent' => 'LtbxdActorle/1.0',
|
||||
],
|
||||
'timeout' => 5,
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
$awards = [];
|
||||
|
||||
foreach ($data['results']['bindings'] ?? [] as $binding) {
|
||||
$name = $binding['awardLabel']['value'] ?? null;
|
||||
$actorName = $binding['name']['value'] ?? null;
|
||||
$awardName = $binding['awardLabel']['value'] ?? null;
|
||||
$year = $binding['year']['value'] ?? null;
|
||||
|
||||
if ($name && $year) {
|
||||
$awards[] = [
|
||||
'name' => $name,
|
||||
if ($actorName && $awardName && $year) {
|
||||
$awards[$actorName][] = [
|
||||
'name' => $awardName,
|
||||
'year' => (int) substr($year, 0, 4),
|
||||
];
|
||||
}
|
||||
@@ -54,13 +71,21 @@ class WikidataGateway
|
||||
return $awards;
|
||||
}
|
||||
|
||||
private function buildQuery(string $actorName): string
|
||||
/**
|
||||
* @param list<Actor> $actors
|
||||
*/
|
||||
private function buildBatchQuery(array $actors): string
|
||||
{
|
||||
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actorName);
|
||||
$values = implode(' ', array_map(function (Actor $actor) {
|
||||
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actor->getName());
|
||||
|
||||
return '"'.$escaped.'"@en';
|
||||
}, $actors));
|
||||
|
||||
return <<<SPARQL
|
||||
SELECT ?awardLabel ?year WHERE {
|
||||
?person rdfs:label "{$escaped}"@en .
|
||||
SELECT ?name ?awardLabel ?year WHERE {
|
||||
VALUES ?name { {$values} }
|
||||
?person rdfs:label ?name .
|
||||
?person wdt:P31 wd:Q5 .
|
||||
?person p:P166 ?awardStatement .
|
||||
?awardStatement ps:P166 ?award .
|
||||
@@ -68,7 +93,7 @@ class WikidataGateway
|
||||
BIND(YEAR(?date) AS ?year)
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
|
||||
}
|
||||
ORDER BY DESC(?year)
|
||||
ORDER BY ?name DESC(?year)
|
||||
SPARQL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Import;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Movie;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Import;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Award;
|
||||
@@ -23,15 +23,25 @@ readonly class AwardImporter
|
||||
|
||||
public function importForActor(Actor $actor): void
|
||||
{
|
||||
if ($actor->isAwardsImported()) {
|
||||
$this->importForActors([$actor]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Actor> $actors
|
||||
*/
|
||||
public function importForActors(array $actors): void
|
||||
{
|
||||
$actorsToFetch = array_filter($actors, fn (Actor $a) => !$a->isAwardsImported());
|
||||
|
||||
if ([] === $actorsToFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$wikidataAwards = $this->wikidataGateway->getAwards($actor);
|
||||
$allAwards = $this->wikidataGateway->getAwardsForActors(array_values($actorsToFetch));
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger?->warning('Failed to fetch awards from Wikidata', [
|
||||
'actor' => $actor->getName(),
|
||||
'actors' => array_map(fn (Actor $a) => $a->getName(), $actorsToFetch),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -40,6 +50,9 @@ readonly class AwardImporter
|
||||
|
||||
$knownTypes = $this->awardTypeRepository->findAll();
|
||||
|
||||
foreach ($actorsToFetch as $actor) {
|
||||
$wikidataAwards = $allAwards[$actor->getName()] ?? [];
|
||||
|
||||
foreach ($wikidataAwards as $wikidataAward) {
|
||||
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
||||
|
||||
@@ -54,6 +67,7 @@ readonly class AwardImporter
|
||||
|
||||
$actor->setAwardsImported(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AwardType> $knownTypes
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Import;
|
||||
|
||||
use App\Entity\Movie;
|
||||
use App\Exception\GatewayException;
|
||||
@@ -6,9 +6,11 @@ namespace App\Message;
|
||||
|
||||
readonly class ImportFilmsBatchMessage
|
||||
{
|
||||
/**
|
||||
* @param list<array{name: string, year: int, ltbxdUri: string, date: string}> $films
|
||||
*/
|
||||
public function __construct(
|
||||
public int $importId,
|
||||
public int $offset,
|
||||
public int $limit,
|
||||
public array $films,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,13 @@ namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Import;
|
||||
use App\Entity\UserMovie;
|
||||
use App\Gateway\LtbxdGateway;
|
||||
use App\Message\ImportFilmsBatchMessage;
|
||||
use App\Model\Ltbxd\LtbxdMovie;
|
||||
use App\Repository\ImportRepository;
|
||||
use App\Service\ActorSyncer;
|
||||
use App\Service\AwardImporter;
|
||||
use App\Service\FilmImporter;
|
||||
use App\Import\ActorSyncer;
|
||||
use App\Import\AwardImporter;
|
||||
use App\Import\FilmImporter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
@@ -22,12 +21,10 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private FilesystemOperator $defaultStorage,
|
||||
private LtbxdGateway $ltbxdGateway,
|
||||
private FilmImporter $filmImporter,
|
||||
private ActorSyncer $actorSyncer,
|
||||
private AwardImporter $awardImporter,
|
||||
private ImportRepository $importRepository,
|
||||
private AwardImporter $awardImporter,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -40,17 +37,15 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
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);
|
||||
$batch = array_map(
|
||||
fn (array $film) => new LtbxdMovie(
|
||||
date: new \DateTime($film['date']),
|
||||
name: $film['name'],
|
||||
year: $film['year'],
|
||||
ltbxdUri: $film['ltbxdUri'],
|
||||
),
|
||||
$message->films,
|
||||
);
|
||||
$userId = $import->getUser()->getId();
|
||||
$importId = $import->getId();
|
||||
|
||||
@@ -59,15 +54,11 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
|
||||
if (!$movie) {
|
||||
$this->importRepository->incrementFailedFilms($import);
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->actorSyncer->syncActorsForMovie($movie);
|
||||
|
||||
// Import awards for actors of this movie
|
||||
foreach ($movie->getActors() as $role) {
|
||||
$this->awardImporter->importForActor($role->getActor());
|
||||
}
|
||||
$actors = array_map(fn ($role) => $role->getActor(), $movie->getActors()->toArray());
|
||||
$this->awardImporter->importForActors($actors);
|
||||
|
||||
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
||||
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
||||
@@ -82,6 +73,7 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Failed to import film', [
|
||||
'film' => $ltbxdMovie->getName(),
|
||||
@@ -91,19 +83,16 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
$this->importRepository->incrementFailedFilms($import);
|
||||
}
|
||||
|
||||
$processedFilms = $this->importRepository->incrementProcessedFilms($import);
|
||||
|
||||
$this->em->clear();
|
||||
$import = $this->em->getRepository(Import::class)->find($importId);
|
||||
}
|
||||
|
||||
$processedBatches = $this->importRepository->incrementProcessedBatches($import);
|
||||
|
||||
if ($processedBatches >= $import->getTotalBatches()) {
|
||||
// Refresh the entity to get updated failedFilms from DB
|
||||
$this->em->refresh($import);
|
||||
|
||||
if ($processedFilms >= $import->getTotalFilms()) {
|
||||
$import->setStatus(Import::STATUS_COMPLETED);
|
||||
$import->setCompletedAt(new \DateTimeImmutable());
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,18 +49,23 @@ readonly class ProcessImportMessageHandler
|
||||
}
|
||||
|
||||
$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++) {
|
||||
$batches = array_chunk($ltbxdMovies, self::BATCH_SIZE);
|
||||
foreach ($batches as $batch) {
|
||||
$films = array_map(fn ($movie) => [
|
||||
'name' => $movie->getName(),
|
||||
'year' => $movie->getYear(),
|
||||
'ltbxdUri' => $movie->getLtbxdUri(),
|
||||
'date' => $movie->getDate()->format('Y-m-d'),
|
||||
], $batch);
|
||||
|
||||
$this->bus->dispatch(new ImportFilmsBatchMessage(
|
||||
importId: $import->getId(),
|
||||
offset: $i * self::BATCH_SIZE,
|
||||
limit: self::BATCH_SIZE,
|
||||
films: $films,
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Provider;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Game;
|
||||
@@ -14,7 +14,7 @@ use App\Repository\MovieRoleRepository;
|
||||
use App\Repository\AwardRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class GameGridGenerator
|
||||
class GameGridProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActorRepository $actorRepository,
|
||||
@@ -18,10 +18,10 @@ class ImportRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, Import::class);
|
||||
}
|
||||
|
||||
public function incrementProcessedBatches(Import $import): int
|
||||
public function incrementProcessedFilms(Import $import): int
|
||||
{
|
||||
return (int) $this->getEntityManager()->getConnection()->fetchOne(
|
||||
'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id RETURNING processed_batches',
|
||||
'UPDATE import SET processed_films = processed_films + 1 WHERE id = :id RETURNING processed_films',
|
||||
['id' => $import->getId()]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
namespace App\Tests\Import;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Award;
|
||||
use App\Entity\AwardType;
|
||||
use App\Gateway\WikidataGateway;
|
||||
use App\Repository\AwardTypeRepository;
|
||||
use App\Service\AwardImporter;
|
||||
use App\Import\AwardImporter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -35,19 +35,21 @@ class AwardImporterTest extends TestCase
|
||||
|
||||
public function testSkipsActorWithAwardsAlreadyImported(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag(awardsImported: true);
|
||||
$actor = $this->createActorWithFlag('Already Imported', awardsImported: true);
|
||||
|
||||
$this->wikidataGateway->expects($this->never())->method('getAwards');
|
||||
$this->wikidataGateway->expects($this->never())->method('getAwardsForActors');
|
||||
|
||||
$this->importer->importForActor($actor);
|
||||
$this->importer->importForActors([$actor]);
|
||||
}
|
||||
|
||||
public function testImportsAwardsAndSetsFlag(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwards')->willReturn([
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'Academy Award for Best Actor', 'year' => 2020],
|
||||
],
|
||||
]);
|
||||
|
||||
$existingType = new AwardType();
|
||||
@@ -60,7 +62,7 @@ class AwardImporterTest extends TestCase
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActor($actor);
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$this->assertTrue($actor->isAwardsImported());
|
||||
$this->assertCount(1, $persisted);
|
||||
@@ -73,10 +75,12 @@ class AwardImporterTest extends TestCase
|
||||
|
||||
public function testCreatesNewAwardTypeWhenNoPatternMatches(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwards')->willReturn([
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||
@@ -86,7 +90,7 @@ class AwardImporterTest extends TestCase
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActor($actor);
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$this->assertTrue($actor->isAwardsImported());
|
||||
// Should persist both a new AwardType and the Award
|
||||
@@ -104,34 +108,65 @@ class AwardImporterTest extends TestCase
|
||||
|
||||
public function testDoesNotSetFlagOnWikidataError(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwards')
|
||||
$this->wikidataGateway->method('getAwardsForActors')
|
||||
->willThrowException(new \RuntimeException('Wikidata timeout'));
|
||||
|
||||
$this->importer->importForActor($actor);
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$this->assertFalse($actor->isAwardsImported());
|
||||
}
|
||||
|
||||
public function testHandlesActorWithNoAwards(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwards')->willReturn([]);
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([]);
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||
|
||||
$this->em->expects($this->never())->method('persist');
|
||||
|
||||
$this->importer->importForActor($actor);
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$this->assertTrue($actor->isAwardsImported());
|
||||
}
|
||||
|
||||
private function createActorWithFlag(bool $awardsImported): Actor
|
||||
public function testBatchImportsMultipleActors(): void
|
||||
{
|
||||
$actor1 = $this->createActorWithFlag('Actor One', awardsImported: false);
|
||||
$actor2 = $this->createActorWithFlag('Actor Two', awardsImported: false);
|
||||
$alreadyImported = $this->createActorWithFlag('Actor Three', awardsImported: true);
|
||||
|
||||
$this->wikidataGateway->expects($this->once())->method('getAwardsForActors')
|
||||
->with($this->callback(fn (array $actors) => 2 === \count($actors)))
|
||||
->willReturn([
|
||||
'Actor One' => [['name' => 'Academy Award for Best Actor', 'year' => 2020]],
|
||||
'Actor Two' => [['name' => 'Golden Globe for Best Actor', 'year' => 2021]],
|
||||
]);
|
||||
|
||||
$existingType = new AwardType();
|
||||
$existingType->setName('Oscar')->setPattern('Academy Award');
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||
|
||||
$persisted = [];
|
||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActors([$actor1, $actor2, $alreadyImported]);
|
||||
|
||||
$this->assertTrue($actor1->isAwardsImported());
|
||||
$this->assertTrue($actor2->isAwardsImported());
|
||||
// 2 Awards + 1 new AwardType (Golden Globe)
|
||||
$this->assertCount(3, $persisted);
|
||||
}
|
||||
|
||||
private function createActorWithFlag(string $name, bool $awardsImported): Actor
|
||||
{
|
||||
$actor = new Actor();
|
||||
$actor->setName('Test Actor');
|
||||
$actor->setName($name);
|
||||
$actor->setAwardsImported($awardsImported);
|
||||
|
||||
return $actor;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
namespace App\Tests\Provider;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Award;
|
||||
@@ -12,11 +12,11 @@ use App\Repository\ActorRepository;
|
||||
use App\Repository\AwardRepository;
|
||||
use App\Repository\MovieRepository;
|
||||
use App\Repository\MovieRoleRepository;
|
||||
use App\Service\GameGridGenerator;
|
||||
use App\Provider\GameGridProvider;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class GameGridGeneratorTest extends TestCase
|
||||
class GameGridProviderTest extends TestCase
|
||||
{
|
||||
public function testResolveHintTextForAward(): void
|
||||
{
|
||||
@@ -35,7 +35,7 @@ class GameGridGeneratorTest extends TestCase
|
||||
$awardRepository = $this->createMock(AwardRepository::class);
|
||||
$awardRepository->method('find')->with(42)->willReturn($award);
|
||||
|
||||
$generator = new GameGridGenerator(
|
||||
$generator = new GameGridProvider(
|
||||
$this->createMock(ActorRepository::class),
|
||||
$this->createMock(MovieRoleRepository::class),
|
||||
$this->createMock(MovieRepository::class),
|
||||
Reference in New Issue
Block a user