Compare commits

..

10 Commits

Author SHA1 Message Date
thibaud-leclere
a196fac6c6 Generate grid 2026-01-31 16:17:24 +01:00
thibaud-leclere
1ebf8b99b3 rearrangements 2026-01-20 10:36:39 +01:00
thibaud-leclere
f0af17024e wip sync cast 2026-01-19 23:39:16 +01:00
thibaud-leclere
b764116552 enhance sync films 2026-01-19 23:22:04 +01:00
thibaud-leclere
5e715a40c6 sync actor roles 2026-01-15 21:51:35 +01:00
thibaud-leclere
cb57824861 Add actors and their roles 2026-01-15 20:35:39 +01:00
thibaud-leclere
dcc47fcb65 Starting actors populate 2026-01-15 14:01:45 +01:00
thibaud-leclere
be171b45b4 Enhance params and envs 2026-01-15 13:16:44 +01:00
thibaud-leclere
b38ef63395 ignore .idea 2026-01-15 13:03:20 +01:00
thibaud-leclere
5c35aff23b Add db, sync movies command 2026-01-14 00:54:49 +01:00
30 changed files with 926 additions and 61 deletions

13
.env
View File

@@ -26,16 +26,6 @@ APP_SHARE_DIR=var/share
DEFAULT_URI=http://localhost DEFAULT_URI=http://localhost
###< symfony/routing ### ###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
# Choose one of the transports below # Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
@@ -46,6 +36,3 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=null://null MAILER_DSN=null://null
###< symfony/mailer ### ###< symfony/mailer ###
TMDB_API_TOKEN=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJkZmE5NDZmMTZmMTcyYmNlMzk0MzZiZmVhZDc2ZTk3NCIsIm5iZiI6MTY0OTE4MjMyNS43NTAwMDAyLCJzdWIiOiI2MjRjODY3NWFmNThjYjAwNTE1NzZiYmEiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.KE68nNxPGYWr5WHVaUuILMOH3sPhiAc9CucPVTgRPpM
TMDB_HOST=https://api.themoviedb.org/3

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@
/public/assets/ /public/assets/
/assets/vendor/ /assets/vendor/
###< symfony/asset-mapper ### ###< symfony/asset-mapper ###
/.idea/

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="app@localhost" uuid="587aeb55-4ca6-47d6-8bc5-b430816b19dd">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/app</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -129,6 +129,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" /> <excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/var/cache" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,3 +1,9 @@
body { body {
background-color: skyblue; background-color: skyblue;
font-family: 'Noto Sans', sans-serif;
}
#actors td {
width: 16px;
height: 16px;
} }

View File

@@ -6,7 +6,7 @@ services:
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-app} POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production # You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pwd}
POSTGRES_USER: ${POSTGRES_USER:-app} POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck: healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
@@ -17,6 +17,8 @@ services:
- database_data:/var/lib/postgresql/data:rw - database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw # - ./docker/db/data:/var/lib/postgresql/data:rw
ports:
- "0.0.0.0:5432:5432"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
volumes: volumes:

View File

@@ -1,6 +1,13 @@
doctrine: doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' driver: pdo_pgsql
host: '%postgres_host%'
port: '%postgres_port%'
dbname: '%postgres_db%'
user: '%postgres_user%'
password: '%postgres_password%'
server_version: '%postgres_version%'
charset: utf8
# IMPORTANT: You MUST configure your server version, # IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file) # either here or in the DATABASE_URL env var (see .env file)
@@ -22,12 +29,9 @@ doctrine:
alias: App alias: App
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
dql:
when@test: numeric_functions:
doctrine: Random: App\Doctrine\Extension\Random
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod: when@prod:
doctrine: doctrine:

11
config/parameters.yml Normal file
View File

@@ -0,0 +1,11 @@
parameters:
postgres_version: "16"
postgres_host: "127.0.0.1"
postgres_port: "5432"
postgres_db: "app"
postgres_user: "app"
postgres_password: "pwd"
tmdb_host: "https://api.themoviedb.org/3"
ltbxd_watched_file: "%kernel.project_dir%/public/files/ltbxd/watched.csv"

View File

@@ -6,7 +6,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: imports:
- { resource: parameters.yml }
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

View File

@@ -0,0 +1,34 @@
<?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 Version20260113232805 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 movie (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, tmdb_id INT NOT NULL, ltbxd_ref VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE movie');
$this->addSql('DROP TABLE messenger_messages');
}
}

View File

