Bläddra i källkod

Refonte du client Api Tmdb

Sangfroid 1 dag sedan
förälder
incheckning
55de3c9a3e

+ 25 - 13
src/Controller/VideothequeCrudController.php

@@ -6,6 +6,7 @@ use App\Entity\Film;
 use App\Form\FilmType;
 use App\Repository\FilmRepository;
 use App\Service\FilmManager;
+use App\Service\Tmdb\TmdbApiException;
 use App\Service\TmdbApiService;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -57,11 +58,15 @@ class VideothequeCrudController extends AbstractController
         TmdbApiService $tmdbApiService,
         ?string $idtmdb = null
     ): Response
-	{
+    {
+        $film = new Film;
         if ($idtmdb !== null) {
-            $film = $tmdbApiService->hydrateFilm($idtmdb);
-        } else {
-    		$film = new Film;
+            $filmTmdb = $tmdbApiService->findFilm((int) $idtmdb);
+            if ($filmTmdb !== null) {
+                $film = $filmTmdb;
+            } else {
+                $this->addFlash('error', 'Impossible de récupérer la fiche du film sur TheMovieDB.org');
+            }
         }
 		$form = $this->createForm(FilmType::class, $film);
 
@@ -95,15 +100,22 @@ class VideothequeCrudController extends AbstractController
         }
         $recherche = $request->query->get('query', "");
 
-        if ($tmdbApiService->query($recherche, $page)) {
-            $nbFilms = $tmdbApiService->countResults();
-            $nbPages = $tmdbApiService->countPages();
-            $films = $tmdbApiService->getFilms();
-            foreach($films as $key => $film) {
-                $filmDejaEnBase = $filmRepository->findFilmInImdb($film['data']);
-                foreach($filmDejaEnBase as $filmInBdd) {
-                    $films[$key]['filmBdd'][] = $filmInBdd;
-                }         
+        try {
+            $result = $recherche !== "" ? $tmdbApiService->search($recherche, $page) : null;
+        } catch (TmdbApiException $e) {
+            $result = null;
+            $this->addFlash('error', 'La recherche sur TheMovieDB.org a échoué');
+        }
+
+        if ($result !== null) {
+            $nbFilms = $result->totalResults;
+            $nbPages = $result->totalPages;
+            foreach ($result->movies as $tmdbId => $movie) {
+                $film = $tmdbApiService->createFilm($movie);
+                $films[$tmdbId] = [
+                    'film' => $film,
+                    'filmsBdd' => $filmRepository->findFilmInImdb($film),
+                ];
             }
         }
         

+ 60 - 0
src/Service/Tmdb/FilmFactory.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Service\Tmdb;
+
+use App\Entity\Film;
+use App\Entity\Genre;
+use App\Entity\MediaVideo;
+use App\Entity\Realisateur;
+use App\Repository\GenreRepository;
+use App\Repository\RealisateurRepository;
+use App\Service\UniciteCollections;
+
+/**
+ * Mappe un film TMDb (DTO) vers l'entité Film du projet.
+ * Les genres et réalisateurs déjà connus en base sont réutilisés.
+ */
+final class FilmFactory
+{
+    public function __construct(
+        protected GenreRepository $repoGenre,
+        protected RealisateurRepository $repoReal,
+        protected UniciteCollections $uc,
+    ) {}
+
+    public function createFilm(TmdbMovie $movie): Film
+    {
+        $film = new Film();
+        $film
+            ->setTitre($movie->titre)
+            ->setInformation($movie->information)
+            ->setDateSortie($movie->getDateSortie())
+            ->setAnnee($movie->getAnnee());
+
+        foreach ($movie->genres as $nom) {
+            $film->addGenre($this->trouverOuCreerGenre($nom));
+        }
+
+        foreach ($movie->realisateurs as $nomComplet) {
+            $film->addRealisateur($this->trouverOuCreerRealisateur($nomComplet));
+        }
+
+        if ($movie->trailer !== null) {
+            $video = new MediaVideo();
+            $video->setFromTmdb($movie->trailer["type"], $movie->trailer["identif"]);
+            $film->addMediaVideo($video);
+        }
+
+        return $this->uc->assureUniciteCollections($film);
+    }
+
+    private function trouverOuCreerGenre(string $nom): Genre
+    {
+        return $this->repoGenre->findOneBy(["name" => $nom]) ?? (new Genre())->setName($nom);
+    }
+
+    private function trouverOuCreerRealisateur(string $nomComplet): Realisateur
+    {
+        return $this->repoReal->findOneBy(["nomComplet" => $nomComplet]) ?? (new Realisateur())->setNomComplet($nomComplet);
+    }
+}

+ 19 - 0
src/Service/Tmdb/TmdbApiException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Service\Tmdb;
+
+/**
+ * Erreur renvoyée par le client TMDb : échec HTTP ou décodage de la réponse.
+ */
+final class TmdbApiException extends \RuntimeException
+{
+    public static function fromRequestFailure(string $endpoint, \Throwable $previous): self
+    {
+        return new self(sprintf('La requête TMDb "%s" a échoué : %s', $endpoint, $previous->getMessage()), 0, $previous);
+    }
+
+    public static function fromDecodingFailure(string $endpoint): self
+    {
+        return new self(sprintf('La réponse TMDb de "%s" n\'a pas pu être décodée.', $endpoint));
+    }
+}

+ 121 - 0
src/Service/Tmdb/TmdbClient.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Service\Tmdb;
+
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * Client HTTP TMDb : requêtes, gestion d'erreurs et décodage en DTO.
+ *
+ * Toutes les erreurs HTTP/réseau/décodage sont converties en TmdbApiException.
+ */
+final class TmdbClient
+{
+    public function __construct(
+        protected HttpClientInterface $tmdbClient,
+        protected LoggerInterface $logger,
+    ) {}
+
+    public function search(string $titre, int $page = 1): TmdbSearchResult
+    {
+        $data = $this->request("search/movie", [
+            "query" => $titre,
+            "page" => max(1, $page),
+            "include_adult" => "true",
+        ]);
+
+        return TmdbSearchResult::fromArray($data);
+    }
+
+    public function getMovie(int $tmdbId): TmdbMovie
+    {
+        $data = $this->requestDetail($tmdbId);
+        return TmdbMovie::fromArray($data);
+    }
+
+    /**
+     * Récupère plusieurs fiches détaillées en parallèle.
+     * Les fiches en échec sont ignorées (logguées) au lieu de faire échouer tout le lot.
+     *
+     * @param int[] $tmdbIds
+     * @return array<int, TmdbMovie> indexés par identifiant TMDb ; les échecs sont absents
+     */
+    public function getMovies(array $tmdbIds): array
+    {
+        if ($tmdbIds === []) {
+            return [];
+        }
+
+        $responses = new \SplObjectStorage();
+        foreach ($tmdbIds as $tmdbId) {
+            $response = $this->tmdbClient->request("GET", "movie/" . $tmdbId, $this->options([
+                "append_to_response" => "credits,videos",
+                "include_video_language" => "fr,en,null",
+            ]));
+            $responses[$response] = $tmdbId;
+        }
+
+        $movies = [];
+        foreach ($this->tmdbClient->stream($responses) as $response => $chunk) {
+            if ($chunk->isTimeout() || !$chunk->isLast()) {
+                continue;
+            }
+            $tmdbId = $responses[$response];
+            try {
+                $movies[$tmdbId] = TmdbMovie::fromArray($response->toArray());
+            } catch (ExceptionInterface $e) {
+                $this->logger->warning("TMDb : fiche détaillée ignorée", [
+                    "tmdbId" => $tmdbId,
+                    "erreur" => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $movies;
+    }
+
+    /**
+     * @param array<string, mixed> $query
+     * @return array<string, mixed>
+     */
+    private function request(string $endpoint, array $query = []): array
+    {
+        try {
+            $response = $this->tmdbClient->request("GET", $endpoint, $this->options($query));
+            return $response->toArray();
+        } catch (ExceptionInterface $e) {
+            $this->logger->warning("TMDb : requête en échec", [
+                "endpoint" => $endpoint,
+                "erreur" => $e->getMessage(),
+            ]);
+            throw TmdbApiException::fromRequestFailure($endpoint, $e);
+        }
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function requestDetail(int $tmdbId): array
+    {
+        return $this->request("movie/" . $tmdbId, [
+            "append_to_response" => "credits,videos",
+            "include_video_language" => "fr,en,null",
+        ]);
+    }
+
+    /**
+     * Le scoped client "tmdb.client" fournit déjà les options globales
+     * (Authorization, language=fr) : ici on ne complète que par requête.
+     *
+     * @param array<string, mixed> $query
+     * @return array{query: array<string, mixed>}
+     */
+    private function options(array $query): array
+    {
+        return [
+            "query" => $query,
+        ];
+    }
+}

+ 109 - 0
src/Service/Tmdb/TmdbMovie.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Service\Tmdb;
+
+/**
+ * Représentation typée d'un film TMDb.
+ *
+ * Peut être construite soit depuis le résultat d'une recherche (payload partiel :
+ * pas de genres nommés, pas de crédits, pas de vidéos), soit depuis la fiche
+ * détaillée /movie/{id} avec append_to_response=credits,videos.
+ */
+final class TmdbMovie
+{
+    private const SITES_VIDEO_SUPPORTES = ["YouTube", "Vimeo", "DailyMotion"];
+
+    /**
+     * @param string[] $genres noms des genres (vide dans le payload d'une recherche)
+     * @param string[] $realisateurs noms des réalisateurs (vide dans le payload d'une recherche)
+     * @param array{type: string, identif: string}|null $trailer
+     */
+    public function __construct(
+        public readonly int $tmdbId,
+        public readonly ?string $titre,
+        public readonly ?string $information,
+        public readonly ?string $dateSortie,
+        public readonly array $genres,
+        public readonly array $realisateurs,
+        public readonly ?array $trailer,
+    ) {}
+
+    /**
+     * @param array<string, mixed> $data
+     */
+    public static function fromArray(array $data): self
+    {
+        $genres = [];
+        foreach ($data["genres"] ?? [] as $genre) {
+            if (isset($genre["name"])) {
+                $genres[] = (string) $genre["name"];
+            }
+        }
+
+        $realisateurs = [];
+        foreach ($data["credits"]["crew"] ?? [] as $crew) {
+            if (($crew["job"] ?? null) === "Director" && isset($crew["name"])) {
+                $realisateurs[] = (string) $crew["name"];
+            }
+        }
+
+        return new self(
+            tmdbId: (int) ($data["id"] ?? 0),
+            titre: isset($data["title"]) ? (string) $data["title"] : null,
+            information: isset($data["overview"]) ? (string) $data["overview"] : null,
+            dateSortie: isset($data["release_date"]) && $data["release_date"] !== "" ? (string) $data["release_date"] : null,
+            genres: $genres,
+            realisateurs: $realisateurs,
+            trailer: self::choisirTrailer($data["videos"]["results"] ?? []),
+        );
+    }
+
+    public function getDateSortie(): ?\DateTimeImmutable
+    {
+        return $this->dateSortie !== null && preg_match("/^\d{4}-\d{2}-\d{2}$/", $this->dateSortie)
+            ? new \DateTimeImmutable($this->dateSortie)
+            : null;
+    }
+
+    /**
+     * TMDb peut renvoyer une date incomplète ("2023") : l'année est alors déduite.
+     */
+    public function getAnnee(): ?\DateTimeImmutable
+    {
+        if ($this->dateSortie === null || !preg_match("/^\d{4}/", $this->dateSortie)) {
+            return null;
+        }
+        return new \DateTimeImmutable(substr($this->dateSortie, 0, 4) . "-01-01");
+    }
+
+    /**
+     * Choisit la bande-annonce : d'abord la BA française, sinon la première BA
+     * trouvée sur un site supporté.
+     *
+     * @param array<int, array<string, mixed>> $videos
+     * @return array{type: string, identif: string}|null
+     */
+    private static function choisirTrailer(array $videos): ?array
+    {
+        $fallback = null;
+        foreach ($videos as $video) {
+            if (!in_array($video["site"] ?? "", self::SITES_VIDEO_SUPPORTES, true)) {
+                continue;
+            }
+            if (($video["type"] ?? null) !== "Trailer" || !isset($video["key"])) {
+                continue;
+            }
+
+            $trailer = [
+                "type" => strtolower((string) $video["site"]),
+                "identif" => (string) $video["key"],
+            ];
+
+            if (($video["iso_639_1"] ?? null) === "fr" && ($video["iso_3166_1"] ?? null) === "FR") {
+                return $trailer;
+            }
+            $fallback ??= $trailer;
+        }
+        return $fallback;
+    }
+}

+ 58 - 0
src/Service/Tmdb/TmdbSearchResult.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Service\Tmdb;
+
+/**
+ * Page de résultats d'une recherche TMDb.
+ */
+final class TmdbSearchResult
+{
+    /**
+     * @param array<int, TmdbMovie> $movies indexés par identifiant TMDb
+     */
+    public function __construct(
+        public readonly int $page,
+        public readonly int $totalPages,
+        public readonly int $totalResults,
+        public readonly array $movies,
+    ) {}
+
+    /**
+     * @param array<string, mixed> $data payload brut de /search/movie
+     * @param array<int, TmdbMovie> $movies
+     */
+    public static function fromArray(array $data, array $movies = []): self
+    {
+        return new self(
+            page: (int) ($data["page"] ?? 1),
+            totalPages: (int) ($data["total_pages"] ?? 0),
+            totalResults: (int) ($data["total_results"] ?? 0),
+            movies: $movies !== [] ? $movies : self::extraireMovies($data),
+        );
+    }
+
+    /**
+     * Remplace les films par des versions enrichies (fiche détaillée récupérée).
+     *
+     * @param array<int, TmdbMovie> $movies indexés par identifiant TMDb
+     */
+    public function withMovies(array $movies): self
+    {
+        return new self($this->page, $this->totalPages, $this->totalResults, $movies);
+    }
+
+    /**
+     * @return array<int, TmdbMovie>
+     */
+    private static function extraireMovies(array $data): array
+    {
+        $movies = [];
+        foreach ($data["results"] ?? [] as $resultat) {
+            $movie = TmdbMovie::fromArray($resultat);
+            if ($movie->tmdbId !== 0) {
+                $movies[$movie->tmdbId] = $movie;
+            }
+        }
+        return $movies;
+    }
+}

+ 45 - 130
src/Service/TmdbApiService.php

@@ -3,160 +3,75 @@
 namespace App\Service;
 
 use App\Entity\Film;
-use App\Entity\Genre;
-use App\Entity\MediaVideo;
-use App\Entity\Realisateur;
-use Symfony\Contracts\HttpClient\HttpClientInterface;
-use stdClass;
-
-class TmdbApiService
+use App\Service\Tmdb\FilmFactory;
+use App\Service\Tmdb\TmdbApiException;
+use App\Service\Tmdb\TmdbClient;
+use App\Service\Tmdb\TmdbMovie;
+use App\Service\Tmdb\TmdbSearchResult;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Orchestration TMDb pour l'application : recherche et mapping vers l'entité Film.
+ */
+final class TmdbApiService
 {
-    protected string $content = "";
-    protected int $nbResults = 0;
-    protected int $nbPages = 0;
-    protected array $films = [];
-
     public function __construct(
-        protected HttpClientInterface $tmdbClient,
-        protected UniciteCollections $uc,
+        protected TmdbClient $client,
+        protected FilmFactory $filmFactory,
+        protected LoggerInterface $logger,
     ) {}
 
-    public function query(string $titre, int $page = 1): bool
+    /**
+     * Recherche par titre. Les fiches détaillées sont récupérées en parallèle
+     * afin d'obtenir genres, réalisateurs et bande-annonce.
+     *
+     * @throws TmdbApiException si la recherche elle-même échoue
+     */
+    public function search(string $titre, int $page = 1): TmdbSearchResult
     {
-        try {
-            $response = $this->tmdbClient->request("GET", "search/movie", [
-                "query" => [
-                    "query" => $titre,
-                    "page" => $page,
-                ],
-            ]);
+        $result = $this->client->search($titre, $page);
 
-            $this->content = $response->getContent();
-            $this->jsonDecode();
-        } catch (\Exception $e) {
-            return false;
+        $ids = [];
+        foreach ($result->movies as $movie) {
+            $ids[$movie->tmdbId] = $movie->tmdbId;
         }
-        return true;
-    }
+        $details = $this->client->getMovies($ids);
 
-    public function getDetailMovie(string $id): ?string
-    {
-        $detail = null;
-        try {
-            $response = $this->tmdbClient->request("GET", "movie/" . $id, [
-                "query" => [
-                    "append_to_response" => "credits,videos",
-                ],
-            ]);
-            $detail = $response->getContent();
-        } catch (\Exception $e) {
-            return null;
+        $movies = [];
+        foreach ($result->movies as $movie) {
+            $movies[$movie->tmdbId] = $details[$movie->tmdbId] ?? $movie;
         }
-        return $detail;
-    }
-
-    public function getFilms(): array
-    {
-        return $this->films;
-    }
-
-    public function countResults(): int
-    {
-        return $this->nbResults;
-    }
-
-    public function countPages(): int
-    {
-        return $this->nbPages;
-    }
 
-    protected function jsonDecode(): void
-    {
-        $json = json_decode($this->content);
-
-        $this->nbResults = $json->total_results;
-        $this->nbPages = $json->total_pages;
-        $entrees = $json->results;
-
-        foreach ($entrees as $entree) {
-            $film = $this->hydrateFilm($entree->id);
-
-            $this->films[$entree->id]["data"] = $film;
-        }
+        return $result->withMovies($movies);
     }
 
+    /**
+     * Construit une entité Film depuis la fiche TMDb correspondante.
+     *
+     * @throws TmdbApiException si la fiche est introuvable ou illisible
+     */
     public function hydrateFilm(int $filmTmdbId): Film
     {
-        $filmTmdb = json_decode($this->getDetailMovie($filmTmdbId));
-        $film = new Film();
-        $film->setTitre($filmTmdb->title)->setInformation($filmTmdb->overview);
-
-        if (preg_match("/^\d{4}-\d{2}-\d{2}$/", $filmTmdb->release_date)) {
-            $dateSortie = $filmTmdb->release_date;
-            $annee = substr($dateSortie, 0, 4) . "-01-01";
-            $film
-                ->setDateSortie(new \DateTimeImmutable($dateSortie))
-                ->setAnnee(new \DateTimeImmutable($annee));
-        } elseif (preg_match("/^\d{4}/", $filmTmdb->release_date)) {
-            $annee = substr($filmTmdb->release_date, 0, 4) . "-01-01";
-            $film->setAnnee(new \DateTimeImmutable($annee));
-        }
-
-        foreach ($filmTmdb->genres as $genre) {
-            $film->addGenre(new Genre()->setName($genre->name));
-        }
-
-        foreach ($this->getRealisateurs($filmTmdb) as $realisateur) {
-            $film->addRealisateur($realisateur);
-        }
-
-        $videoTmdb = $this->getOneVideo($filmTmdb);
-        if (!empty($videoTmdb)) {
-            $video = new MediaVideo();
-            $video->setFromTmdb($videoTmdb["type"], $videoTmdb["identif"]);
-            $film->addMediaVideo($video);
-        }
-
-        $film = $this->uc->assureUniciteCollections($film);
-        return $film;
+        return $this->filmFactory->createFilm($this->client->getMovie($filmTmdbId));
     }
 
     /**
-     * @return array<int, Realisateur>
+     * Construit une entité Film à partir du film TMDb fourni (issu d'une recherche).
      */
-    public function getRealisateurs(stdClass $detailMovie): array
+    public function createFilm(TmdbMovie $movie): Film
     {
-        $directors = [];
-        foreach ($detailMovie->credits->crew as $crew) {
-            if ($crew->job === "Director") {
-                $realisateur = new Realisateur();
-                $realisateur->setNomComplet($crew->name);
-                $directors[] = $realisateur;
-            }
-        }
-        return $directors;
+        return $this->filmFactory->createFilm($movie);
     }
 
     /**
-     * @return array<string, string>
+     * Variante non levante : renvoie null et logge l'erreur au lieu de lever.
      */
-    public function getOneVideo(stdClass $detailMovie): array
+    public function findFilm(int $filmTmdbId): ?Film
     {
-        foreach ($detailMovie->videos->results as $video) {
-            if (
-                ($video->site === "YouTube" ||
-                    $video->site === "Vimeo" ||
-                    $video->site === "DailyMotion") &&
-                $video->type === "Trailer" &&
-                $video->iso_639_1 === "fr" &&
-                $video->iso_3166_1 === "FR"
-            ) {
-                return [
-                    "type" => strtolower($video->site),
-                    "identif" => $video->key,
-                ];
-            }
+        try {
+            return $this->hydrateFilm($filmTmdbId);
+        } catch (TmdbApiException $e) {
+            return null;
         }
-        return [];
     }
 }

+ 13 - 13
templates/videotheque/add_tmdb.html.twig

@@ -52,31 +52,31 @@
                     <td colspan="5" class="text-center">Pas de résultat</td>
                 <tr>
             {% endif %}
-            {% for idtmdb, film in films %}
-                {% if film['filmBdd'] is defined %}
+            {% for idtmdb, item in films %}
+                {% if item.filmsBdd|length > 0 %}
                 <tr>
                     <td colspan="5">
                         <div class="alert alert-warning mb-0" role="alert">
-                            Ce film est peut-être déjà en base : 
-                            {% for filmBdd in film['filmBdd'] %}
+                            Ce film est peut-être déjà en base :
+                            {% for filmBdd in item.filmsBdd %}
                                 &nbsp;<a href="{{ path('videotheque_voirfilm', {'id': filmBdd.id} ) }}">Aller sur la fiche</a>
                             {% endfor %}
                         </div>
                     </td>
                 </tr>
                 {% endif %}
-                <tr class="{{ film['data'].information ? '' : 'border-bottom' }}">
+                <tr class="{{ item.film.information ? '' : 'border-bottom' }}">
                     <td>
-                        <a type="button" class="btn btn-{{ film['filmBdd'] is defined ? 'warning' : 'primary' }}" href="{{ path('videotheque_ajouter', {'idtmdb': idtmdb }) }}">
-                            Créér{{ film['filmBdd'] is defined ? ' quand même' : '' }}
+                        <a type="button" class="btn btn-{{ item.filmsBdd|length > 0 ? 'warning' : 'primary' }}" href="{{ path('videotheque_ajouter', {'idtmdb': idtmdb }) }}">
+                            Créér{{ item.filmsBdd|length > 0 ? ' quand même' : '' }}
                         </a>
                     </td>
-                    <td>{{ film['data'].titre }}</td>
-                    <td>{% if film['data'].dateSortie %}{{ film['data'].dateSortie | date ('d/m/Y')}}{% endif %}</td>
-                    <td>{% for genre in film['data'].genres %}<span class="badge bg-info me-1">{{ genre.name }}</span>{% endfor %}</td>
-                    <td>{% for realisateur in film['data'].realisateurs %}<span class="badge bg-info me-1">{{ realisateur.nomComplet }}</span>{% endfor %}</td>
+                    <td>{{ item.film.titre }}</td>
+                    <td>{% if item.film.dateSortie %}{{ item.film.dateSortie | date ('d/m/Y')}}{% endif %}</td>
+                    <td>{% for genre in item.film.genres %}<span class="badge bg-info me-1">{{ genre.name }}</span>{% endfor %}</td>
+                    <td>{% for realisateur in item.film.realisateurs %}<span class="badge bg-info me-1">{{ realisateur.nomComplet }}</span>{% endfor %}</td>
                 </tr>
-                {% if film['data'].information %}
+                {% if item.film.information %}
                     <tr data-controller="collapser" class="border-bottom">
                         <td colspan="5">
                             <div class="d-flex">
@@ -86,7 +86,7 @@
                                 </a>
                             </div>
                             <div id="collapseResume-{{ idtmdb }}" class="collapse" data-collapser-target="collapse">
-                                {{ film['data'].information }}
+                                {{ item.film.information }}
                             </div>
                         </td>
                     </tr>