Files
Mangarr/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php
ext.jeremy.guillot@maxicoffee.domains fb8f64ee59 feat(manga): regrouper les chapitres d'un volume importé dans la liste API
Les chapitres partageant le même pagesDirectory non-null et le même volume
non-null (import CBZ en bloc) sont fusionnés en un seul item isVolumeGroup=true
côté Application, avec volumeChaptersRange et volumeChapterCount. Le frontend
affiche "Vol. X — Chapitres Y-Z" à la place de N lignes identiques.
2026-03-15 19:21:02 +01:00

444 lines
17 KiB
PHP

<?php
namespace App\Domain\Manga\Infrastructure\Persistence;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
use App\Entity\Manga as EntityManga;
use Doctrine\ORM\EntityManagerInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Entity\Chapter as EntityChapter;
use DateTime;
readonly class LegacyMangaRepository implements MangaRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array
{
$offset = ($page - 1) * $limit;
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('m')
->from(EntityManga::class, 'm')
->orderBy("m.$sortBy", $sortOrder)
->setFirstResult($offset)
->setMaxResults($limit);
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
public function count(): int
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(m.id)')
->from(EntityManga::class, 'm')
->getQuery()
->getSingleScalarResult();
}
public function findById(string $id): ?DomainManga
{
// Convertir le string ID en integer pour la base de données
if (!is_numeric($id)) {
return null;
}
$entity = $this->entityManager->find(EntityManga::class, (int) $id);
return $entity ? $this->toDomain($entity) : null;
}
public function findBySlug(MangaSlug $slug): ?DomainManga
{
$entity = $this->entityManager->getRepository(EntityManga::class)
->findOneBy(['slug' => $slug->getValue()]);
return $entity ? $this->toDomain($entity) : null;
}
public function save(DomainManga $manga): void
{
// Check if this is an update (manga has a numeric ID) or a new creation
$entity = null;
if ($manga->getId() && $manga->getId()->getValue() && is_numeric($manga->getId()->getValue())) {
$entity = $this->entityManager->find(EntityManga::class, (int) $manga->getId()->getValue());
}
if (!$entity) {
$entity = new EntityManga();
}
$imageUrls = $manga->getImageUrls();
$fullImageUrl = $imageUrls?->getFull();
$thumbnailUrl = $imageUrls?->getThumbnail();
$entity->setTitle($manga->getTitle()->getValue())
->setSlug($manga->getSlug()->getValue())
->setDescription($manga->getDescription())
->setAuthor($manga->getAuthor())
->setPublicationYear($manga->getPublicationYear())
->setGenres($manga->getGenres())
->setStatus($manga->getStatus())
->setImageUrl($fullImageUrl ?? null)
->setThumbnailUrl($thumbnailUrl ?? null)
->setAlternativeSlugs($manga->getAlternativeSlugs())
->setMonitored($manga->isMonitoringEnabled())
->setLastMonitoringCheck($manga->getLastMonitoringCheck());
// Only set externalId if it exists (to avoid setting null on update)
if ($manga->getExternalId()) {
$entity->setExternalId($manga->getExternalId()->getValue());
}
if ($manga->getRating() !== null) {
$entity->setRating($manga->getRating());
}
$this->entityManager->persist($entity);
$this->entityManager->flush();
// Met à jour l'ID du modèle du domaine avec l'ID généré par la BDD
if ($entity->getId()) {
$manga->updateId(new MangaId((string) $entity->getId()));
}
// Handle new chapters added through the aggregate
foreach ($manga->pullNewChapters() as $chapter) {
$mangaEntity = $this->entityManager->find(EntityManga::class, (int) $manga->getId()->getValue());
$chapterEntity = new EntityChapter();
$chapterEntity->setManga($mangaEntity)
->setNumber($chapter->getNumber())
->setTitle($chapter->getTitle())
->setVolume($chapter->getVolume())
->setVisible($chapter->isVisible())
->setPagesDirectory($chapter->getPagesDirectory())
->setPageCount($chapter->getPageCount());
$this->entityManager->persist($chapterEntity);
}
// Handle chapters modified through the aggregate
foreach ($manga->pullModifiedChapters() as $chapter) {
$chapterEntity = $this->entityManager->find(EntityChapter::class, $chapter->getId());
if ($chapterEntity) {
$chapterEntity->setVisible($chapter->isVisible())
->setPagesDirectory($chapter->getPagesDirectory())
->setPageCount($chapter->getPageCount())
->setTitle($chapter->getTitle())
->setVolume($chapter->getVolume())
->setCbzPath($chapter->getPagesDirectory());
$this->entityManager->persist($chapterEntity);
}
}
// Handle chapters deleted through the aggregate
foreach ($manga->pullChaptersToDelete() as $chapter) {
$chapterEntity = $this->entityManager->find(EntityChapter::class, $chapter->getId());
if ($chapterEntity) {
$this->entityManager->remove($chapterEntity);
}
}
$this->entityManager->flush();
}
public function delete(DomainManga $manga): void
{
$entity = $this->entityManager->find(EntityManga::class, $manga->getId()->getValue());
if ($entity) {
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
}
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array
{
$offset = ($page - 1) * $limit;
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->orderBy('c.number', $sortOrder)
->setParameter('mangaId', $mangaId)
->setFirstResult($offset)
->setMaxResults($limit);
return array_map(
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->orderBy('c.number', $sortOrder)
->setParameter('mangaId', $mangaId);
return array_map(
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
public function countChapters(string $mangaId): int
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(c.id)')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->setParameter('mangaId', $mangaId)
->getQuery()
->getSingleScalarResult();
}
public function countAvailableChapters(string $mangaId): int
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(c.id)')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
->setParameter('mangaId', $mangaId)
->getQuery()
->getSingleScalarResult();
}
public function findByExternalId(ExternalId $externalId): ?DomainManga
{
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
'externalId' => $externalId->getValue()
]);
return $entity ? $this->toDomain($entity) : null;
}
public function findChapterById(string $id): ?Chapter
{
$entity = $this->entityManager->find(EntityChapter::class, $id);
return $entity ? $this->toChapterDomain($entity) : null;
}
public function findVisibleChapterById(string $id): ?Chapter
{
$entity = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.id = :id')
->andWhere('c.visible = :visible')
->setParameter('id', $id)
->setParameter('visible', 1)
->getQuery()
->getOneOrNullResult();
return $entity ? $this->toChapterDomain($entity) : null;
}
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter
{
$entity = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.number = :chapterNumber')
->setParameter('mangaId', $mangaId)
->setParameter('chapterNumber', $chapterNumber)
->getQuery()
->getOneOrNullResult();
return $entity ? $this->toChapterDomain($entity) : null;
}
public function findChaptersByMangaIdAndVolume(string $mangaId, int $volume): array
{
$entities = $this->entityManager->getRepository(EntityChapter::class)
->findBy(['manga' => $mangaId, 'volume' => $volume]);
return array_map([$this, 'toChapterDomain'], $entities);
}
public function findVisibleChaptersByMangaIdAndVolume(string $mangaId, int $volume): array
{
$entities = $this->entityManager->getRepository(EntityChapter::class)
->findBy(['manga' => $mangaId, 'volume' => $volume, 'visible' => true]);
return array_map([$this, 'toChapterDomain'], $entities);
}
public function findVisibleChaptersWithPagesByMangaIdAndVolume(string $mangaId, int $volume): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.volume = :volume')
->andWhere('c.visible = true')
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
->setParameter('mangaId', $mangaId)
->setParameter('volume', $volume)
->orderBy('c.number', 'ASC')
->getQuery()
->getResult();
return array_map([$this, 'toChapterDomain'], $entities);
}
public function search(string $query, int $page = 1, int $limit = 20): array
{
$offset = ($page - 1) * $limit;
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
$sql = "SELECT m.* FROM manga m
WHERE m.title LIKE :query
OR m.slug LIKE :query
OR CAST(m.alternative_slugs AS TEXT) LIKE :query
ORDER BY m.title ASC
LIMIT :limit OFFSET :offset";
$rsm = new \Doctrine\ORM\Query\ResultSetMapping();
$rsm->addEntityResult(EntityManga::class, 'm');
$rsm->addFieldResult('m', 'id', 'id');
$rsm->addFieldResult('m', 'title', 'title');
$rsm->addFieldResult('m', 'slug', 'slug');
$rsm->addFieldResult('m', 'image_url', 'imageUrl');
$rsm->addFieldResult('m', 'publication_year', 'publicationYear');
$rsm->addFieldResult('m', 'description', 'description');
$rsm->addFieldResult('m', 'genres', 'genres');
$rsm->addFieldResult('m', 'created_at', 'createdAt');
$rsm->addFieldResult('m', 'rating', 'rating');
$rsm->addFieldResult('m', 'author', 'author');
$rsm->addFieldResult('m', 'external_id', 'externalId');
$rsm->addFieldResult('m', 'status', 'status');
$rsm->addFieldResult('m', 'thumbnail_url', 'thumbnailUrl');
$rsm->addFieldResult('m', 'monitored', 'monitored');
$rsm->addFieldResult('m', 'last_monitoring_check', 'lastMonitoringCheck');
$rsm->addFieldResult('m', 'alternative_slugs', 'AlternativeSlugs');
$nativeQuery = $this->entityManager->createNativeQuery($sql, $rsm);
$nativeQuery->setParameter('query', '%' . $query . '%');
$nativeQuery->setParameter('limit', $limit);
$nativeQuery->setParameter('offset', $offset);
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$nativeQuery->getResult()
);
}
public function countSearch(string $query): int
{
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
$sql = "SELECT COUNT(m.id) FROM manga m
WHERE m.title LIKE :query
OR m.slug LIKE :query
OR m.author LIKE :query
OR m.description LIKE :query
OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
$conn = $this->entityManager->getConnection();
$stmt = $conn->prepare($sql);
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
return (int) $result->fetchOne();
}
/**
* @param float[] $chapterNumbers
* @return array<float, Chapter>
*/
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.number IN (:chapterNumbers)')
->setParameter('mangaId', $mangaId)
->setParameter('chapterNumbers', array_map('floatval', $chapterNumbers));
$chapters = $queryBuilder->getQuery()->getResult();
$chaptersByNumber = [];
foreach ($chapters as $chapter) {
$chaptersByNumber[(float) $chapter->getNumber()] = $this->toChapterDomain($chapter);
}
return $chaptersByNumber;
}
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('m')
->from(EntityManga::class, 'm')
->where('m.monitored = :enabled')
->setParameter('enabled', $criteria->enabled);
if ($criteria->lastCheckBefore) {
$queryBuilder->andWhere('(m.lastMonitoringCheck IS NULL OR m.lastMonitoringCheck < :lastCheckBefore)')
->setParameter('lastCheckBefore', $criteria->lastCheckBefore);
}
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
private function toDomain(EntityManga $entity): DomainManga
{
return new DomainManga(
id: new MangaId((string) $entity->getId()),
title: new MangaTitle($entity->getTitle()),
slug: new MangaSlug($entity->getSlug()),
description: $entity->getDescription() ?? '',
author: $entity->getAuthor() ?? '',
publicationYear: $entity->getPublicationYear() ?? 0,
genres: $entity->getGenres() ?? [],
status: $entity->getStatus() ?? '',
externalId: $entity->getExternalId() ? new ExternalId($entity->getExternalId()) : null,
imageUrl: $entity->getImageUrl(),
rating: $entity->getRating(),
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
createdAt: $entity->getCreatedAt(),
monitoringStatus: $entity->isMonitored() ? MonitoringStatus::enabled() : MonitoringStatus::disabled()
);
}
private function toChapterDomain(EntityChapter $entity): Chapter
{
return new Chapter(
id: new ChapterId((string) $entity->getId()),
mangaId: new MangaId((string) $entity->getManga()->getId()),
number: $entity->getNumber(),
title: $entity->getTitle(),
volume: $entity->getVolume(),
isVisible: $entity->isVisible(),
// Fallback to cbzPath during transition (Phase 4 will drop cbzPath column)
pagesDirectory: $entity->getPagesDirectory() ?? $entity->getCbzPath(),
pageCount: $entity->getPageCount(),
);
}
}