@@ -0,0 +1,39 @@
<?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 Version20260115201004 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 actor (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, popularity DOUBLE PRECISION DEFAULT NULL, tmdb_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE movie_role (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, character VARCHAR(255) NOT NULL, actor_id INT DEFAULT NULL, movie_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_A40FAB6710DAF24A ON movie_role (actor_id)');
$this->addSql('CREATE INDEX IDX_A40FAB678F93B6FC ON movie_role (movie_id)');
$this->addSql('ALTER TABLE movie_role ADD CONSTRAINT FK_A40FAB6710DAF24A FOREIGN KEY (actor_id) REFERENCES actor (id)');
$this->addSql('ALTER TABLE movie_role ADD CONSTRAINT FK_A40FAB678F93B6FC FOREIGN KEY (movie_id) REFERENCES movie (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE movie_role DROP CONSTRAINT FK_A40FAB6710DAF24A');
$this->addSql('ALTER TABLE movie_role DROP CONSTRAINT FK_A40FAB678F93B6FC');
$this->addSql('DROP TABLE actor');
$this->addSql('DROP TABLE movie_role');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Command;
use App\Entity\Actor;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-actors')]
readonly class SyncActorsCommand
{
public function __construct (
private TMDBGateway $TMDBGateway,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
try {
$creditsContext = $this->TMDBGateway->getMovieCredits($film->getTmdbId());
} 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();
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Command;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Gateway\TMDBGateway;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-films')]
readonly class SyncFilmsCommands
{
public function __construct(
private LtbxdGateway $ltbxdGateway,
private TMDBGateway $TMDBGateway,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
try {
$ltbxdMovies = $this->ltbxdGateway->parseFile();
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
$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());
} 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();
}
}
$this->em->flush();
$output->writeln('Films synced');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Context\TMDB;
use App\Model\TMDB\TMDBMovieCredit;
class MovieCreditsContext
{
public function __construct(
/** @var TMDBMovieCredit[] */
public array $cast { get => $this->cast; },
) {}
}

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Gateway\TMDBGateway; use App\Gateway\TMDBGateway;
use App\Model\Ltbxd\LtbxdMovie; use App\Repository\ActorRepository;
use App\Repository\MovieRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
@@ -15,23 +15,60 @@ use Symfony\Component\Serializer\SerializerInterface;
class HomepageController extends AbstractController class HomepageController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly TMDBGateway $TMDBGateway, private readonly SerializerInterface $serializer, private readonly ActorRepository $actorRepository
) {} ) {}
#[Route('/')] #[Route('/')]
public function index(SerializerInterface $serializer): Response public function index(SerializerInterface $serializer): Response
{ {
$file = file_get_contents('files/watched.csv'); // Final actor to be guessed
$ltbxdMovies = $this->serializer->deserialize($file, LtbxdMovie::class.'[]', 'csv'); $mainActor = $this->actorRepository->findOneRandom(4);
/** @var LtbxdMovie $ltbxdMovie */
$films = [];
foreach ($ltbxdMovies as $ltbxdMovie) {
// Search movie on TMDB
$searchResult = $this->TMDBGateway->searchMovie($ltbxdMovie->getName());
$films[] = $searchResult->getResults()[0];
}
dd($films);
return $this->render('homepage/index.html.twig'); // Actors for the grid
$actors = [];
$leftSize = 0;
$rightSize = 0;
foreach (str_split(strtolower($mainActor->getName())) as $char) {
if (!preg_match('/[a-z]/', $char)) {
continue;
}
$tryFindActor = 0;
do {
$actor = $this->actorRepository->findOneRandom(4, $char);
++$tryFindActor;
} while (
$actor === $mainActor
|| in_array($actor, array_map(fn ($actorMap) => $actorMap['actor'], $actors))
|| $tryFindActor < 5
);
$actorData = [
'actor' => $actor,
'pos' => strpos($actor->getName(), $char),
];
if ($leftSize < $actorData['pos']) {
$leftSize = $actorData['pos'];
}
$rightSizeActor = strlen($actor->getName()) - $actorData['pos'] - 1;
if ($rightSize < $rightSizeActor) {
$rightSize = $rightSizeActor;
}
$actors[] = $actorData;
}
// Predict grid size
$width = $rightSize + $leftSize + 1;
$middle = $leftSize;
return $this->render('homepage/index.html.twig', [
'mainActor' => $mainActor,
'actors' => $actors,
'width' => $width,
'middle' => $middle,
]);
} }
} }

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Doctrine\Extension;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
class Random extends FunctionNode
{
/**
* @throws QueryException
*/
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker): string
{
return 'RANDOM()';
}
}

108
src/Entity/Actor.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Entity;
use App\Repository\ActorRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ActorRepository::class)]
class Actor
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(nullable: true)]
private ?float $popularity = null;
/**
* @var Collection<int, MovieRole>
*/
#[ORM\OneToMany(targetEntity: MovieRole::class, mappedBy: 'actor')]
private Collection $movieRoles;
#[ORM\Column(nullable: true)]
private ?int $tmdbId = null;
public function __construct()
{
$this->movieRoles = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPopularity(): ?float
{
return $this->popularity;
}
public function setPopularity(?float $popularity): static
{
$this->popularity = $popularity;
return $this;
}
/**
* @return Collection<int, MovieRole>
*/
public function getMovieRoles(): Collection
{
return $this->movieRoles;
}
public function addMovieRole(MovieRole $movieRole): static
{
if (!$this->movieRoles->contains($movieRole)) {
$this->movieRoles->add($movieRole);
$movieRole->setActor($this);
}
return $this;
}
public function removeMovieRole(MovieRole $movieRole): static
{
if ($this->movieRoles->removeElement($movieRole)) {
// set the owning side to null (unless already changed)
if ($movieRole->getActor() === $this) {
$movieRole->setActor(null);
}
}
return $this;
}
public function getTmdbId(): ?int
{
return $this->tmdbId;
}
public function setTmdbId(?int $tmdbId): static
{
$this->tmdbId = $tmdbId;
return $this;
}
}

108
src/Entity/Movie.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Entity;
use App\Repository\MovieRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MovieRepository::class)]
class Movie
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private ?int $tmdbId = null;
#[ORM\Column(length: 255)]
private ?string $ltbxdRef = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
/**
* @var Collection<int, MovieRole>
*/
#[ORM\OneToMany(targetEntity: MovieRole::class, mappedBy: 'movie')]
private Collection $actors;
public function __construct()
{
$this->actors = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getTmdbId(): ?int
{
return $this->tmdbId;
}
public function setTmdbId(int $tmdbId): static
{
$this->tmdbId = $tmdbId;
return $this;
}
public function getLtbxdRef(): ?string
{
return $this->ltbxdRef;
}
public function setLtbxdRef(string $ltbxdRef): static
{
$this->ltbxdRef = $ltbxdRef;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
/**
* @return Collection<int, MovieRole>
*/
public function getActors(): Collection
{
return $this->actors;
}
public function addActor(MovieRole $actor): static
{
if (!$this->actors->contains($actor)) {
$this->actors->add($actor);
$actor->setMovie($this);
}
return $this;
}
public function removeActor(MovieRole $actor): static
{
if ($this->actors->removeElement($actor)) {
// set the owning side to null (unless already changed)
if ($actor->getMovie() === $this) {
$actor->setMovie(null);
}
}
return $this;
}
}

65
src/Entity/MovieRole.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace App\Entity;
use App\Repository\MovieRoleRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MovieRoleRepository::class)]
class MovieRole
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $character = null;
#[ORM\ManyToOne(inversedBy: 'movieRoles')]
private ?Actor $actor = null;
#[ORM\ManyToOne(inversedBy: 'actors')]
private ?Movie $movie = null;
public function getId(): ?int
{
return $this->id;
}
public function getCharacter(): ?string
{
return $this->character;
}
public function setCharacter(string $character): static
{
$this->character = $character;
return $this;
}
public function getActor(): ?Actor
{
return $this->actor;
}
public function setActor(?Actor $actor): static
{
$this->actor = $actor;
return $this;
}
public function getMovie(): ?Movie
{
return $this->movie;
}
public function setMovie(?Movie $movie): static
{
$this->movie = $movie;
return $this;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Exception;
class GatewayException extends \Exception
{
public function __construct(
public string $gateway { get => $this->gateway; },
string $message = '',
?\Throwable $previous = null,
)
{
parent::__construct($message, previous: $previous);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Gateway;
use App\Exception\GatewayException;
use App\Model\Ltbxd\LtbxdMovie;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\SerializerInterface;
readonly class LtbxdGateway
{
public function __construct(
private SerializerInterface $serializer,
#[Autowire('%ltbxd_watched_file%')]
private string $fileDir,
) {}
/**
* @return LtbxdMovie[]
* @throws GatewayException
*/
public function parseFile(): array
{
if (!file_exists($this->fileDir)) {
throw new GatewayException(sprintf('Could not find file %s', $this->fileDir));
}
$fileContent = file_get_contents($this->fileDir);
try {
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
} catch (ExceptionInterface $e) {
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
}
}
}

View File

@@ -2,47 +2,75 @@
namespace App\Gateway; namespace App\Gateway;
use App\Context\TMDB\ActorCreditsContext;
use App\Context\TMDB\MovieCreditsContext;
use App\Context\TMDB\MovieSearchContext; use App\Context\TMDB\MovieSearchContext;
use App\Exception\GatewayException;
use App\Model\TMDB\TMDBMovie;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use function Symfony\Component\String\u; use Symfony\Contracts\HttpClient\ResponseInterface;
class TMDBGateway readonly class TMDBGateway
{ {
private const string SEARCH_URI = '/search/movie'; private const string SEARCH_URI = '/search/movie';
private const string MOVIE_CREDITS_URI = '/movie/{id}/credits';
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private HttpClientInterface $client,
private readonly SerializerInterface $serializer, private SerializerInterface $serializer,
private readonly CacheInterface $cache,
#[Autowire('%env(TMDB_API_TOKEN)%')] #[Autowire('%env(TMDB_API_TOKEN)%')]
private readonly string $apiToken, private string $apiToken,
#[Autowire('%env(TMDB_HOST)%')] #[Autowire('%tmdb_host%')]
private readonly string $host, private string $host,
) { ) {
} }
public function searchMovie(string $movieName): ?MovieSearchContext /**
* @throws GatewayException
*/
public function searchMovie(string $movieName): ?TMDBMovie
{ {
$cacheKey = 'tmdb_movie.'.u($movieName)->snake(); $url = $this->host.self::SEARCH_URI.'?'.http_build_query(['query' => $movieName]);
$searchContext = $this->fetchSerialized('GET', $url, MovieSearchContext::class);
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($movieName) { if (empty($searchResult = $searchContext->getResults())) {
$url = $this->host.self::SEARCH_URI.'?'.http_build_query(['query' => $movieName]); return null;
try { }
$response = $this->client->request('GET', $url, ['headers' => $this->getHeaders()]); return reset($searchResult);
$result = $response->getContent();
return $this->serializer->deserialize($result, MovieSearchContext::class, 'json');
} catch (\Throwable) {
return null;
}
});
} }
private function getHeaders(): array /**
* @throws GatewayException
*/
public function getMovieCredits(int $movieId): ?MovieCreditsContext
{ {
return ['Authorization' => 'Bearer '.$this->apiToken, 'accept' => 'application/json']; $url = $this->host.str_replace('{id}', $movieId, self::MOVIE_CREDITS_URI);
return $this->fetchSerialized('GET', $url, MovieCreditsContext::class);
}
/**
* @throws GatewayException
*/
private function fetch(string $method, string $url): ResponseInterface
{
try {
return $this->client->request($method, $url, ['headers' => ['Authorization' => 'Bearer '.$this->apiToken, 'accept' => 'application/json']]);
} catch (\Throwable $e) {
throw new GatewayException(self::class, $e->getMessage(), $e);
}
}
/**
* @throws GatewayException
*/
private function fetchSerialized(string $method, string $url, string $class, string $type = 'json'): mixed
{
$result = $this->fetch($method, $url);
try {
return $this->serializer->deserialize($result->getContent(), $class, $type);
} catch (\Throwable $e) {
throw new GatewayException(self::class, $e->getMessage(), $e);
}
} }
} }

View File

@@ -36,4 +36,9 @@ class LtbxdMovie
{ {
return $this->ltbxdUri; return $this->ltbxdUri;
} }
public function getLtbxdRef(): string
{
return basename($this->ltbxdUri);
}
} }

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Model\TMDB;
class TMDBMovieCredit
{
public function __construct(
public int $id { get => $this->id; },
public string $name { get => $this->name; },
public float $popularity { get => $this->popularity; },
public string $character { get => $this->character; },
) {}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Repository;
use App\Entity\Actor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Actor>
*/
class ActorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Actor::class);
}
public function findOneRandom(?float $popularity = null, ?string $char = null): Actor
{
$qb = $this->createQueryBuilder('o');
$expr = $qb->expr();
if (!empty($popularity)) {
$qb->andWhere($expr->gte('o.popularity', ':popularity'))
->setParameter('popularity', $popularity);
}
if (!empty($char)) {
$qb->andWhere($expr->like('o.name', ':name'))
->setParameter('name', '%'.$char.'%');
}
return $qb
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Movie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Movie>
*/
class MovieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Movie::class);
}
// /**
// * @return Movie[] Returns an array of Movie objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Movie
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\MovieRole;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MovieRole>
*/
class MovieRoleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MovieRole::class);
}
// /**
// * @return MovieRole[] Returns an array of MovieRole objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?MovieRole
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

21
src/Twig/AppExtension.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('match', [$this, 'match']),
];
}
public function match(string $string, string $pattern): bool
{
return preg_match($pattern, $string);
}
}

View File

@@ -1 +1,26 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %}
<table id="actors">
{% set iActor = 0 %}
{% for mainChar in mainActor.name|split('') %}
{% if not mainChar|match('/[a-zA-Z]/') %}
<tr><td></td></tr>
{% else %}
{% set actor = actors[iActor] %}
<tr>
{% set i = 0 %}
{% set start = middle - actor.pos %}
{% for c in range(0, width) %}
{% if c >= start and c - start < actor.actor.name|length %}
<td {% if c - start == actor.pos %}style="color:red;"{% endif %}>{{ actor.actor.name|slice(c - start, 1)|upper }}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% set iActor = iActor + 1 %}
{% endif %}
{% endfor %}
</table>
{% endblock %}